Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+379 -80
View File
@@ -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>