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.
This commit is contained in:
SaelixCode
2026-07-05 23:00:50 -04:00
parent 5f8c0c8c97
commit 9cfb968081
2 changed files with 73 additions and 2 deletions
+13 -2
View File
@@ -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({
</div>
</div>
)}
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && !scanBannerDismissed && (
<div
className="mx-3 my-2 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/[0.06] px-2 py-1.5 text-xs text-warning"
role="status"
@@ -583,6 +586,14 @@ export default function StackAnatomyPanel({
{scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'}
{scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''}
</span>
<button
type="button"
className="shrink-0 ml-2 p-0.5 rounded hover:bg-warning/10 transition-colors"
onClick={dismissScanBanner}
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
</div>
)}
</div>
@@ -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<string | null>(() => 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 };
}