Skip to content

Commit 23b9349

Browse files
Ripwordsclaude
andcommitted
feat(ui): inflight toast + clamav scan visibility
Adds an indeterminate-progress toast that surfaces while the report upload is in flight. When the report carries user attachments the toast calls out that they're being scanned ("Scanning N attachments & sending report…") so users know the wizard hasn't frozen during a scan that can take several seconds. Toast is shown for all file types — image risk is small, but consistent UX beats a confusing inconsistency. Operator visibility into ClamAV: - Structured logs in clamav.ts: scan start (size), scan verdict (clean / infected:reason / error) with duration in ms - Repro-Scan-Enabled response header on intake 201 + scanEnabled in the JSON body so the SDK / DevTools can confirm whether scanning was on Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7971214 commit 23b9349

6 files changed

Lines changed: 185 additions & 1 deletion

File tree

apps/dashboard/server/api/intake/reports.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,10 @@ export default defineEventHandler(async (event) => {
438438
}
439439

440440
event.node.res.statusCode = 201
441+
event.node.res.setHeader("Repro-Scan-Enabled", env.INTAKE_USER_FILE_SCAN_ENABLED ? "1" : "0")
441442
return {
442443
id: report.id,
444+
scanEnabled: env.INTAKE_USER_FILE_SCAN_ENABLED,
443445
...(replayPart ? { replayStored, replayDisabled } : {}),
444446
}
445447
})

apps/dashboard/server/lib/clamav.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,21 @@ export async function scanBytes(bytes: Uint8Array): Promise<ScanResult> {
6363
)
6464
}
6565
const stream = Readable.from(Buffer.from(bytes))
66+
const start = Date.now()
67+
console.info(`[clamav] scan start size=${bytes.byteLength}`)
6668
try {
6769
const { isInfected, viruses } = await client.scanStream(stream)
68-
if (isInfected) return { clean: false, reason: viruses?.[0] ?? "infected" }
70+
const duration = Date.now() - start
71+
if (isInfected) {
72+
const reason = viruses?.[0] ?? "infected"
73+
console.warn(`[clamav] scan verdict=infected:${reason} duration=${duration}ms`)
74+
return { clean: false, reason }
75+
}
76+
console.info(`[clamav] scan verdict=clean duration=${duration}ms`)
6977
return { clean: true }
7078
} catch (err) {
79+
const duration = Date.now() - start
80+
console.error(`[clamav] scan error duration=${duration}ms`, err)
7181
// Reset the cached client so the next call re-inits — handles transient
7282
// socket drops cleanly when clamd restarts.
7383
_client = null

packages/ui/src/reporter.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { DEFAULT_ATTACHMENT_LIMITS, validateAttachments, type Attachment } from
55
import { StepAnnotate } from "./wizard/step-annotate"
66
import { StepDetails } from "./wizard/step-details"
77
import { StepReview, type SummaryLine } from "./wizard/step-review"
8+
import { SubmitToast } from "./wizard/submit-toast"
89
import { PrimaryButton, SecondaryButton, WizardHeader } from "./wizard/controls"
910

1011
export interface ReporterSubmitResult {
@@ -274,6 +275,7 @@ export function Reporter({ onClose, onCapture, onSubmit, openedAt }: ReporterPro
274275
{ class: "ft-wizard" },
275276
h(WizardHeader, headerProps),
276277
body,
278+
h(SubmitToast, { visible: submitting, attachmentCount: attachments.length }),
277279
h(
278280
"footer",
279281
{ class: "ft-wizard-footer" },

packages/ui/src/styles-inline.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,74 @@ export default String.raw`:host,
403403
font-variant-numeric: tabular-nums;
404404
}
405405
406+
/* === Submit toast === */
407+
.ft-toast {
408+
position: absolute;
409+
top: 100px;
410+
right: 24px;
411+
background: var(--ft-color-bg);
412+
border: 1px solid var(--ft-color-border);
413+
border-radius: var(--ft-radius-md);
414+
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.18);
415+
padding: 12px 14px;
416+
display: flex;
417+
flex-direction: column;
418+
gap: 10px;
419+
min-width: 260px;
420+
max-width: 320px;
421+
z-index: 2;
422+
animation: ft-toast-in 200ms ease-out;
423+
}
424+
@keyframes ft-toast-in {
425+
from {
426+
transform: translateY(-8px);
427+
opacity: 0;
428+
}
429+
to {
430+
transform: translateY(0);
431+
opacity: 1;
432+
}
433+
}
434+
.ft-toast-row {
435+
display: flex;
436+
align-items: center;
437+
gap: 10px;
438+
font-size: 13px;
439+
font-weight: 500;
440+
color: var(--ft-color-text);
441+
line-height: 1.3;
442+
}
443+
.ft-toast-icon {
444+
font-size: 16px;
445+
flex-shrink: 0;
446+
}
447+
.ft-toast-progress {
448+
height: 4px;
449+
background: var(--ft-color-surface);
450+
border-radius: 2px;
451+
overflow: hidden;
452+
position: relative;
453+
}
454+
.ft-toast-progress::before {
455+
content: "";
456+
position: absolute;
457+
top: 0;
458+
left: 0;
459+
bottom: 0;
460+
width: 35%;
461+
background: var(--ft-color-primary);
462+
border-radius: 2px;
463+
animation: ft-toast-stripe 1.2s ease-in-out infinite;
464+
}
465+
@keyframes ft-toast-stripe {
466+
0% {
467+
transform: translateX(-100%);
468+
}
469+
100% {
470+
transform: translateX(286%);
471+
}
472+
}
473+
406474
/* === Inline messages === */
407475
.ft-msg {
408476
font-size: 12px;

packages/ui/src/styles.css

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,74 @@
400400
font-variant-numeric: tabular-nums;
401401
}
402402

403+
/* === Submit toast === */
404+
.ft-toast {
405+
position: absolute;
406+
top: 100px;
407+
right: 24px;
408+
background: var(--ft-color-bg);
409+
border: 1px solid var(--ft-color-border);
410+
border-radius: var(--ft-radius-md);
411+
box-shadow: 0 12px 28px -12px rgba(0, 0, 0, 0.18);
412+
padding: 12px 14px;
413+
display: flex;
414+
flex-direction: column;
415+
gap: 10px;
416+
min-width: 260px;
417+
max-width: 320px;
418+
z-index: 2;
419+
animation: ft-toast-in 200ms ease-out;
420+
}
421+
@keyframes ft-toast-in {
422+
from {
423+
transform: translateY(-8px);
424+
opacity: 0;
425+
}
426+
to {
427+
transform: translateY(0);
428+
opacity: 1;
429+
}
430+
}
431+
.ft-toast-row {
432+
display: flex;
433+
align-items: center;
434+
gap: 10px;
435+
font-size: 13px;
436+
font-weight: 500;
437+
color: var(--ft-color-text);
438+
line-height: 1.3;
439+
}
440+
.ft-toast-icon {
441+
font-size: 16px;
442+
flex-shrink: 0;
443+
}
444+
.ft-toast-progress {
445+
height: 4px;
446+
background: var(--ft-color-surface);
447+
border-radius: 2px;
448+
overflow: hidden;
449+
position: relative;
450+
}
451+
.ft-toast-progress::before {
452+
content: "";
453+
position: absolute;
454+
top: 0;
455+
left: 0;
456+
bottom: 0;
457+
width: 35%;
458+
background: var(--ft-color-primary);
459+
border-radius: 2px;
460+
animation: ft-toast-stripe 1.2s ease-in-out infinite;
461+
}
462+
@keyframes ft-toast-stripe {
463+
0% {
464+
transform: translateX(-100%);
465+
}
466+
100% {
467+
transform: translateX(286%);
468+
}
469+
}
470+
403471
/* === Inline messages === */
404472
.ft-msg {
405473
font-size: 12px;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { h } from "preact"
2+
3+
interface Props {
4+
visible: boolean
5+
attachmentCount: number
6+
}
7+
8+
/**
9+
* Inflight toast shown while the report is being uploaded. When the report
10+
* carries user attachments we hint that those are being scanned, since
11+
* server-side processing can run several seconds when a virus scan is
12+
* enabled — reassuring the user that the wizard hasn't frozen.
13+
*
14+
* The progress bar is intentionally indeterminate: we have no granular
15+
* progress signal from a single multipart POST, only "still in flight".
16+
*/
17+
export function SubmitToast({ visible, attachmentCount }: Props) {
18+
if (!visible) return null
19+
const message =
20+
attachmentCount > 0
21+
? `Scanning ${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"} & sending report…`
22+
: "Sending report…"
23+
return h(
24+
"div",
25+
{ class: "ft-toast", role: "status", "aria-live": "polite" },
26+
h(
27+
"div",
28+
{ class: "ft-toast-row" },
29+
h("span", { class: "ft-toast-icon", "aria-hidden": "true" }, "🛡️"),
30+
h("span", null, message),
31+
),
32+
h("div", { class: "ft-toast-progress", "aria-hidden": "true" }),
33+
)
34+
}

0 commit comments

Comments
 (0)