Unify retained operation history in Activity

This commit is contained in:
2026-10-07 19:30:38 -04:00
parent c39563daef
commit 05bdeaf624
35 changed files with 1008 additions and 120 deletions
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
import { contextHref } from '$lib/navigation-context.js';
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '$ui/activity-history.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import { fleet } from '$ui/stores/fleet.js';
import { uiNow } from '$ui/stores/servers.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import Freshness from './Freshness.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
let { server, source, initial }: { server?: string; source?: ActivitySource; initial?: ActivityPage } = $props();
const id = $props.id();
const headingId = `${id}-activity-heading`;
let data = $state<ActivityPage | undefined>(untrack(() => initial));
let loading = $state(!untrack(() => initial));
let error = $state('');
let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let results = $state<HTMLOListElement>();
let search = $state(untrack(() => activityQuery($page.url, { server, source }).q ?? ''));
let pendingUrl: URL | undefined;
let navigationId = 0;
const filters = $derived(activityQuery($page.url, { server, source }));
const apiUrl = $derived(activityApiHref($page.url, { server, source }));
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
const serverOptions = $derived.by(() => {
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
if (filters.server) names.add(filters.server);
return [...names].sort((a, b) => serverTitle(a).localeCompare(serverTitle(b)));
});
const hostOptions = $derived.by(() => {
const hosts = new Map<string, string>();
for (const item of $fleet.servers) if (item.host) hosts.set(item.host.id, item.host.name);
for (const row of data?.rows ?? []) if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
if (filters.host && !hosts.has(filters.host)) hosts.set(filters.host, 'Selected host');
return [...hosts].sort((a, b) => a[1].localeCompare(b[1]));
});
$effect(() => { search = filters.q ?? ''; });
$effect(() => {
const report = initial;
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
});
$effect(() => {
const url = apiUrl;
retry;
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
return pollVisible(async signal => {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load activity.');
const body: ActivityPage = await response.json();
if (signal.aborted || apiUrl !== url) return;
// Keep a focused operation link in place when newer rows arrive.
if (loadedUrl !== url || !results?.contains(document.activeElement)) { data = body; checkedAt = Date.now(); }
loadedUrl = url; loading = false; error = '';
} catch (cause) {
if (signal.aborted || apiUrl !== url) return;
error = cause instanceof Error ? cause.message : 'Could not load activity.';
loading = false;
return false;
}
}, 10_000, untrack(() => loadedUrl !== url || retry > 0));
});
function update(changes: Partial<Record<keyof ActivityQuery, string>>) {
const href = activityHistoryHref(pendingUrl ?? $page.url, changes);
pendingUrl = new URL(href, $page.url);
const id = ++navigationId;
void goto(href, { noScroll: true, keepFocus: true })
.catch(() => { error = 'Could not update activity filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
}
function reset() { update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' }); }
function submitSearch() { update({ q: search.trim() }); }
</script>
<section class="activity-log" aria-labelledby={headingId}>
<h2 class="sr-only" id={headingId}>Activity history</h2>
{#snippet filterFields(suffix: string)}
<div class="filters">
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each Object.entries(activityTypes) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each Object.entries(activitySources) as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
<form class="search" role="search" onsubmit={event => { event.preventDefault(); submitSearch(); }}><label for={`activity-search${suffix}`}>Search activity<input id={`activity-search${suffix}`} type="search" bind:value={search} maxlength="200" placeholder="Server, host, or activity…" /></label><Button type="submit" size="sm">Search</Button></form>
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters"><span>{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
</Dialog>
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
{#if loading}<EmptyState title="Loading activity…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
{:else if data?.rows.length}
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
{#each data.rows as row (row.id)}
<li class="activity-row">
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}>{row.title}</a></h3>{#if row.detail}<p>{row.detail}</p>{/if}
<div class="resources">{#if row.server && server === undefined}<HistoricalServerIdentity name={row.server} deletedAt={row.serverDeletedAt} href={contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url)} />{/if}
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
</div>
</div>
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{activityTimeLabel(row.at, $timeZone, $uiNow)}</time>
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
</div>
</li>
{/each}
</ol>
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if}
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
<div class="pagination"><span>{data.rows.length ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` : 'No activities on this page'}</span>
<label>Per page<select aria-label="Activities per page" value={data.limit} onchange={event => update({ limit: event.currentTarget.value })}>{#each [...new Set([25, 50, 100, data.limit])].sort((a, b) => a - b) as size}<option value={size}>{size}</option>{/each}</select></label>
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"><Button size="sm" disabled={data.offset === 0} href={activityHistoryHref($page.url, { offset: String(Math.max(0, data.offset - data.limit)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.limit >= data.total} href={activityHistoryHref($page.url, { offset: String(data.offset + data.limit) })}>Next</Button></nav>{/if}
</div>
{/if}
</section>
<style>
.activity-log { display: grid; gap: var(--space-4); min-width: 0; }
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: var(--space-3); }
label { display: grid; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); min-width: 0; }
select, input { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
.search { grid-column: span 2; display: flex; align-items: end; gap: var(--space-2); min-width: 0; }
.search label { flex: 1; }
.context, .pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); }
.context > :last-child { margin-left: auto; }
.text-button { padding: 4px 0; border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font: inherit; }
.activity-list { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.activity-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, auto); gap: var(--space-3); padding: var(--space-4); }
.activity-row + .activity-row { border-top: 1px solid var(--border-subtle); }
.activity-row:focus-within { background: var(--surface-raised); }
h3, p { margin: 0; }
h3 { font-size: var(--text-sm); font-weight: var(--weight-medium); }
a { color: var(--accent-primary-hover); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.event { min-width: 0; overflow-wrap: anywhere; }
.event p { margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.resources { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin-top: var(--space-2); font-size: var(--text-xs); }
.host-name, .sources, time { color: var(--text-secondary); font-size: var(--text-xs); }
.outcome { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); text-align: right; }
time { font-variant-numeric: tabular-nums; }
.sources { max-width: 32ch; overflow-wrap: anywhere; }
.pagination { justify-content: space-between; }
.pagination label { display: flex; align-items: center; gap: var(--space-2); }
.pagination select { width: auto; min-height: 36px; }
.pagination nav { display: flex; gap: var(--space-2); }
.error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
.mobile-filters { display: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 640px) {
.desktop-filters { display: none; }
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
.filters { grid-template-columns: minmax(0, 1fr); }
.search { grid-column: auto; }
.context { gap: var(--space-1) var(--space-2); }
.context > :last-child { margin-left: 0; }
.activity-row { grid-template-columns: minmax(0, 1fr); padding: var(--space-3); gap: var(--space-2); }
.outcome { flex-direction: row; flex-wrap: wrap; align-items: center; text-align: left; }
.sources { max-width: none; }
.pagination { gap: var(--space-3); }
.pagination nav { margin-left: auto; }
}
</style>