Retain host and server scope during investigations

This commit is contained in:
2026-10-05 19:05:07 -04:00
parent 056fbd7517
commit cd577b646c
12 changed files with 95 additions and 29 deletions
+9
View File
@@ -113,6 +113,15 @@ export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'r
routine: boolean;
}
export type HostOperationFilter = 'important' | 'routine' | 'all';
export interface HostOperationScope { host?: string; server?: string }
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
const scope: HostOperationScope = {};
for (const key of ['host', 'server'] as const) {
const value = params.get(key);
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
}
return scope;
}
export function isHostOperationFilter(value: unknown): value is HostOperationFilter {
return typeof value === 'string' && ['important', 'routine', 'all'].includes(value);
}
+9 -5
View File
@@ -10,7 +10,7 @@ import { parseHostInventory } from './request-context.js';
import { fleetSettings } from '../settings.js';
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationFilter, HostOperationHistoryPage, HostOperationState } from '../../hosts.js';
import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, HostOperation, HostOperationFilter, HostOperationHistoryPage, HostOperationScope, HostOperationState } from '../../hosts.js';
export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000;
@@ -395,12 +395,16 @@ export function listHostOperations(hostId: string, limit = 100, summary = false)
.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, filter: HostOperationFilter = 'important'): HostOperationHistoryPage {
export function listHostOperationHistory(page = 1, filter: HostOperationFilter = 'important', scope: HostOperationScope = {}): HostOperationHistoryPage {
const handle = database();
const pageSize = 25;
const where = filter === 'all' ? '' : `WHERE ${filter === 'routine' ? 'o.routine=1' : importantOperationSql}`;
const conditions = filter === 'all' ? [] : [filter === 'routine' ? 'o.routine=1' : `(${importantOperationSql})`];
const parameters: string[] = [];
if (scope.host) { conditions.push('o.host_id=?'); parameters.push(scope.host); }
if (scope.server) { conditions.push('o.server_name=?'); parameters.push(scope.server); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
return handle.transaction((): HostOperationHistoryPage => {
const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get() as { total: number };
const { total } = handle.prepare(`SELECT COUNT(*) AS total FROM ec_host_operations o ${where}`).get(...parameters) 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,
@@ -411,7 +415,7 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
FROM ec_host_operations o LEFT JOIN ec_hosts h ON h.id=o.host_id AND h.deleted_at IS NULL
${where}
ORDER BY o.created_at DESC,o.rowid DESC LIMIT ? OFFSET ?`)
.all(pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations'];
.all(...parameters, pageSize, (currentPage - 1) * pageSize) as HostOperationHistoryPage['operations'];
return { operations: operations.map(operation => ({ ...operation, routine: !!operation.routine,
targetPath: safeOperationTarget(operation.kind, operation.targetPath) })), filter,
page: currentPage, pageSize, total, totalPages };
+2
View File
@@ -110,6 +110,8 @@ export function ensureHostSchema(handle: Database.Database): void {
})();
}
handle.exec(`CREATE INDEX IF NOT EXISTS ec_host_operations_routine_history ON ec_host_operations(routine,created_at);
CREATE INDEX IF NOT EXISTS ec_host_operations_host_history ON ec_host_operations(host_id,created_at);
CREATE INDEX IF NOT EXISTS ec_host_operations_server_history ON ec_host_operations(server_name,created_at);
CREATE INDEX IF NOT EXISTS ec_host_operations_important_history ON ec_host_operations(created_at)
WHERE ${importantOperationSql.replaceAll('o.', '')}`);
handle.exec('CREATE TABLE IF NOT EXISTS ec_host_schema_migrations (name TEXT PRIMARY KEY)');
+6 -2
View File
@@ -1,7 +1,7 @@
import { error, json } from '@sveltejs/kit';
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
import { isHostOperationFilter } from '$lib/hosts.js';
import { hostOperationScope, isHostOperationFilter } from '$lib/hosts.js';
export function GET({ request }: { request: Request }) {
requireManagementOrigin(request);
@@ -10,5 +10,9 @@ export function GET({ request }: { request: Request }) {
const filter = params.get('filter') ?? 'important';
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.');
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
return json(listHostOperationHistory(page, filter), { headers: { 'Cache-Control': 'no-store' } });
const scope = hostOperationScope(params);
for (const key of ['host', 'server'] as const) {
if (params.has(key) && params.get(key) !== scope[key]) error(400, 'Invalid operation history scope.');
}
return json(listHostOperationHistory(page, filter, scope), { headers: { 'Cache-Control': 'no-store' } });
}
+8 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount, untrack } from 'svelte';
import { beforeNavigate, afterNavigate, goto } from '$app/navigation';
@@ -36,7 +37,12 @@
if (query.hostId) params.set('host', query.hostId);
return params;
}
function scopeUrl(change: Partial<CapacityQuery>) { return `/capacity?${queryParams({ ...report.query, ...change })}`; }
function scopeUrl(change: Partial<CapacityQuery>) {
const params = queryParams({ ...report.query, ...change });
const returnTo = page.url.searchParams.get('returnTo');
if (returnTo) params.set('returnTo', returnTo);
return `/capacity?${params}`;
}
function cancel() { sequence++; request?.abort(); request = null; refreshing = false; }
async function refresh(signal?: AbortSignal) {
if (navigating || refreshing) return;
@@ -72,6 +78,7 @@
<SectionHeader title="Capacity" subtitle="See what your hosts can run, what is limiting them, and how headroom changes.">
{#snippet actions()}<Button href="/hosts">Manage hosts</Button><Button disabled={refreshing || navigating} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader>
{#if page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
{#if refreshError || navigationError}<div class="refresh-error" role="alert"><strong>{navigationError || refreshError}</strong><span>Showing the last successfully loaded capacity report.</span></div>{/if}
<div class="toolbar">
<div class="filters"><Field label="Host"><Select value={report.query.hostId ?? ''} options={hostOptions} disabled={navigating} onchange={event => void changeScope({ hostId: (event.currentTarget as HTMLSelectElement).value || null })} /></Field>
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import { untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js';
let requestSequence = 0;
@@ -57,7 +58,7 @@
<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="Running instances, 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}
{#snippet actions()}<Button href={contextHref(`/capacity?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>Capacity</Button><Button href={contextHref(`/hosts/operations?host=${encodeURIComponent($page.params.id ?? "")}`, $page.url)}>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}
+7 -5
View File
@@ -1,19 +1,21 @@
import { redirect } from '@sveltejs/kit';
import { listHostOperationHistory } from '$lib/server/hosts/index.js';
import { isHostOperationFilter } from '$lib/hosts.js';
import { getHost, listHostOperationHistory } from '$lib/server/hosts/index.js';
import { hostOperationScope, isHostOperationFilter } from '$lib/hosts.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => {
const requestedPage = url.searchParams.get('page');
const requestedFilter = url.searchParams.get('filter');
const filter = isHostOperationFilter(requestedFilter) ? requestedFilter : 'important';
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter);
const scope = hostOperationScope(url.searchParams);
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter, scope);
if ((requestedPage !== null && requestedPage !== String(history.page))
|| (requestedFilter !== null && requestedFilter !== filter)) {
const params = new URLSearchParams();
const params = new URLSearchParams(url.searchParams);
params.delete('page');
if (requestedFilter !== null) params.set('filter', filter);
if (history.page !== 1) params.set('page', String(history.page));
redirect(307, `/hosts/operations${params.size ? `?${params}` : ''}`);
}
return { history };
return { history, scope, hostName: scope.host ? getHost(scope.host)?.name ?? scope.host : null };
};
+14 -8
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import { navigating } from '$app/stores';
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 { timeFormat } from '$ui/stores/time-zone.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';
@@ -25,9 +27,10 @@
{ 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)!);
function pageHref(page: number, filter: HostOperationFilter = history.filter) {
const params = new URLSearchParams({ filter });
if (page !== 1) params.set('page', String(page));
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; }
@@ -40,7 +43,7 @@
request = controller;
refreshing = true;
try {
const params = new URLSearchParams({ 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] : [])])
});
@@ -60,12 +63,14 @@
onMount(() => { const stop = pollVisible(refresh, 15000, false); return () => { stop(); cancel(); }; });
</script>
<svelte:head><title>Operation history · Hosts · Electric Cherry</title></svelte:head>
<svelte:head><title>Host operation history · 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.">
<SectionHeader title="Host operation history" eyebrow="Operations" subtitle="Remote operations, newest first. Filtered views retain their host and server context.">
{#snippet actions()}<Button disabled={refreshing || changingPage} onclick={() => void refresh()}>{refreshing ? 'Refreshing…' : 'Refresh'}</Button>{/snippet}
</SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/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: ${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}
@@ -86,7 +91,7 @@
<tbody>{#each history.operations as operation (operation.id)}
<tr>
<td class="operation">{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>{#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 ?? operation.instanceId ?? 'Host-level'}</span>
{#if operation.targetPath}<small class="target-path">{operation.targetPath}</small>{/if}
@@ -112,6 +117,7 @@
</section>
<style>
.scope { display:flex; flex-wrap:wrap; gap:16px; color:var(--text-secondary); font-size:var(--text-sm); }
.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); }
+2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
@@ -112,6 +113,7 @@
<SectionHeader title="Provisioning" subtitle="Create, start, restart, rebuild, stop, and destroy server instances.">
{#snippet actions()}<Button onclick={() => lifecycleOpen = true}>Manage server</Button><Button variant="primary" onclick={() => open = true}>Create server</Button><details class="fleet-actions"><summary>Fleet actions</summary><Button variant="danger" onclick={() => housePartyOpen = true}>Destroy fleet…</Button></details>{/snippet}
</SectionHeader>
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
<Tabs {tabs} bind:value={tab} onchange={() => saveContext(true)} ariaLabel="Provisioning views" idPrefix="deployment-tabs" panelId="deployment-panel" />
<LifecycleDialog bind:open={lifecycleOpen} />
<DeployServerDialog bind:open />
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { safeReturnHref, returnLabel } from '$lib/navigation-context.js';
import { safeReturnHref, returnLabel, contextHref } from '$lib/navigation-context.js';
import { onDestroy, onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import type { PageData } from './$types';
@@ -145,7 +145,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/operations">View host operation history</a>
<a href={contextHref(`/hosts/operations?server=${encodeURIComponent(deployment.server)}`, $page.url)}>View host operation history</a>
</div></Card>
{/if}
{#if outcome.active && detail.workerActivity}
+8 -5
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
let { data }: { data: import('./$types').PageData } = $props();
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
@@ -284,7 +285,7 @@
{#if hasDiagnosticIssue}
<section class="connection-issue" aria-label="Server connection issue">
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : ''}</p></div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={`/hosts/${server.host_id}`}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
<div class="issue-actions"><Button size="sm" onclick={() => changeTab('diagnostics')}>Diagnose connection</Button><Button size="sm" variant="ghost" onclick={() => changeTab('logs')}>Open logs</Button>{#if native && server.host_id}<Button size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Check host</Button>{:else}<Button size="sm" href="/settings">Check system setup</Button>{/if}</div>
</section>
{/if}
<div class="tabs-row">
@@ -384,7 +385,7 @@
{native ? 'Probe RCON through agent' : 'Probe RCON'}
</Button>
{/if}
{#if server.host_id}<Button variant="secondary" size="sm" href={`/hosts/${server.host_id}`}>Inspect host</Button>{/if}
{#if server.host_id}<Button variant="secondary" size="sm" href={contextHref(`/hosts/${server.host_id}`, $page.url)}>Inspect host</Button>{/if}
</div>
{#if rconProbe?.ok}
@@ -402,7 +403,7 @@
<h2>Configuration</h2>
</div>
<div class="diag-grid">
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={`/hosts/${server.host_id}`}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
{#if native}<div class="kv"><span class="k">Host</span><span class="v"><a href={contextHref(`/hosts/${server.host_id}`, $page.url)}>{server.host?.name ?? server.host_id}</a></span></div><div class="kv"><span class="k">Instance</span><span class="v mono">{server.native_instance_id}</span></div><div class="kv"><span class="k">Generation</span><span class="v mono">{server.instance_generation}</span></div><div class="kv"><span class="k">Game-file version</span><span class="v mono"><a href="/game-files">{server.game_library_id}</a></span></div>{#if server.network}<div class="kv"><span class="k">Local UDP binding</span><span class="v mono">{server.network.bindAddress}:{server.network.localPort}</span></div><div class="kv"><span class="k">Public / NAT endpoint</span><span class="v mono">{server.network.publicAddress || server.host?.publicAddress || server.host?.hostname || 'Not configured'}:{server.network.publicPort ?? server.network.localPort}</span></div>{/if}
{/if}
<div class="kv"><span class="k">Game port</span><span class="v mono">{server.game_port ?? '(unresolved)'}</span></div>
<div class="kv"><span class="k">Game</span><span class="v mono">{server.game ?? '(unset)'}</span></div>
@@ -416,6 +417,7 @@
{:else if activeTab === 'matches'}
{#key name}<MatchHistory server={name} />{/key}
{:else if activeTab === 'history'}
<div class="history-links"><Button href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</Button><Button href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</Button></div>
{#if operations.length}
<Card padding="lg">
<div class="panel-head">
@@ -424,7 +426,7 @@
</div>
<div class="server-operations">
{#each operations as operation (operation.key)}
<a href={operation.href}>
<a href={contextHref(operation.href, $page.url)}>
<strong>{operation.label}</strong>
<span>{operation.detail}</span>
<StatusBadge state={operation.state} />
@@ -447,7 +449,7 @@
<div class="history">
{#each history.slice(0,historyLimit) as d (d.id)}
{@const mine = d.servers.find((s) => s.server === name)!}
<a class="hist-row s-{mine.restart_state}" href="/campaigns/{d.id}">
<a class="hist-row s-{mine.restart_state}" href={contextHref(`/campaigns/${d.id}`, $page.url)}>
<span class="hist-action">{d.action}</span>
<span class="hist-state">
<StatusBadge state={mine.restart_state} />
@@ -476,6 +478,7 @@
{/if}
<style>
.history-links { display:flex; flex-wrap:wrap; gap:12px; }
.server-actions { text-align: right; }
.server-actions summary { cursor: pointer; padding: 9px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); font-size: var(--text-sm); }
.server-actions[open] summary { margin-bottom: 10px; }