mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +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 { 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 { Button } from './ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||||
import { ScrollableTabRow } from './ui/ScrollableTabRow';
|
import { ScrollableTabRow } from './ui/ScrollableTabRow';
|
||||||
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown';
|
||||||
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
|
||||||
|
import { useScanBannerDismiss } from '@/hooks/useScanBannerDismiss';
|
||||||
import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
|
import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy';
|
||||||
import { buildServiceUrl } from '@/lib/serviceUrl';
|
import { buildServiceUrl } from '@/lib/serviceUrl';
|
||||||
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
import { StackActivityTimeline } from './stack/StackActivityTimeline';
|
||||||
@@ -126,6 +127,8 @@ export default function StackAnatomyPanel({
|
|||||||
attemptedAt?: number;
|
attemptedAt?: number;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
} | null>(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.
|
// Best-effort badge: read the last stored preflight severity to dot the tab.
|
||||||
// Skipped when the active node does not advertise the capability.
|
// Skipped when the active node does not advertise the capability.
|
||||||
@@ -570,7 +573,7 @@ export default function StackAnatomyPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
|
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && !scanBannerDismissed && (
|
||||||
<div
|
<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"
|
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"
|
role="status"
|
||||||
@@ -583,6 +586,14 @@ export default function StackAnatomyPanel({
|
|||||||
{scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'}
|
{scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'}
|
||||||
{scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''}
|
{scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''}
|
||||||
</span>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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