From 9cfb968081f4c40e8ffac51e2d5b23322c7cd269 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sun, 5 Jul 2026 23:00:50 -0400 Subject: [PATCH] fix: add fingerprint-based dismiss to post-deploy scan banner Created useScanBannerDismiss hook following the usePreflightDismiss pattern. The banner now persists dismissal in localStorage keyed to a fingerprint of scan status + attemptedAt. Dismissal survives page reloads for the same scan outcome, and the banner automatically reappears when a new scan runs or the status changes. --- frontend/src/components/StackAnatomyPanel.tsx | 15 ++++- frontend/src/hooks/useScanBannerDismiss.ts | 60 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 frontend/src/hooks/useScanBannerDismiss.ts diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 5a5a38ac..89bcf00e 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react'; +import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen, X } from 'lucide-react'; import { Button } from './ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { ScrollableTabRow } from './ui/ScrollableTabRow'; @@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown'; import { usePreflightDismiss } from '@/hooks/usePreflightDismiss'; +import { useScanBannerDismiss } from '@/hooks/useScanBannerDismiss'; import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy'; import { buildServiceUrl } from '@/lib/serviceUrl'; import { StackActivityTimeline } from './stack/StackActivityTimeline'; @@ -126,6 +127,8 @@ export default function StackAnatomyPanel({ attemptedAt?: number; errorMessage?: string | null; } | null>(null); + const { dismissed: scanBannerDismissed, dismiss: dismissScanBanner } = + useScanBannerDismiss(stackName, activeNode?.id, scanStatus); // Best-effort badge: read the last stored preflight severity to dot the tab. // Skipped when the active node does not advertise the capability. @@ -570,7 +573,7 @@ export default function StackAnatomyPanel({ )} - {scanStatus && scanStatus.status && scanStatus.status !== 'ok' && ( + {scanStatus && scanStatus.status && scanStatus.status !== 'ok' && !scanBannerDismissed && (
+
)} diff --git a/frontend/src/hooks/useScanBannerDismiss.ts b/frontend/src/hooks/useScanBannerDismiss.ts new file mode 100644 index 00000000..d0494635 --- /dev/null +++ b/frontend/src/hooks/useScanBannerDismiss.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +// Bumped when a dismiss is written so sibling consumers re-read localStorage +// and agree, without a full page reload. +const DISMISS_EVENT = 'sencho:scan-banner-dismiss-changed'; + +const keyFor = (stackName: string, nodeId: number | undefined) => + `sencho.scanBannerDismissed.${stackName}.${nodeId ?? 'local'}`; + +/** Fingerprint encodes the scan outcome: any new scan run (different attemptedAt) + * or status change produces a new fingerprint, re-surfacing the banner. */ +function fingerprint(status: string | null, attemptedAt: number | undefined): string { + if (!status) return ''; + return `${status}:${attemptedAt ?? 0}`; +} + +/** + * Per-stack dismiss for the post-deploy scan warning banner, persisted in + * localStorage and keyed to a fingerprint of the scan run. Dismissal sticks + * across reloads for the same scan outcome, and clears automatically once a + * new scan runs (different attemptedAt) or the status changes. + */ +export function useScanBannerDismiss( + stackName: string, + nodeId: number | undefined, + scanStatus: { status: string | null; attemptedAt?: number } | null, +) { + const fp = useMemo( + () => fingerprint(scanStatus?.status ?? null, scanStatus?.attemptedAt), + [scanStatus?.status, scanStatus?.attemptedAt], + ); + const storageKey = keyFor(stackName, nodeId); + + const read = useCallback(() => { + try { return localStorage.getItem(storageKey); } catch { return null; } + }, [storageKey]); + + const [storedFp, setStoredFp] = useState(() => read()); + + useEffect(() => { + setStoredFp(read()); + const handler = () => setStoredFp(read()); + window.addEventListener(DISMISS_EVENT, handler); + window.addEventListener('storage', handler); + return () => { + window.removeEventListener(DISMISS_EVENT, handler); + window.removeEventListener('storage', handler); + }; + }, [read]); + + const dismissed = fp !== '' && storedFp === fp; + + const dismiss = useCallback(() => { + try { localStorage.setItem(storageKey, fp); } catch { /* ignore */ } + setStoredFp(fp); + window.dispatchEvent(new Event(DISMISS_EVENT)); + }, [storageKey, fp]); + + return { dismissed, dismiss }; +}