467 lines
17 KiB
Svelte
467 lines
17 KiB
Svelte
<script lang="ts">
|
||
import { onMount, untrack } from 'svelte';
|
||
import { beforeNavigate } from '$app/navigation';
|
||
import { navigating, page } from '$app/stores';
|
||
import type { HostOperationFilter, HostOperationHistoryPage } from '$lib/hosts.js';
|
||
import type { PageData } from './$types';
|
||
import { pollVisible } from '$ui/poll.js';
|
||
import BackLink from '$ui/components/BackLink.svelte';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
import Freshness from '$ui/components/Freshness.svelte';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||
import { hostOperationTitle } from '$lib/host-operation-labels.js';
|
||
import { serverTitle } from '$ui/server-labels.js';
|
||
|
||
let { data }: { data: PageData } = $props();
|
||
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
|
||
let refreshing = $state(false);
|
||
let refreshError = $state('');
|
||
let updatedAt = $state(Date.now());
|
||
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');
|
||
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)!);
|
||
const allOperationsHref = $derived.by(() => {
|
||
const params = new URLSearchParams($page.url.searchParams);
|
||
params.delete('operation');
|
||
params.delete('page');
|
||
return `/hosts/operations${params.size ? `?${params}` : ''}`;
|
||
});
|
||
function pageHref(pageNumber: number, filter: HostOperationFilter = history.filter) {
|
||
const params = new URLSearchParams($page.url.searchParams);
|
||
params.set('filter', filter);
|
||
params.delete('page');
|
||
if (pageNumber !== 1) params.set('page', String(pageNumber));
|
||
return `/hosts/operations?${params}`;
|
||
}
|
||
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 params = new URLSearchParams({
|
||
...data.scope,
|
||
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();
|
||
if (!response.ok) throw new Error('Could not refresh operation history.');
|
||
if (current === sequence && data === source && !$navigating) {
|
||
snapshot = { source, history: body };
|
||
refreshError = '';
|
||
updatedAt = Date.now();
|
||
}
|
||
} 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 = '';
|
||
updatedAt = Date.now();
|
||
});
|
||
});
|
||
onMount(() => {
|
||
const stop = pollVisible(refresh, 15000, false);
|
||
return () => {
|
||
stop();
|
||
cancel();
|
||
};
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Host operation history · Electric Cherry</title></svelte:head>
|
||
|
||
<section class="page" aria-busy={changingPage}>
|
||
<SectionHeader
|
||
title="Host operation history"
|
||
eyebrow="Hosts"
|
||
subtitle="Remote operations, newest first. Filtered views retain their host and server context."
|
||
>
|
||
{#snippet actions()}<Freshness
|
||
{updatedAt}
|
||
error={refreshError}
|
||
intervalLabel="Updates every 15 seconds"
|
||
onRetry={() => void refresh()}
|
||
/>{/snippet}
|
||
</SectionHeader>
|
||
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink
|
||
fallback={data.scope.operation ? allOperationsHref : '/hosts'}
|
||
/>{/if}
|
||
{#if data.scope.operation}<div class="selected-operation">
|
||
<div><strong>Selected host operation</strong><code>{data.scope.operation}</code></div>
|
||
<a href={allOperationsHref}>View all operations</a>
|
||
</div>{/if}
|
||
{#if data.scope.host || data.scope.server}<div class="scope" role="status">
|
||
<span
|
||
>Showing {data.hostName ? `host: ${data.hostName}` : ''}{data.scope.host &&
|
||
data.scope.server
|
||
? ' · '
|
||
: ''}{data.scope.server
|
||
? `server: ${serverTitle(data.scope.server)}`
|
||
: ''}</span
|
||
><a href={`/hosts/operations?filter=${history.filter}`}>Clear scope</a>
|
||
</div>{/if}
|
||
<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>
|
||
<section class="panel" aria-label="Host operation history">
|
||
{#if !history.operations.length}<p class="empty" role="status">
|
||
{data.scope.operation
|
||
? 'This operation is unavailable in the selected history. It may have expired or be excluded by the current filters.'
|
||
: 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"
|
||
>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 class="operation"
|
||
>{hostOperationTitle(operation.kind)}
|
||
<details>
|
||
<summary>Technical details</summary><small
|
||
>{operation.kind}</small
|
||
><small>{operation.id}</small>
|
||
</details></td
|
||
>
|
||
<td
|
||
>{#if operation.hostName !== null}<a
|
||
href={contextHref(
|
||
`/hosts/${operation.hostId}`,
|
||
$page.url
|
||
)}>{operation.hostName}</a
|
||
>{:else}Deleted host<small>{operation.hostId}</small
|
||
>{/if}</td
|
||
>
|
||
<td class="target">
|
||
<span
|
||
>{operation.serverName
|
||
? serverTitle(operation.serverName)
|
||
: operation.instanceId
|
||
? 'Server container'
|
||
: 'Host-level'}</span
|
||
>
|
||
{#if operation.instanceId && !operation.serverName}<small
|
||
>{operation.instanceId}</small
|
||
>{/if}
|
||
{#if operation.targetPath}<small class="target-path"
|
||
>{operation.targetPath}</small
|
||
>{/if}
|
||
</td>
|
||
<td class="source">{operation.source ?? 'Not recorded'}</td>
|
||
<td
|
||
><StatusBadge
|
||
state={operation.state === 'completed'
|
||
? 'done'
|
||
: operation.state === 'pending'
|
||
? 'queued'
|
||
: operation.state === 'accepted'
|
||
? 'running'
|
||
: operation.state}
|
||
/></td
|
||
>
|
||
<td class="updated"><RelativeTime value={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>
|
||
.scope {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.selected-operation {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface);
|
||
font-size: var(--text-sm);
|
||
}
|
||
.selected-operation > div {
|
||
min-width: 0;
|
||
}
|
||
.selected-operation code {
|
||
display: block;
|
||
margin-top: 4px;
|
||
overflow-wrap: anywhere;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.selected-operation a {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
.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 {
|
||
position: relative;
|
||
overflow: auto;
|
||
}
|
||
.table-wrap:focus-visible {
|
||
outline: 2px solid var(--accent-primary);
|
||
outline-offset: -2px;
|
||
}
|
||
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;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.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) {
|
||
td,
|
||
th {
|
||
padding: 12px;
|
||
}
|
||
.page-controls {
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
.filter-links a {
|
||
padding: 8px 12px;
|
||
}
|
||
}
|
||
</style>
|