Files
electric-cherry/src/routes/hosts/operations/+page.svelte
T

467 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>