Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -26,24 +26,51 @@
|
||||
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 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 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');
|
||||
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');
|
||||
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; }
|
||||
function cancel() {
|
||||
sequence++;
|
||||
request?.abort();
|
||||
request = null;
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
async function refresh(signal?: AbortSignal) {
|
||||
if (refreshing || $navigating) return;
|
||||
@@ -53,74 +80,203 @@
|
||||
request = controller;
|
||||
refreshing = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ ...data.scope, page: String(history.page), filter: history.filter });
|
||||
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] : [])])
|
||||
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.';
|
||||
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; } }
|
||||
} 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(); }; });
|
||||
$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="Operations" 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
|
||||
title="Host operation history"
|
||||
eyebrow="Operations"
|
||||
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 ? '/activity' : '/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}
|
||||
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink
|
||||
fallback={data.scope.operation ? '/activity' : '/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>
|
||||
<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>
|
||||
{#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>
|
||||
<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>
|
||||
<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>
|
||||
<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}
|
||||
@@ -128,40 +284,183 @@
|
||||
</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); }
|
||||
.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; }
|
||||
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