Move host operation history to a paginated Hosts page

This commit is contained in:
2026-10-05 12:27:48 -04:00
parent 09ef8c6ec8
commit 10ab071bce
14 changed files with 298 additions and 17 deletions
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { navigating } from '$app/stores';
import type { HostOperationHistoryPage } from '$lib/hosts.js';
import type { PageData } from './$types';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
let { data }: { data: PageData } = $props();
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
let refreshing = $state(false);
let refreshError = $state('');
let request: AbortController | null = null;
let sequence = 0;
const history = $derived(snapshot?.source === data ? snapshot.history : data.history);
const changingPage = $derived($navigating?.to?.url.pathname === '/hosts/operations');
const firstOperation = $derived((history.page - 1) * history.pageSize + 1);
const numbers = new Intl.NumberFormat('en-US');
function pageHref(page: number) { return page === 1 ? '/hosts/operations' : `/hosts/operations?page=${page}`; }
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
async function refresh(signal?: AbortSignal) {
if (refreshing || $navigating) return;
const current = ++sequence;
const source = data;
const controller = new AbortController();
request = controller;
refreshing = true;
try {
const response = await fetch(`/api/hosts/operations?page=${history.page}`, {
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])])
});
const body = await response.json();
if (!response.ok) throw new Error('Could not refresh operation history.');
if (current === sequence && data === source && !$navigating) { snapshot = { source, history: body }; refreshError = ''; }
}
catch {
if (current === sequence && data === source && !signal?.aborted && !controller.signal.aborted) {
refreshError = 'Could not refresh operation history. Showing the last successfully loaded page.';
}
return false;
} finally { if (current === sequence) { refreshing = false; request = null; } }
}
beforeNavigate(cancel);
$effect(() => { void data; untrack(() => { cancel(); snapshot = null; refreshError = ''; }); });
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
</script>
<svelte:head><title>Operation history · Hosts · Electric Cherry</title></svelte:head>
<section class="page" aria-busy={changingPage}>
<SectionHeader title="Operation history" eyebrow="Hosts" subtitle="Remote operations across all hosts, newest first.">
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader>
{#if refreshError}<p class="ui-error" role="alert">{refreshError}</p>{/if}
<section class="panel" aria-label="Host operation history">
{#if !history.operations.length}<p class="empty" role="status">No remote operations yet.</p>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-wrap" role="region" aria-label="Operations" tabindex="0">
<table>
<thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">State</th><th scope="col">Updated</th><th scope="col">Result</th></tr></thead>
<tbody>{#each history.operations as operation (operation.id)}
<tr>
<td>{operation.kind}<small>{operation.id}</small></td>
<td>{#if operation.hostName !== null}<a href={`/hosts/${operation.hostId}`}>{operation.hostName}</a>{:else}Deleted host<small>{operation.hostId}</small>{/if}</td>
<td class="state" class:failed={operation.state === 'failed'}>{operation.state}</td>
<td class="updated">{$timeFormat.dateTime(operation.updatedAt)}</td>
<td class="result">{operation.error ?? '—'}</td>
</tr>
{/each}</tbody>
</table>
</div>
<nav class="pagination" aria-label="Operation history pages">
<span role="status">{numbers.format(firstOperation)}–{numbers.format(firstOperation + history.operations.length - 1)} of {numbers.format(history.total)} operations</span>
<div class="page-controls">
<Button size="sm" href={pageHref(history.page - 1)} disabled={history.page === 1 || changingPage}>Previous</Button>
<span>Page {numbers.format(history.page)} of {numbers.format(history.totalPages)}</span>
<Button size="sm" href={pageHref(history.page + 1)} disabled={history.page >= history.totalPages || changingPage}>Next</Button>
</div>
</nav>
{/if}
</section>
</section>
<style>
.page { display: grid; gap: 24px; min-width: 0; }
.panel { min-width: 0; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.table-wrap { overflow: auto; }
.table-wrap:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
table { width: 100%; min-width: 760px; border-collapse: collapse; text-align: left; font-size: var(--text-sm); }
td, th { padding: 16px; border-bottom: 1px solid var(--border-subtle); vertical-align: top; }
th { color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
td { line-height: 1.6; overflow-wrap: anywhere; }
small { display: block; color: var(--text-secondary); font-size: var(--text-xs); margin-top: 4px; }
.state { text-transform: capitalize; }
.failed { color: var(--state-danger); }
.updated { white-space: nowrap; }
.result { min-width: 160px; max-width: 420px; }
.pagination { padding: 16px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; color: var(--text-secondary); font-size: var(--text-xs); }
.page-controls { display: flex; align-items: center; gap: 12px; }
.empty { padding: 24px; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); }
@media (max-width: 600px) { .page-controls { width: 100%; justify-content: space-between; } }
</style>