feat(status): per-browser dismissal for Recent Incidents

The Recent Incidents list is derived server-side from raw AppStatus
health-check samples, so there is no incident row to delete and deleting
the underlying samples would corrupt uptime % and the sparkline timeline.
Per-browser, non-destructive dismissal is the right model: localStorage
holds the dismissed (appId, ISO startedAt) keys, the page filters them
out on render, and a Restore affordance brings them back.

- Per-row Dismiss (X) and section-level Clear all
- Restore link appears whenever any incident on the current page is hidden
- Dismissal key is (appId, startedAt) so it survives 24h/7d/30d switches
- Focus is moved to the Restore link after Clear all empties the list
  (otherwise the unmounting button would drop focus to <body>)
- Quota / disabled-localStorage failure is swallowed; in-memory state
  still works for the active session

Hand-rolled <button> elements match 14 other link-styled buttons already
in the project; both use the project-standard focus-visible:ring-2
focus-visible:ring-primary/30 ring.
This commit is contained in:
2026-05-28 15:40:23 +03:00
parent dab13518ef
commit 16c667ca15
+134 -21
View File
@@ -1,11 +1,80 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import type { PageData } from './$types.js';
import SparklineChart from '$lib/components/app/SparklineChart.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { data }: { data: PageData } = $props();
type Incident = PageData['incidents'][number];
// Per-browser dismissal of incidents. Stored in localStorage as an array of
// "<appId>|<ISO startedAt>" keys. Non-destructive: nothing is deleted from
// the DB, so uptime % and sparklines are unaffected. The same incident is
// dismissed across the 24h / 7d / 30d range views because the key is
// derived from the immutable (appId, startedAt) pair.
const DISMISSED_KEY = 'web-app-launcher:dismissed-incidents';
let dismissedKeys = $state<Set<string>>(new Set());
function incidentKey(i: Incident): string {
return `${i.appId}|${new Date(i.startedAt).toISOString()}`;
}
onMount(() => {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
if (raw) dismissedKeys = new Set(JSON.parse(raw) as string[]);
} catch {
// Corrupt or unavailable — fall back to an empty set.
}
});
function persist(next: Set<string>): void {
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...next]));
} catch {
// Quota exceeded / disabled localStorage — in-memory state still works for this session.
}
}
function dismissOne(i: Incident): void {
const next = new Set(dismissedKeys);
next.add(incidentKey(i));
dismissedKeys = next;
persist(next);
}
function clearAllVisible(): void {
const next = new Set(dismissedKeys);
for (const i of data.incidents) next.add(incidentKey(i));
dismissedKeys = next;
persist(next);
}
function restoreAllVisible(): void {
const next = new Set(dismissedKeys);
for (const i of data.incidents) next.delete(incidentKey(i));
dismissedKeys = next;
persist(next);
}
const visibleIncidents = $derived(
data.incidents.filter((i) => !dismissedKeys.has(incidentKey(i)))
);
const hiddenCount = $derived(data.incidents.length - visibleIncidents.length);
// Focus recovery: when the user clicks "Clear all" the button unmounts in
// the same tick, dropping focus to <body>. Move focus to the "Restore"
// affordance that replaces it so keyboard navigation isn't lost.
let restoreRef = $state<HTMLButtonElement | null>(null);
async function clearAllAndRefocus(): Promise<void> {
clearAllVisible();
await tick();
restoreRef?.focus();
}
const ranges = [
{ value: '24h', label: '24 Hours' },
{ value: '7d', label: '7 Days' },
@@ -174,28 +243,72 @@
<!-- Incidents Section -->
{#if data.incidents.length > 0}
<div class="mt-8">
<h2 class="mb-4 text-lg font-semibold text-foreground">Recent Incidents</h2>
<div class="space-y-2">
{#each data.incidents as incident (`${incident.appId}-${incident.startedAt}`)}
<div class="rounded-xl border border-border bg-card/50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="inline-block h-2 w-2 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>
<span class="text-sm font-medium text-foreground">{incident.appName ?? 'Unknown'}</span>
</div>
<span class="text-xs text-muted-foreground">
{incident.durationMs ? `${Math.round(incident.durationMs / 60_000)}min` : 'ongoing'}
</span>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{new Date(incident.startedAt).toLocaleString()}
{#if incident.endedAt}
&mdash; {new Date(incident.endedAt).toLocaleString()}
{/if}
</p>
</div>
{/each}
<div class="mb-4 flex items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-foreground">Recent Incidents</h2>
{#if visibleIncidents.length > 0}
<Button
variant="ghost"
size="sm"
onclick={clearAllAndRefocus}
aria-label="Clear all recent incidents"
>
Clear all
</Button>
{/if}
</div>
{#if visibleIncidents.length > 0}
<div class="space-y-2">
{#each visibleIncidents as incident (`${incident.appId}-${incident.startedAt}`)}
<div class="rounded-xl border border-border bg-card/50 p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class="inline-block h-2 w-2 shrink-0 rounded-full {statusDotColor(incident.status ?? 'offline')}"></span>
<span class="truncate text-sm font-medium text-foreground">{incident.appName ?? 'Unknown'}</span>
</div>
<div class="flex shrink-0 items-center gap-1">
<span class="text-xs text-muted-foreground">
{incident.durationMs ? `${Math.round(incident.durationMs / 60_000)}min` : 'ongoing'}
</span>
<button
type="button"
onclick={() => dismissOne(incident)}
aria-label="Dismiss incident"
class="rounded-md p-1 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<p class="mt-1 text-xs text-muted-foreground">
{new Date(incident.startedAt).toLocaleString()}
{#if incident.endedAt}
&mdash; {new Date(incident.endedAt).toLocaleString()}
{/if}
</p>
</div>
{/each}
</div>
{:else}
<div class="rounded-xl border border-border bg-card/50 p-6 text-center">
<p class="text-sm text-muted-foreground">No incidents to show.</p>
</div>
{/if}
{#if hiddenCount > 0}
<p class="mt-3 text-xs text-muted-foreground">
{hiddenCount} hidden &middot;
<button
bind:this={restoreRef}
type="button"
onclick={restoreAllVisible}
class="rounded-sm font-medium text-primary underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
>
Restore
</button>
</p>
{/if}
</div>
{/if}
</div>