mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:01:34 +00:00
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:
@@ -128,12 +128,39 @@ export default function DiscoveryPage() {
|
|||||||
refetchInterval: 30000,
|
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({
|
const { data: scansData } = useQuery({
|
||||||
queryKey: ['discovery-scans'],
|
queryKey: ['discovery-scans'],
|
||||||
queryFn: () => getDiscoveryScans(),
|
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({
|
const { data: agentsData } = useQuery({
|
||||||
queryKey: ['agents-for-filter'],
|
queryKey: ['agents-for-filter'],
|
||||||
queryFn: () => getAgents({ per_page: '200' }),
|
queryFn: () => getAgents({ per_page: '200' }),
|
||||||
@@ -300,6 +327,59 @@ export default function DiscoveryPage() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader title="Certificate Discovery" subtitle={data ? `${data.total} discovered certificates` : undefined} />
|
<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 stats bar */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="px-6 py-3 flex gap-4 border-b border-surface-border/50">
|
<div className="px-6 py-3 flex gap-4 border-b border-surface-border/50">
|
||||||
|
|||||||
Reference in New Issue
Block a user