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:
+134
-21
@@ -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}
|
||||
— {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}
|
||||
— {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 ·
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user