mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user