Move host operation history to a paginated Hosts page

This commit is contained in:
2026-10-05 12:27:48 -04:00
parent 09ef8c6ec8
commit 10ab071bce
14 changed files with 298 additions and 17 deletions
+6 -2
View File
@@ -59,7 +59,9 @@ T5 and for T6 when available, one setup operation at a time. Uploading new files
or enrolling another host needs no manual propagation. Setup waits for an active, connected,
healthy host with sufficient storage. Failed operations retry automatically with
backoff from one minute to fifteen minutes; their errors remain visible in the
host's setup panel and operation history. Disconnected hosts resume when they
host's setup panel and **Hosts → Operation history**. This shared history lists
operations across hosts, newest first, with a host column and 25 rows per page.
Disconnected hosts resume when they
reconnect. Draining and disabled hosts do not receive new automatic setup work.
An automatic download that has not been delivered to the agent can be replaced
@@ -284,7 +286,9 @@ explicit installer `--reenroll` when local credentials already exist.
API outline:
- `GET /api/hosts/:id`: host details, operation history and deletion blockers.
- `GET /api/hosts/:id`: host details and deletion blockers.
- `GET /api/hosts/operations?page=1`: operation history across hosts, newest first,
with 25 summaries per page and total/page counts. Payloads and results are omitted.
- `DELETE /api/hosts/:id`: administrator requests cleanup of an unused host;
returns `202` while remote removal is pending, `200` for an unenrolled host,
or `409` when the agent needs updating or unresolved ownership remains.
+6
View File
@@ -100,6 +100,12 @@ export interface HostOperation {
payload: Record<string, unknown>; state: HostOperationState; result: unknown; error: string | null;
createdAt: string; updatedAt: string;
}
export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'result'> {
hostName: string | null;
}
export interface HostOperationHistoryPage {
operations: HostOperationSummary[]; page: number; pageSize: 25; total: number; totalPages: number;
}
export interface HostPreparationItem {
kind: 'runtime' | 'library'; label: string;
+17 -1
View File
@@ -6,7 +6,7 @@ import { db } from '../db/index.js';
import { ensureHostSchema } from './schema.js';
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationState } from '../../hosts.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationHistoryPage, HostOperationState } from '../../hosts.js';
export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000;
@@ -356,6 +356,22 @@ export function listHostOperations(hostId: string, limit = 100, summary = false)
return (database().prepare(`SELECT ${columns} FROM ec_host_operations o WHERE o.host_id=? ORDER BY o.created_at DESC,o.rowid DESC LIMIT ?`)
.all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow);
}
/** Browse retained history without loading command payloads or credential-bearing results. */
export function listHostOperationHistory(page = 1): HostOperationHistoryPage {
const handle = database();
const pageSize = 25;
return handle.transaction((): HostOperationHistoryPage => {
const { total } = handle.prepare('SELECT COUNT(*) AS total FROM ec_host_operations').get() as { total: number };
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(totalPages, Number.isSafeInteger(page) && page > 0 ? page : 1);
const operations = handle.prepare(`SELECT o.id,o.host_id AS hostId,h.name AS hostName,o.kind,
o.instance_id AS instanceId,o.generation,o.state,o.error,o.created_at AS createdAt,o.updated_at AS updatedAt
FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL
ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`)
.all(pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations'];
return { operations, page: currentPage, pageSize, total, totalPages };
})();
}
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */
export function pendingCreationCancellations(hostId: string): string[] {
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations
+2
View File
@@ -31,6 +31,8 @@ export function ensureHostSchema(handle: Database.Database): void {
UNIQUE(host_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS ec_host_operations_queue ON ec_host_operations(host_id, state, created_at);
-- Reversing this index also reverses its implicit rowid tie-breaker.
CREATE INDEX IF NOT EXISTS ec_host_operations_history ON ec_host_operations(created_at);
-- Polling checks cancellations and preparation by kind across retained
-- history. The queue index cannot narrow those lookups beyond host_id,
-- causing a history scan for every queued command on Node's main thread.
@@ -48,7 +48,7 @@
<p>This deletes the host's game-library copies, server files, agent data, credentials, and service installation. Electric Cherry's uploaded libraries on the controller and its operation history are kept.</p>
<p>The host remains visible until cleanup is confirmed. Offline hosts wait for reconnection. Unsent commands will be cancelled.</p>
{#if checking}<p role="status">Checking servers and unfinished operations…</p>
{:else if deletion && !deletion.allowed}<div role="status" class="blocked"><strong>This host still has work to finish:</strong><ul>{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}</ul><p>Open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to review its operations or drain its servers.</p></div>
{:else if deletion && !deletion.allowed}<div role="status" class="blocked"><strong>This host still has work to finish:</strong><ul>{#each deletion.blockers as blocker}<li>{blocker}</li>{/each}</ul><p>Review <a href="/hosts/operations" onclick={() => open = false}>operation history</a> or open <a href={`/hosts/${host?.id}`} onclick={() => open = false}>host details</a> to drain its servers.</p></div>
{:else if deletion?.allowed}<p>Cleanup cannot be undone once it starts. Adding this machine again requires installing and enrolling the agent again.</p>{/if}
{#if error}<p role="alert" class="ui-error">{error}</p>{/if}
</div>
+10 -1
View File
@@ -143,9 +143,18 @@
const visibleTabs = $derived(tabs.filter(tab => tab.label !== 'Settings' || data.user?.role === 'admin'));
const secondary = $derived(tabs.find(tab => tab.label === 'Library')!.match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }, { href: '/game-files', label: 'Game Files' }]
: tabs.find(tab => tab.label === 'Hosts')!.match($page.url.pathname)
? [{ href: '/hosts', label: 'All hosts' }, { href: '/hosts/operations', label: 'Operation history' }]
: tabs.find(tab => tab.label === 'Operations')!.match($page.url.pathname)
? [{ href: '/provisioning', label: 'Provisioning' }, { href: '/collections', label: 'Collections' }, { href: '/campaigns', label: 'Campaigns' }, { href: '/maintenance', label: 'Maintenance' }, ...(data.user?.role === 'admin' ? [{ href: '/server-keys', label: 'Keys' }] : [])]
: tabs.find(tab => tab.label === 'Settings')!.match($page.url.pathname) ? [{ href: '/settings', label: 'Preferences' }, ...(data.user?.role === 'admin' ? [{ href: '/settings/users', label: 'Users' }] : [])] : []);
function secondaryActive(href: string): boolean {
const path = $page.url.pathname;
if (href === '/settings') return path === href;
if (href === '/hosts') return path === href || /^\/hosts\/(?!operations(?:\/|$))/.test(path);
return path === href || path.startsWith(href + '/');
}
</script>
<svelte:window onkeydown={closeMenu} />
@@ -184,7 +193,7 @@
</div>{/if}
</div>
</nav>
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={item.href === '/settings' ? $page.url.pathname === item.href ? 'page' : undefined : $page.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
{#if sessionReady && secondary.length}<div class="subnav"><div role="navigation" aria-label="Section">{#each secondary as item}<a href={item.href} aria-current={secondaryActive(item.href) ? 'page' : undefined}>{item.label}</a>{/each}</div></div>{/if}
{/if}
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
+2 -4
View File
@@ -1,15 +1,13 @@
import { capacityCalibration } from '$lib/server/hosts/calibration.js';
import { error, json } from '@sveltejs/kit';
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, listHostOperations, updateHost } from '$lib/server/hosts/index.js';
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js';
type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
// Payloads and results can contain RCON keys or staged credentials. History
// exposes metadata only; agent APIs remain the only command delivery path.
return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id, 100, true).map(({ payload: _payload, result: _result, ...operation }) => operation) });
return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id) });
}
export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request);
@@ -0,0 +1,10 @@
import { error, json } from '@sveltejs/kit';
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET({ request }: { request: Request }) {
requireManagementOrigin(request);
const page = Number(new URL(request.url).searchParams.get('page') ?? '1');
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.');
return json(listHostOperationHistory(page), { headers: { 'Cache-Control': 'no-store' } });
}
+6 -7
View File
@@ -4,14 +4,14 @@
let requestSequence = 0;
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { Host, HostOperation, HostMode, CapacityCalibration } from '$lib/hosts.js';
import type { Host, HostMode, CapacityCalibration } from '$lib/hosts.js';
import { hostAgentUpdateLabel, hostAgentUpdateMessage, hostAgentUpdatesEnabled, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
import HostPreparation from '$ui/components/HostPreparation.svelte';
import CapacitySamples from '$ui/components/CapacitySamples.svelte';
let host = $state<Host | null>(null); let operations = $state<HostOperation[]>([]);
let host = $state<Host | null>(null);
let calibration = $state<CapacityCalibration | null>(null);
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
let deleteOpen = $state(false);
@@ -24,7 +24,7 @@
async function load(initialize = false, signal?: AbortSignal) {
const target = endpoint; const sequence = ++requestSequence;
try {
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; calibration = body.calibration ?? null; error = '';
const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; calibration = body.calibration ?? null; error = '';
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
} catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
}
@@ -45,8 +45,8 @@
</script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, credentials, and operation history.">
{#snippet actions()}{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, settings, and credentials.">
{#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
{#if loading}<p role="status">Loading host…</p>{:else if host}
@@ -85,11 +85,10 @@
</section><section class="panel"><h2>Agent access</h2><p>Credentials: {host.credentialActive ? 'Active' : 'Not active'}. Enrollment links expire after 15 minutes and work once.</p>{#if canManage}<div class="actions"><Button disabled={busy || (cleaning && !host.decommission?.retryable)} onclick={() => action('/enrollment', 'POST')}>Generate installer</Button><Button disabled={busy || cleaning || !host.credentialActive || !host.connected} onclick={() => action('/credentials', 'POST', undefined, 'Credential rotation queued.')}>Rotate credential</Button><Button disabled={busy || cleaning || !host.credentialActive} onclick={() => action('/credentials', 'DELETE', undefined, 'Host credentials revoked. Generate a new installer to enroll again.')}>Revoke credential</Button></div>
{#if enrollment}<p>Expires {new Date(enrollment.expiresAt).toLocaleString()}</p><pre>{enrollment.command}</pre>{/if}{:else}<p>Administrators manage enrollment and agent credentials.</p>{/if}
</section></div>
<section class="panel"><h2>Operation history</h2>{#if !operations.length}<p>No remote operations yet.</p>{:else}<div class="table-wrap"><table><thead><tr><th>Operation</th><th>State</th><th>Updated</th><th>Result</th></tr></thead><tbody>{#each operations as operation}<tr><td>{operation.kind}<small>{operation.id}</small></td><td>{operation.state}</td><td>{new Date(operation.updatedAt).toLocaleString()}</td><td>{operation.error ?? '—'}</td></tr>{/each}</tbody></table></div>{/if}</section>
{/if}
</section>
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
<style>
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } .table-wrap { overflow:auto; } table { width:100%; border-collapse:collapse; text-align:left; font-size:var(--text-sm); } td,th { padding:12px; border-bottom:1px solid var(--border-subtle); } small { display:block; color:var(--text-secondary); font-size:var(--text-xs); margin-top:4px; } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
</style>
@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { listHostOperationHistory } from '$lib/server/hosts/index.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}`);
}
return { history };
};
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { navigating } from '$app/stores';
import type { HostOperationHistoryPage } from '$lib/hosts.js';
import type { PageData } from './$types';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
let { data }: { data: PageData } = $props();
let snapshot = $state.raw<{ source: PageData; history: HostOperationHistoryPage } | null>(null);
let refreshing = $state(false);
let refreshError = $state('');
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');
function pageHref(page: number) { return page === 1 ? '/hosts/operations' : `/hosts/operations?page=${page}`; }
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 response = await fetch(`/api/hosts/operations?page=${history.page}`, {
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 = ''; }
}
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 = ''; }); });
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
</script>
<svelte:head><title>Operation history · Hosts · Electric Cherry</title></svelte:head>
<section class="page" aria-busy={changingPage}>
<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>
{#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>
{: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>
<tbody>{#each history.operations as operation (operation.id)}
<tr>
<td>{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="state" class:failed={operation.state === 'failed'}>{operation.state}</td>
<td class="updated">{$timeFormat.dateTime(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>
.page { display: grid; gap: 24px; min-width: 0; }
.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); }
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; }
.state { text-transform: capitalize; }
.failed { color: var(--state-danger); }
.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) { .page-controls { width: 100%; justify-content: space-between; } }
</style>
+1 -1
View File
@@ -125,7 +125,7 @@
</dl>
{#if detail.agentOperation.error}<p>{detail.agentOperation.error}</p>{/if}
{#if outcome.active && detail.agentOperation.state === 'completed'}<p>The agent finished its command. EC is verifying readiness.</p>{/if}
<a href={`/hosts/${encodeURIComponent(detail.agentOperation.hostId)}`}>View host operation history</a>
<a href="/hosts/operations">View host operation history</a>
</div></Card>
{/if}
{#if outcome.active && detail.workerActivity}
+1
View File
@@ -12,6 +12,7 @@ describe('management route permissions', () => {
'/leaderboard/players/[game]/[guid]', '/api/campaigns/[id]', '/campaigns/[id]',
'/api/provisioning/[id]', '/provisioning/[id]', '/api/provisioning/[id]/events',
'/api/betas/[id]', '/api/scripts/[id]', '/api/configs', '/api/plugins', '/api/hosts/[id]',
'/hosts/operations', '/api/hosts/operations',
'/api/game-files/[id]', '/api/maintenance', '/api/events/servers', '/api/events/pipeline'
])('lets viewers read the safe summary route %s', route => {
expect(routePermission(route, 'GET')).toBe('viewer');
+117
View File
@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
async function seedHistory(count = 53) {
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
const first = hosts.createHost({ name: 'Alpha host' });
const second = hosts.createHost({ name: 'Beta host' });
const deleted = hosts.createHost({ name: 'Removed host' });
const handle = db();
const ids: string[] = [];
handle.transaction(() => {
const insert = handle.prepare(`INSERT INTO ec_host_operations
(id,host_id,kind,instance_id,generation,payload,result,state,error,created_at,updated_at)
VALUES(?,?,'instance.inspect','instance-one',2,?,?,'completed',NULL,?,?)`);
for (let index = 0; index < count; index++) {
// Reverse IDs and tied timestamps ensure insertion order breaks ties.
const id = `operation-${String(count - index).padStart(4, '0')}`;
const timestamp = index < 3 ? '2026-10-01T12:00:00.000Z' : '2026-10-05T12:00:00.000Z';
ids.push(id);
insert.run(id, [first, second, deleted][index % 3].id,
'{"credential":"payload-secret"}', '{"credential":"result-secret"}', timestamp, timestamp);
}
handle.prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?').run('2026-10-05T12:30:00.000Z', deleted.id);
})();
return { ...hosts, handle, ids, first, second, deleted };
}
describe('host operation history', () => {
it('pages all hosts in stable newest-first order, retaining deleted-host records', async () => {
const api = await seedHistory();
const pages = [1, 2, 3].map(page => api.listHostOperationHistory(page));
expect(pages.map(page => page.operations.length)).toEqual([25, 25, 3]);
pages.forEach((page, index) => expect(page).toMatchObject({ page: index + 1, pageSize: 25, total: 53, totalPages: 3 }));
const operations = pages.flatMap(page => page.operations);
expect(operations.map(operation => operation.id)).toEqual([...api.ids].reverse());
expect(operations.find(operation => operation.hostId === api.first.id)?.hostName).toBe('Alpha host');
expect(operations.find(operation => operation.hostId === api.second.id)?.hostName).toBe('Beta host');
expect(operations.filter(operation => operation.hostId === api.deleted.id).length).toBeGreaterThan(0);
expect(operations.filter(operation => operation.hostId === api.deleted.id).every(operation => operation.hostName === null)).toBe(true);
expect(api.listHostOperationHistory(999)).toEqual(pages[2]);
});
it('bounds reads in SQLite using the history index and omits private payloads and results', async () => {
const api = await seedHistory(1003);
const prepare = vi.spyOn(api.handle, 'prepare');
const history = api.listHostOperationHistory(2);
const query = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.includes('FROM ec_host_operations o LEFT JOIN'))!;
prepare.mockRestore();
const projection = query.split('FROM ec_host_operations')[0];
expect(projection).not.toContain('payload');
expect(projection).not.toContain('result');
expect(projection).not.toContain('*');
expect(query).toContain('LIMIT ? OFFSET ?');
const plan = api.handle.prepare(`EXPLAIN QUERY PLAN ${query}`).all(25, 25) as { detail: string }[];
expect(plan.some(row => row.detail.includes('USING INDEX ec_host_operations_history'))).toBe(true);
expect(plan.some(row => row.detail.includes('TEMP B-TREE'))).toBe(false);
expect(history.operations).toHaveLength(25);
expect(history.operations[0]).toMatchObject({ instanceId: 'instance-one', generation: 2, state: 'completed' });
expect(history.operations.every(operation => !('payload' in operation) && !('result' in operation))).toBe(true);
expect(JSON.stringify(history)).not.toContain('secret');
});
it('returns an empty first page and normalizes invalid internal page numbers', async () => {
const api = await seedHistory(0);
const empty = { operations: [], page: 1, pageSize: 25, total: 0, totalPages: 1 };
expect(api.listHostOperationHistory()).toEqual(empty);
for (const page of [0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, 999]) {
expect(api.listHostOperationHistory(page)).toEqual(empty);
}
});
it('serves fixed-size API pages without caching or exposing private operation data', async () => {
const api = await seedHistory();
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
const first = GET({ request: new Request('https://ec.test/api/hosts/operations') });
expect(first.headers.get('cache-control')).toBe('no-store');
expect(await first.json()).toEqual(api.listHostOperationHistory(1));
const second = GET({ request: new Request('https://ec.test/api/hosts/operations?page=2&pageSize=500') });
const body = await second.json();
expect(body).toEqual(api.listHostOperationHistory(2));
expect(body.operations).toHaveLength(25);
expect(JSON.stringify(body)).not.toContain('secret');
const last = GET({ request: new Request('https://ec.test/api/hosts/operations?page=999') });
expect(await last.json()).toEqual(api.listHostOperationHistory(3));
});
it('rejects invalid API pages and keeps host management access restrictions', async () => {
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
const url = 'https://ec.test/api/hosts/operations';
for (const page of ['', '0', '-1', '1.5', 'nope', 'Infinity', '9007199254740992']) {
expect(() => GET({ request: new Request(`${url}?page=${page}`) })).toThrow(expect.objectContaining({ status: 400 }));
}
const blockedHeaders: HeadersInit[] = [
{ origin: 'https://other.test' },
{ 'sec-fetch-site': 'cross-site' },
{ authorization: 'Bearer ec_host_secret' },
{ authorization: 'Bearer ec_enroll_secret' }
];
for (const headers of blockedHeaders) {
expect(() => GET({ request: new Request(url, { headers }) })).toThrow(expect.objectContaining({ status: 403 }));
}
});
it('keeps operation history out of host detail responses', async () => {
const api = await seedHistory();
const { GET } = await import('../../src/routes/api/hosts/[id]/+server.js');
const body = await GET({ request: new Request(`https://ec.test/api/hosts/${api.first.id}`), params: { id: api.first.id } }).json();
expect(body.host.id).toBe(api.first.id);
expect(body).not.toHaveProperty('operations');
expect(JSON.stringify(body)).not.toContain('secret');
});
});