Make starting a scan a button in the header, and drop the box it lived in

"New scan" sits beside Quarantine as the screen's one action, in the
primary colour, on every tab. The "Files already here" box it replaces
is gone: it held an action under a Save button, a counter that the
Activity tab now shows live, and a field that belongs with the other
settings, which is where it is now.

The button says why when it cannot be pressed — scanning is off, every
file has already been checked, or a scan is already running — rather
than sitting grey with no explanation. It refuses a second scan while
one is working through the queue, which is a thing somebody would
otherwise do by clicking twice.

Starting one lands on the Activity tab. A button whose screen looks
unchanged afterwards reads as a button that did nothing, and this one
has somewhere worth looking.

Checked against a real ClamAV: 42 files queued, 31 came back clean, the
rest were the ones whose bytes are missing. The button then disabled
itself, because there was nothing left to scan.
This commit is contained in:
ignacionelson
2026-09-16 22:52:08 -03:00
parent f937b4398d
commit d445b01dd4
3 changed files with 59 additions and 35 deletions
@@ -18,6 +18,7 @@ use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Queue;
@@ -188,9 +189,14 @@ class VirusScanningSettingsController extends Controller
{
abort_unless($this->config->enabled(), 422);
\Illuminate\Support\Facades\Artisan::queue('projectsend:scan-files', ['--existing' => true]);
Artisan::queue('projectsend:scan-files', ['--existing' => true]);
return back()->with('success', __('Scanning existing files has started. It runs in the background.'));
// Onto the tab that shows it happening rather than back where they
// were: somebody who just started a scan wants to watch it, and a
// screen that looks unchanged reads as a button that did nothing.
return redirect()
->route('system-settings.virus-scanning.edit', ['tab' => 'activity'])
->with('success', __('The scan has started.'));
}
/**
@@ -294,6 +300,9 @@ class VirusScanningSettingsController extends Controller
NotScannedReason::Encrypted->value,
])
->count(),
// So the New scan button can refuse a second scan while one is
// still working through the queue.
'queued' => Queue::size('scans'),
];
}
@@ -98,15 +98,35 @@ export default function VirusScanningSettings({
it — the quarantine screen answers 403 otherwise,
and a button that leads to a refusal is worse than
no button. */}
{auth.permissions.includes('release_quarantined_files') && (
<Button variant="outline" asChild>
<Link href={route('files.quarantine')}>
{counts.quarantined > 0
? t('Quarantine (:count)', { count: counts.quarantined })
: t('Quarantine')}
</Link>
<div className="flex items-start gap-2">
{auth.permissions.includes('release_quarantined_files') && (
<Button variant="outline" asChild>
<Link href={route('files.quarantine')}>
{counts.quarantined > 0
? t('Quarantine (:count)', { count: counts.quarantined })
: t('Quarantine')}
</Link>
</Button>
)}
{/* The screen's one action, where an action belongs.
Disabled rather than hidden when there is nothing
to do, so it can say why. */}
<Button
type="button"
disabled={!enabled || counts.never_scanned === 0}
title={
!enabled
? t('Switch scanning on first.')
: counts.never_scanned === 0
? t('Every file has already been checked.')
: t(':count files have never been checked.', { count: counts.never_scanned })
}
onClick={() => router.post(route('system-settings.virus-scanning.scan-existing'), {}, { preserveScroll: false })}
>
{t('New scan')}
</Button>
)}
</div>
</div>
{counts.let_through > 0 && (
@@ -214,30 +234,6 @@ export default function VirusScanningSettings({
{tab === 'options' && (
<div className="max-w-xl space-y-6">
<div className="space-y-3 rounded-lg border p-4">
<HeadingSmall
title={t('Files already here')}
description={t('Anything uploaded before scanning was switched on has never been checked.')}
/>
<p className="text-muted-foreground text-sm">
{t('Never scanned: :never · Being checked: :pending · In quarantine: :quarantined', {
never: counts.never_scanned,
pending: counts.pending,
quarantined: counts.quarantined,
})}
</p>
<Button
type="button"
variant="outline"
disabled={!enabled || counts.never_scanned === 0}
onClick={() => router.post(route('system-settings.virus-scanning.scan-existing'), {}, { preserveScroll: true })}
>
{t('Scan existing files')}
</Button>
</div>
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="max_size_mb">{t('Largest file to scan (MB)')}</Label>
@@ -325,7 +321,7 @@ export default function VirusScanningSettings({
onChange={(e) => setData('existing_rate_per_minute', Number(e.target.value))}
/>
<p className="text-muted-foreground text-sm">
{t('Applies to the button above, so a backfill does not starve the scanner of new uploads.')}
{t('The pace of a New scan, so working through a whole library does not starve the scanner of new uploads.')}
</p>
<InputError message={errors.existing_rate_per_minute} />
</div>
@@ -389,3 +389,22 @@ test('a backfill counts as running even though it holds nothing back', function
->and($body['queued'])->toBe(1)
->and($body['running'])->toBeTrue();
});
test('starting a scan lands on the tab that shows it happening', function () {
// A button whose screen looks unchanged afterwards reads as a button
// that did nothing.
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/scan-existing')
->assertRedirect(route('system-settings.virus-scanning.edit', ['tab' => 'activity']));
});
test('the screen says whether a scan is already under way', function () {
Illuminate\Support\Facades\Queue::fake();
App\Modules\Files\Jobs\ScanFileJob::dispatch(1, true);
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
fn (AssertableInertia $page) => $page->where('counts.queued', 1),
);
});