Unify retained operation history in Activity

This commit is contained in:
2026-10-07 19:30:38 -04:00
parent c39563daef
commit 05bdeaf624
35 changed files with 1008 additions and 120 deletions
+6
View File
@@ -0,0 +1,6 @@
import { parseActivityQuery } from '$lib/activity.js';
import { activityHistory } from '$lib/server/activity.js';
export function load({ url }: { url: URL }) {
return { activity: activityHistory(parseActivityQuery(url.searchParams)) };
}
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import ActivityLog from '$ui/components/ActivityLog.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let { data }: { data: import('./$types').PageData } = $props();
</script>
<svelte:head><title>Activity · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Activity" subtitle="Server operations, releases, maintenance, and host work in one history.">
{#snippet actions()}{#if data.user && data.user.role !== 'viewer'}<Button href="/provisioning">Manage servers</Button>{/if}{/snippet}
</SectionHeader>
<ActivityLog initial={data.activity} />
</div>
<style>
.page { display: grid; gap: var(--space-6); min-width: 0; }
</style>
+7
View File
@@ -0,0 +1,7 @@
import { json } from '@sveltejs/kit';
import { parseActivityQuery } from '$lib/activity.js';
import { activityHistory } from '$lib/server/activity.js';
export function GET({ url }: { url: URL }) {
return json(activityHistory(parseActivityQuery(url.searchParams)), { headers: { 'Cache-Control': 'no-store' } });
}
+1 -1
View File
@@ -11,7 +11,7 @@ export function GET({ request }: { request: Request }) {
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid operation history page.');
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
const scope = hostOperationScope(params);
for (const key of ['host', 'server'] as const) {
for (const key of ['host', 'server', 'operation'] 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' } });
+5 -1
View File
@@ -6,7 +6,11 @@ import { cancelMaintenance, checkPlutonium, queueUpdates, retryMaintenance, save
import { validTimeZone } from '$lib/time-zone.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function GET() { return json(maintenanceOverview()); }
export function GET(event?: { url: URL }) {
const job = event?.url.searchParams.get('job');
if (job !== undefined && job !== null && (!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))) return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
return json(maintenanceOverview(job ? Number(job) : null));
}
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request.' }, { status: 400 });
+2 -1
View File
@@ -1,4 +1,4 @@
import { redirect } from '@sveltejs/kit';
import { error, redirect } from '@sveltejs/kit';
import { getHost, listHostOperationHistory } from '$lib/server/hosts/index.js';
import { hostOperationScope, isHostOperationFilter } from '$lib/hosts.js';
import type { PageServerLoad } from './$types';
@@ -8,6 +8,7 @@ export const load: PageServerLoad = ({ url }) => {
const requestedFilter = url.searchParams.get('filter');
const filter = isHostOperationFilter(requestedFilter) ? requestedFilter : 'important';
const scope = hostOperationScope(url.searchParams);
if (url.searchParams.has('operation') && !scope.operation) error(400, 'Invalid host operation.');
const history = listHostOperationHistory(Number(requestedPage ?? 1), filter, scope);
if ((requestedPage !== null && requestedPage !== String(history.page))
|| (requestedFilter !== null && requestedFilter !== filter)) {
+12 -2
View File
@@ -27,6 +27,11 @@
{ id: 'all', label: 'All operations', description: 'Every recorded remote operation.', empty: 'No remote operations yet.' }
];
const selectedFilter = $derived(filters.find(filter => filter.id === history.filter)!);
const allOperationsHref = $derived.by(() => {
const params = new URLSearchParams($page.url.searchParams);
params.delete('operation'); params.delete('page');
return `/hosts/operations${params.size ? `?${params}` : ''}`;
});
function pageHref(pageNumber: number, filter: HostOperationFilter = history.filter) {
const params = new URLSearchParams($page.url.searchParams);
params.set('filter', filter); params.delete('page');
@@ -69,7 +74,8 @@
<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.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: ${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">
@@ -82,7 +88,7 @@
</div>
{#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">{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">
@@ -118,6 +124,10 @@
<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); }
+22 -8
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import ActivityLog from '$ui/components/ActivityLog.svelte';
import BackLink from '$ui/components/BackLink.svelte';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import { tick, untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { MaintenanceOverview, MaintenancePolicy } from '$lib/maintenance.js';
@@ -38,6 +39,15 @@
let scheduleLoaded = false;
let displayedZone = $state('');
let request = 0;
const focusedJob = $derived(/^[1-9]\d*$/.test($page.url.searchParams.get('job') ?? '') ? $page.url.searchParams.get('job') : null);
let scrolledJob: string | null = null;
$effect(() => {
if (!focusedJob) { scrolledJob = null; return; }
if (data?.runtime.jobs.some(job => String(job.id) === focusedJob) && scrolledJob !== focusedJob) {
scrolledJob = focusedJob;
void tick().then(() => document.getElementById(`job-${focusedJob}`)?.scrollIntoView({ block: 'start' }));
}
});
let checkedAt = $state<number | null>(null);
const tabs = [{ id: 'overview', label: 'Overview' }, { id: 'policies', label: 'Policies & schedules' }, { id: 'activity', label: 'Activity' }];
const policies: { id: MaintenancePolicy; title: string; description: string }[] = [
@@ -71,7 +81,7 @@
const isLatest = (item: (typeof activities)[number]) => !item.job || !data?.runtime.jobs.some(j => j.server === item.server && j.kind === item.job!.kind && j.id > item.job!.id);
const attention = $derived(activities.filter(a => !a.server_deleted_at && a.state === 'attention' && isLatest(a)).length);
const waiting = $derived(activities.filter(a => !a.server_deleted_at && a.state === 'active').length);
const matching = $derived(activities.filter(a =>
const matching = $derived(activities.filter(a => focusedJob ? a.key === `job-${focusedJob}` :
(kind === 'all' || a.kind === kind) &&
(!server || `${a.server} ${serverTitle(a.server)}`.toLowerCase().includes(server.toLowerCase())) &&
(view === 'overview' ? !a.server_deleted_at && a.state !== 'done' && isLatest(a) : status === 'all' || a.state === status)
@@ -103,12 +113,12 @@
checkedAt = Date.now();
if (!scheduleLoaded) { scheduleEnabled = body.runtime.schedule.enabled; scheduleLoaded = true; }
}
async function load() {
async function load(job = focusedJob) {
if (busy || refreshing) return;
refreshing = true;
const current = ++request;
try {
const response = await fetch('/api/maintenance');
const response = await fetch(`/api/maintenance${job ? `?job=${encodeURIComponent(job)}` : ''}`);
if (!response.ok) throw new Error('Could not refresh maintenance.');
const body = await response.json();
if (current === request) { receive(body); error = ''; }
@@ -126,7 +136,7 @@
} catch (cause) { error = (cause as Error).message; }
finally { busy = false; }
}
onMount(() => pollVisible(load, 5000));
$effect(() => { const job = focusedJob; return untrack(() => pollVisible(() => load(job), 5000)); });
</script>
<svelte:head><title>Maintenance | Electric Cherry</title></svelte:head>
@@ -137,7 +147,7 @@
{#if data}<Button variant="ghost" disabled={busy} onclick={() => act({ action: 'pause', paused: !data!.policies.paused })}>{data.policies.paused ? 'Resume maintenance' : 'Pause all maintenance'}</Button>{/if}
{/snippet}
</SectionHeader>
{#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={load} disabled={busy}>Retry refresh</Button></p>{/if}
{#if error}<p class="ui-error" role="alert">{error} {data ? 'Showing the last successful refresh.' : ''} <Button size="sm" onclick={() => load()} disabled={busy}>Retry refresh</Button></p>{/if}
{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 5 seconds" />{/if}
{#if notice}<p role="status">{notice}</p>{/if}
{#if data?.policies.paused}<div class="paused"><strong>All maintenance is paused.</strong> Checks and verification continue. New actions, scheduled runs, and queued maintenance are held. Submitted operations finish recovery. Campaigns, betas, and manual server operations continue.</div>{/if}
@@ -167,6 +177,9 @@
{#if data.runtime.schedule.nextAt}<p>Next run: {$timeFormat.dateTime(data.runtime.schedule.nextAt)}{data.policies.paused ? ' · Held while maintenance is paused' : ''}</p>{/if}
<p class="muted">Disabling the schedule stops future runs. Existing requests remain in Activity, where unsubmitted restarts can be cancelled.</p>
</section>
{:else if view === 'activity'}
<ActivityLog source="maintenance" />
<p class="muted">Use <a href="/maintenance?view=overview#activity">active work and attention</a> to cancel waiting maintenance or retry a failed verification after inspection.</p>
{:else}
{#if view === 'overview'}
<div class="summary"><span><strong>{attention}</strong> {attention === 1 ? 'needs' : 'need'} attention</span><span><strong>{waiting}</strong> active</span><span><strong>{outdated.length}</strong> outdated</span><span><strong>{unknown.length}</strong> {unknown.length === 1 ? 'version' : 'versions'} unverified</span></div>
@@ -223,7 +236,8 @@
</section>
{/if}
<section id="activity">
<h2>{view === 'overview' ? 'Active work & attention' : 'Maintenance activity'}</h2>
<h2>{focusedJob ? `Maintenance request #${focusedJob}` : view === 'overview' ? 'Active work & attention' : 'Maintenance activity'}</h2>
{#if focusedJob}<p class="muted">Selected retained request. <a href="/maintenance?view=overview#activity">Show active work and attention</a></p>{/if}
<div class="activity">{#each matching.slice(0, limit) as item (item.key)}
<article class="card" id={item.key}>
<div class="row-heading"><h3>{item.title} · <HistoricalServerIdentity name={item.server} deletedAt={item.server_deleted_at} /></h3><Badge variant={item.state === 'attention' ? 'danger' : item.state === 'active' ? 'info' : 'neutral'}>{item.label}</Badge></div>
@@ -234,7 +248,7 @@
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button size="sm" disabled={busy || data.policies.paused} onclick={() => act({ action: 'retry', id: item.job!.id })}>Retry after inspection</Button>{/if}
</div>
</article>
{:else}<EmptyState title={view === 'overview' ? 'No active maintenance in this view' : 'No matching activity'} description="Findings, scheduled restarts, and update verification appear here." />{/each}</div>
{:else}<EmptyState title={focusedJob ? 'Maintenance request unavailable' : view === 'overview' ? 'No active maintenance in this view' : 'No matching activity'} description={focusedJob ? 'This request may have expired under the history retention settings.' : 'Findings, scheduled restarts, and update verification appear here.'} />{/each}</div>
<ListFooter total={matching.length} bind:limit onchange={() => saveContext(false)} label="activities" />
</section>
{/if}
+1 -1
View File
@@ -1,3 +1,3 @@
import { redirect } from '@sveltejs/kit';
export function load() { redirect(308, '/provisioning'); }
export function load() { redirect(308, '/activity'); }
+1 -1
View File
@@ -87,7 +87,7 @@
<div class="page">
<SectionHeader title={deployment ? `${actionLabels[deployment.action]} · ${serverTitle(deployment.server)}` : 'Provisioning operation'} subtitle={deployment?.server}>
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Provisioning', href: '/provisioning' }, { label: 'Operation' }]} />{/snippet}
{#snippet crumbs()}<Breadcrumbs items={[{ label: 'Activity', href: '/activity' }, { label: 'Operation' }]} />{/snippet}
{#snippet actions()}{#if serverHref}<Button href={serverHref}>View server</Button>{/if}{/snippet}
</SectionHeader>
<div class="identity-row">
+13 -37
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import { instanceActivity } from '$ui/server-activity.js';
import ActivityLog from '$ui/components/ActivityLog.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { freshTelemetry } from '$lib/server-observation.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
@@ -12,7 +12,6 @@
import MatchHistory from '$ui/components/MatchHistory.svelte';
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte';
import { pollVisible } from '$ui/poll.js';
import { fetchCampaigns } from '$ui/campaigns.js';
import { createFleetStore } from '$ui/stores/fleet.js';
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
import { serverTitle } from '$ui/server-labels.js';
@@ -78,10 +77,6 @@
artifactId?: number | null; version?: number | null; releaseVersion?: string | null;
sha256: string; size: number; updatedAt: string; source: string | null;
}
interface DeploymentRow {
id: string; action: string; started_at: string; finished_at: string | null;
servers: Array<{ server: string; restart_state: string; players: number | null; error: string | null; files_changed: number }>;
}
let name = $derived($page.params.name ?? '');
const detail = $derived(createFleetStore(name));
@@ -90,12 +85,11 @@
detail.hydrateFleet(data.serverSnapshot);
return detail.startFleetMonitor();
});
const snapshot = $derived($fleet.receivedAt ? $fleet : data.serverSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.serverSnapshot.servers);
const serverContext = $derived(visibleFleet.find(s => s.name === name) ?? null);
let server = $derived((serverContext as Server | null));
const health = $derived(serverContext ? serverHealth(serverContext, $uiNow) : null);
const canOperate = $derived($page.data.user?.role === 'admin' || $page.data.user?.role === 'operator');
const canOperate = $derived($page.data?.user?.role === 'admin' || $page.data?.user?.role === 'operator');
const availableActions = $derived(serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []);
const primaryAction = $derived(availableActions.includes('restart') ? 'restart' : availableActions.includes('start') ? 'start' : null);
function openAction(action: RuntimeAction) { if (canOperate && availableActions.includes(action)) { lifecycleAction = action; lifecycleOpen = true; } }
@@ -112,7 +106,6 @@
});
const native = $derived(!!server?.native_instance_id);
let telemetryOpen = $state(false);
let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]);
let rconProbe = $state<RconProbeResult | null>(null);
let probingRcon = $state(false);
@@ -137,29 +130,18 @@
}
let tabLoading = $state(false);
let tabError = $state('');
let historyLimit = $state(25);
let historyTotal = $state(0);
let inventoryLimit = $state(25);
const operations = $derived(snapshot.operations.filter(op => op.server === name));
const activity = $derived(instanceActivity(name, operations, history));
let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'history' && tab !== 'configuration') return;
if (tab !== 'configuration') return;
const sequence = ++tabSequence;
try {
if (tab === 'history') {
const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal);
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
history = body.deployments; historyTotal = body.total;
} else {
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
if (!response.ok) throw new Error('Could not refresh inventory.');
const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
inventory = body.scripts;
}
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
if (!response.ok) throw new Error('Could not refresh inventory.');
const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeSection !== tab || signal?.aborted) return;
inventory = body.scripts;
tabError = '';
return true;
} catch (error) {
@@ -170,8 +152,9 @@
$effect(() => {
const currentName = name;
const tab = activeSection;
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
tabLoading = tab === 'history' || tab === 'configuration';
tabError = ''; inventory = []; inventoryLimit = 25;
tabLoading = tab === 'configuration';
if (tab !== 'configuration') return;
return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
});
$effect(() => { name; rconProbe = null; });
@@ -382,15 +365,8 @@
{:else if activeSection === 'matches'}
{#key name}<MatchHistory server={name} />{/key}
{:else if activeSection === 'history'}
<div class="history-links"><a href={contextHref(`/provisioning?view=history&server=${encodeURIComponent(name)}`, $page.url)}>Provisioning history</a><a href={contextHref(`/hosts/operations?server=${encodeURIComponent(name)}`, $page.url)}>Host operation history</a></div>
<Card>
<div class="panel-head"><h2>Activity</h2></div><p class="sub">Recent operations and campaign history</p>
{#if tabLoading}<p class="sub">Loading campaign history…</p>{/if}
{#if activity.length}<div class="table-scroll"><table class="detail-table"><thead><tr><th>Time</th><th>Activity</th><th>Result</th><th>Source</th></tr></thead><tbody>
{#each activity as item (item.key)}<tr><td><time datetime={item.at} title={$timeFormat.dateTime(item.at)}>{ago(item.at, $uiNow)}</time></td><td>{item.label}{#if item.detail}<div class="ui-error">{item.detail}</div>{/if}</td><td><StatusBadge state={item.state} /></td><td><a href={contextHref(item.href, $page.url)}>{item.source}</a></td></tr>{/each}
</tbody></table></div>{:else if !tabLoading && !tabError}<EmptyState title="No activity recorded" />{/if}
</Card>
<ListFooter total={historyTotal} bind:limit={historyLimit} onchange={() => loadTab(name, 'history')} label="campaigns" />
<div class="history-links"><a href={contextHref(`/activity?server=${encodeURIComponent(name)}`, $page.url)}>Open in Activity</a></div>
{#key name}<ActivityLog server={name} />{/key}
{/if}
</div>
</div>