Add filters and context to host operation history
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
|
||||
import { isHostOperationFilter } from '$lib/hosts.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}`);
|
||||
const requestedFilter = url.searchParams.get('filter');
|
||||
const filter = isHostOperationFilter(requestedFilter) ? requestedFilter : 'important';
|
||||
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter);
|
||||
if ((requestedPage !== null && requestedPage !== String(history.page))
|
||||
|| (requestedFilter !== null && requestedFilter !== filter)) {
|
||||
const params = new URLSearchParams();
|
||||
if (requestedFilter !== null) params.set('filter', filter);
|
||||
if (history.page !== 1) params.set('page', String(history.page));
|
||||
redirect(307, `/hosts/operations${params.size ? `?${params}` : ''}`);
|
||||
}
|
||||
return { history };
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { navigating } from '$app/stores';
|
||||
import type { HostOperationHistoryPage } from '$lib/hosts.js';
|
||||
import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js';
|
||||
import type { PageData } from './$types';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
@@ -19,7 +19,17 @@
|
||||
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}`; }
|
||||
const filters: Array<{ id: HostOperationFilter; label: string; description: string; empty: string }> = [
|
||||
{ id: 'important', label: 'Actions & errors', description: 'Actions, failures, and checks that need attention.', empty: 'No actions or checks needing attention recorded.' },
|
||||
{ id: 'routine', label: 'Routine checks', description: 'Monitoring and file reads across all states.', empty: 'No routine checks recorded.' },
|
||||
{ id: 'all', label: 'All operations', description: 'Every recorded remote operation.', empty: 'No remote operations yet.' }
|
||||
];
|
||||
const selectedFilter = $derived(filters.find(filter => filter.id === history.filter)!);
|
||||
function pageHref(page: number, filter: HostOperationFilter = history.filter) {
|
||||
const params = new URLSearchParams({ filter });
|
||||
if (page !== 1) params.set('page', String(page));
|
||||
return `/hosts/operations?${params}`;
|
||||
}
|
||||
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
|
||||
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
@@ -30,7 +40,8 @@
|
||||
request = controller;
|
||||
refreshing = true;
|
||||
try {
|
||||
const response = await fetch(`/api/hosts/operations?page=${history.page}`, {
|
||||
const params = new URLSearchParams({ page: String(history.page), filter: history.filter });
|
||||
const response = await fetch(`/api/hosts/operations?${params}`, {
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000), ...(signal ? [signal] : [])])
|
||||
});
|
||||
const body = await response.json();
|
||||
@@ -55,18 +66,32 @@
|
||||
<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>
|
||||
<div class="history-filters">
|
||||
<nav class="filter-links" aria-label="Operation history filters">
|
||||
{#each filters as filter}
|
||||
<a href={pageHref(1, filter.id)} class:active={history.filter === filter.id}
|
||||
aria-current={history.filter === filter.id ? 'page' : undefined}>{filter.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<p>{selectedFilter.description}</p>
|
||||
</div>
|
||||
{#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>
|
||||
{#if !history.operations.length}<p class="empty" role="status">{selectedFilter.empty}</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>
|
||||
<thead><tr><th scope="col">Operation</th><th scope="col">Host</th><th scope="col">Target</th><th scope="col">Source</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 class="operation">{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="target">
|
||||
<span>{operation.serverName ?? operation.instanceId ?? 'Host-level'}</span>
|
||||
{#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if}
|
||||
</td>
|
||||
<td class="source">{operation.source ?? 'Not recorded'}</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>
|
||||
@@ -88,14 +113,25 @@
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: 24px; min-width: 0; }
|
||||
.history-filters { min-width: 0; }
|
||||
.history-filters p { margin: 10px 0 0; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.filter-links { display: flex; gap: 4px; width: fit-content; max-width: 100%; padding: 4px; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.filter-links a { padding: 8px 16px; border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-sm); text-decoration: none; white-space: nowrap; }
|
||||
.filter-links a:hover { color: var(--text-primary); }
|
||||
.filter-links a.active { color: var(--text-primary); background: var(--surface-raised); }
|
||||
.filter-links a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
|
||||
.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); }
|
||||
table { width: 100%; min-width: 1050px; 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; }
|
||||
.operation { min-width: 180px; max-width: 240px; }
|
||||
.target { min-width: 180px; max-width: 320px; }
|
||||
.target-path { font-family: var(--font-mono); }
|
||||
.source { min-width: 120px; max-width: 220px; }
|
||||
.state { text-transform: capitalize; }
|
||||
.failed { color: var(--state-danger); }
|
||||
.updated { white-space: nowrap; }
|
||||
@@ -103,5 +139,9 @@
|
||||
.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; } }
|
||||
@media (max-width: 600px) {
|
||||
td, th { padding: 12px; }
|
||||
.page-controls { width: 100%; justify-content: space-between; }
|
||||
.filter-links a { padding: 8px 12px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user