Move host operation history to a paginated Hosts page
This commit is contained in:
@@ -4,14 +4,14 @@
|
||||
let requestSequence = 0;
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Host, HostOperation, HostMode, CapacityCalibration } from '$lib/hosts.js';
|
||||
import type { Host, HostMode, CapacityCalibration } from '$lib/hosts.js';
|
||||
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
|
||||
import HostPreparation from '$ui/components/HostPreparation.svelte';
|
||||
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
|
||||
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
|
||||
let host = $state<Host | null>(null);
|
||||
let calibration = $state<CapacityCalibration | null>(null);
|
||||
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
@@ -24,7 +24,7 @@
|
||||
async function load(initialize = false, signal?: AbortSignal) {
|
||||
const target = endpoint; const sequence = ++requestSequence;
|
||||
try {
|
||||
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; calibration = body.calibration ?? null; error = '';
|
||||
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; calibration = body.calibration ?? null; error = '';
|
||||
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
|
||||
} catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
|
||||
}
|
||||
@@ -45,8 +45,8 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
|
||||
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history.">
|
||||
{#snippet actions()}{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
|
||||
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, settings, and credentials.">
|
||||
{#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
|
||||
{#if loading}<p role="status">Loading host…</p>{:else if host}
|
||||
@@ -85,11 +85,10 @@
|
||||
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
|
||||
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
|
||||
</section></div>
|
||||
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
|
||||
{/if}
|
||||
</section>
|
||||
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
|
||||
|
||||
<style>
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
||||
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
const requestedPage = url.searchParams.get('page');
|
||||
const history = listHostOperationHistory(Number(requestedPage ?? 1));
|
||||
if (requestedPage !== null && requestedPage !== String(history.page)) {
|
||||
redirect(307, history.page === 1 ? '/hosts/operations' : `/hosts/operations?page=${history.page}`);
|
||||
}
|
||||
return { history };
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user