feat(discovery): close P-M1 — in-flight scan progress panel on DiscoveryPage

Closes frontend-design-audit finding P-M1 (Med):

  DiscoveryPage doesn't show real-time scan progress — operator who
  just kicked off a scan must navigate to NetworkScanPage to see
  if it's running

Operator choice (2026-05-14): poll-and-render over SSE / WebSocket.
Rationale recorded in the source comment: zero new transport
infrastructure to maintain; reuses the existing TanStack Query
plumbing. SSE / WebSocket were the alternative paths but neither
is currently used anywhere else in the codebase (grep -rn
"text/event-stream|EventSource|websocket" returned zero hits), so
adopting one for a single Medium finding would be disproportionate.

═══════════════════════════ CHANGES ═══════════════════════════════

web/src/pages/DiscoveryPage.tsx:
  • Dropped the `enabled: showScans` gate on the ['discovery-scans']
    query. The query is now always-on, so the new in-flight panel
    has data to render without operator interaction.
  • Refetch cadence flips between 2.5s and 30s via a function-shape
    refetchInterval that introspects the query's most-recent data:
      anyInFlight = scans.some(s => !s.completed_at)
      return anyInFlight ? 2500 : 30000
    domain.DiscoveryScan.CompletedAt is *time.Time (nullable
    pointer) — nil while the agent is still scanning, set when the
    agent posts its DiscoveryReport. When the last running scan
    finishes, the next 2.5s tick sees no in-flight rows and the
    interval flips back to 30s automatically.
  • Derived `inFlightScans = scans.data.filter(!completed_at)` —
    drives both the visibility gate (panel doesn't render when
    empty) and the row count badge.
  • New panel renders ABOVE the existing summary tiles:
    - Amber background, animated ping dot, role=status + aria-live=
      polite so screen readers announce status changes.
    - "{N} scan(s) in progress" header + per-scan row showing
      agent_id, directories count, started_at (formatDateTime), and
      certificates_found-so-far.
    - data-testid hooks: discovery-inflight-panel +
      discovery-inflight-row-<id> for QA + future Playwright.

No backend changes — getDiscoveryScans() endpoint already returns
the complete DiscoveryScan shape including the nullable
completed_at field. The closure is pure frontend.

═══════════════════════════ AUDIT FRAMING ════════════════════════

The audit said "real-time scan progress" but the operator chose
the practical interpretation — sub-3-second update latency for an
operator visiting the page, not push-based streaming. The poll
cadence is high enough that an operator clicking from
NetworkScanPage to DiscoveryPage sees in-flight signal within the
first refetch tick (the dashboard's pre-existing 30s polling drops
to 2.5s the moment the first in-flight scan is observed).

═══════════════════════════ VERIFICATION ═══════════════════════════

  • npx tsc --noEmit — exit 0
  • npx vitest run DiscoveryPage AuditPage — 7/7 pass
  • npx vite build — built in 3.31s
  • CI guards: no-raw-table baseline 17/17, no-unbound-label 134/134,
    no-raw-toLocaleString clean (the new <ul>/<li> rows don't add
    raw tables; the panel uses Phase 6's formatDateTime for the
    timestamp so no-raw-toLocaleString stays clean).

Ground-truth: origin/master tip fc237de (P-H2 just pushed)
verified via GitHub API BEFORE commit.
This commit is contained in:
shankar0123
2026-05-14 19:43:14 +00:00
parent fc237de357
commit ac5bb71b61
+81 -1
View File
@@ -128,12 +128,39 @@ export default function DiscoveryPage() {
refetchInterval: 30000,
});
// P-M1 closure (frontend-design-audit 2026-05-14): always-on
// scans query so the "in-flight scans" panel below renders without
// requiring the operator to click "Show Scan History" first. The
// pre-P-M1 gate `enabled: showScans` made the audit's stated
// problem possible — an operator kicked off a scan from
// NetworkScanPage, navigated back to DiscoveryPage, and saw no
// signal that the scan was running.
//
// Refetch cadence flips between 2.5s (fast) when ANY scan is
// in-flight and 30s (slow) when none are. "In-flight" =
// completed_at is null/undefined on the DiscoveryScan record
// (domain.DiscoveryScan.CompletedAt is *time.Time — nil while the
// agent is still scanning). When the last running scan finishes,
// the next refetch returns it with completed_at set; the very
// next interval flips back to slow polling, no manual intervention.
//
// Operator chose poll over SSE/WebSocket on 2026-05-14: no new
// transport infrastructure to maintain; reuses the existing
// TanStack Query plumbing.
const { data: scansData } = useQuery({
queryKey: ['discovery-scans'],
queryFn: () => getDiscoveryScans(),
enabled: showScans,
refetchInterval: (query) => {
const scans = (query.state.data?.data ?? []) as DiscoveryScan[];
const anyInFlight = scans.some((s) => !s.completed_at);
return anyInFlight ? 2500 : 30000;
},
});
// Derive the in-flight subset for the new panel (and to gate
// panel visibility — empty array → panel doesn't render).
const inFlightScans = (scansData?.data ?? []).filter((s) => !s.completed_at);
const { data: agentsData } = useQuery({
queryKey: ['agents-for-filter'],
queryFn: () => getAgents({ per_page: '200' }),
@@ -300,6 +327,59 @@ export default function DiscoveryPage() {
<>
<PageHeader title="Certificate Discovery" subtitle={data ? `${data.total} discovered certificates` : undefined} />
{/* P-M1 closure: in-flight scan panel. Renders ABOVE the summary
tiles so an operator who just kicked off a scan from
NetworkScanPage sees immediate progress on return, without
having to expand "Scan History" or navigate back to
NetworkScanPage. Panel auto-hides when no scans are
in-flight; the refetchInterval on the underlying query
flips to 2.5s while this panel is visible so the operator
sees updates with sub-3-second latency. */}
{inFlightScans.length > 0 && (
<div
className="px-6 py-3 border-b border-surface-border/50 bg-amber-50"
role="status"
aria-live="polite"
data-testid="discovery-inflight-panel"
>
<div className="flex items-center gap-2 mb-2">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
</span>
<span className="text-sm font-semibold text-amber-900">
{inFlightScans.length} scan{inFlightScans.length === 1 ? '' : 's'} in progress
</span>
<span className="text-xs text-amber-800/70">
Auto-refreshing every 2.5s while running
</span>
</div>
<ul className="space-y-1">
{inFlightScans.map((s) => (
<li
key={s.id}
className="flex items-center gap-3 text-xs text-amber-900"
data-testid={`discovery-inflight-row-${s.id}`}
>
<span className="font-mono">{s.agent_id}</span>
<span className="text-amber-800/80">·</span>
<span className="text-amber-800/80">
{s.directories?.length || 0} {s.directories?.length === 1 ? 'directory' : 'directories'}
</span>
<span className="text-amber-800/80">·</span>
<span className="text-amber-800/80">
started {formatDateTime(s.started_at)}
</span>
<span className="text-amber-800/80">·</span>
<span className="text-amber-900">
{s.certificates_found} found so far
</span>
</li>
))}
</ul>
</div>
)}
{/* Summary stats bar */}
{summary && (
<div className="px-6 py-3 flex gap-4 border-b border-surface-border/50">