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
+10
View File
@@ -53,3 +53,13 @@ Keyboard shortcut help is available from the account menu and with `?`.
Detail pages use `Breadcrumbs`. Its parent link restores the validated `returnTo`
URL, including filters, sorting, pagination, and anchors. Supply `fallback` when
the default parent also needs filters. Do not add a duplicate Back link.
### Activity history
Use `ActivityLog` for the fleet timeline and scoped history. The server History
tab fixes the server filter; Maintenance fixes the maintenance/recovery source.
The API counts and pages retained records together, with stable ordering. Parent
operations represent their host receipts and maintenance verification so a single
user action does not appear multiple times. Routine successful host checks stay
in the technical Host operation history. Keep detail links contextual so their
parent breadcrumb restores filters and pagination.
+5 -3
View File
@@ -17,9 +17,11 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
when the running revision is unverified.
- **Policies & schedules** controls health recovery, configuration drift, automatic
Plutonium updates, and the optional daily restart schedule independently.
- **Activity** combines recovery findings, update results, and scheduled restarts.
Follow a finding for evidence and attempts, or its Provisioning operation for
execution progress. Cancel unsubmitted runtime restarts here.
- **Activity** uses the shared Activity log, scoped to maintenance and recovery.
Filter retained findings, update results, and scheduled restarts in one timeline.
Follow a finding for evidence or an operation for execution progress. Use
Overview’s **Active work & attention** to cancel waiting work or retry failed
verification after inspection.
Fleet and server details link their revision indicators to Maintenance. Failed
runtime maintenance appears in Fleet attention, even when the underlying restart
+3 -2
View File
@@ -1,7 +1,8 @@
# Provisioning
Open **Activity → Operations** for instance lifecycle work and persistent
activity. **Fleet → Collections** owns reusable lineups and collection launch
Open **Activity → Activity log** for retained lifecycle, host, campaign, and
maintenance history. Use **Manage servers** from that page or command search
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and
recovery for all six actions. Create a server using **Create server**. For an existing
+87
View File
@@ -0,0 +1,87 @@
export const activityTypes = {
create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server',
stop: 'Stop server', remove: 'Remove server', update: 'Game update', files: 'File changes',
health: 'Health finding', drift: 'File drift', host: 'Host operation', beta: 'Beta test'
} as const;
export type ActivityType = keyof typeof activityTypes;
export const activitySources = {
provisioning: 'Server operations', maintenance: 'Maintenance & recovery', recovery: 'Recovery',
campaign: 'Campaigns', beta: 'Betas', host: 'Hosts', autoscaling: 'Autoscaling'
} as const;
export type ActivitySource = keyof typeof activitySources;
export const activityStates = {
queued: 'Queued', running: 'In progress', succeeded: 'Completed', warning: 'Completed with warnings',
failed: 'Failed', cancelled: 'Cancelled', attention: 'Needs attention', unknown: 'Unknown'
} as const;
export type ActivityState = keyof typeof activityStates;
export interface ActivityRow {
id: string;
at: string;
type: ActivityType;
source: ActivitySource;
/** A shared operation can have more than one caller; filters match any membership. */
sources: ActivitySource[];
state: ActivityState;
rawState: string;
title: string;
detail: string | null;
server: string | null;
serverDeletedAt: string | null;
hostId: string | null;
hostName: string | null;
href: string;
}
export interface ActivityQuery {
server?: string;
host?: string;
type?: ActivityType;
source?: ActivitySource;
state?: ActivityState;
days?: number;
q?: string;
limit?: number;
offset?: number;
}
export interface ActivityPage {
rows: ActivityRow[];
total: number;
limit: number;
offset: number;
query: ActivityQuery;
}
function selection<T extends string>(value: string | null, labels: Record<T, string>): T | undefined {
return value !== null && Object.hasOwn(labels, value) ? value as T : undefined;
}
function integer(value: string | null, fallback: number, min: number, max: number): number {
if (value === null || !/^\d+$/.test(value)) return fallback;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}
/** Server and host are exact identities; q searches friendly names and safe summaries. */
export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
const query: ActivityQuery = {
limit: integer(params.get('limit'), 25, 1, 100),
offset: integer(params.get('offset'), 0, 0, 1_000_000_000)
};
for (const key of ['server', 'host', 'q'] as const) {
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
if (value) query[key] = value;
}
query.type = selection(params.get('type'), activityTypes);
query.source = selection(params.get('source'), activitySources);
query.state = selection(params.get('state'), activityStates);
// Missing or zero days means all retained history, including removed servers.
const days = integer(params.get('days'), 0, 0, 36_500);
if (days) query.days = days;
return query;
}
export function normalizeActivityQuery(query: ActivityQuery): ActivityQuery {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, String(value));
return parseActivityQuery(params);
}
+2 -2
View File
@@ -130,10 +130,10 @@ export interface HostOperationSummary extends Omit<HostOperation, 'payload' | 'r
routine: boolean;
}
export type HostOperationFilter = 'important' | 'routine' | 'all';
export interface HostOperationScope { host?: string; server?: string }
export interface HostOperationScope { host?: string; server?: string; operation?: string }
export function hostOperationScope(params: URLSearchParams): HostOperationScope {
const scope: HostOperationScope = {};
for (const key of ['host', 'server'] as const) {
for (const key of ['host', 'server', 'operation'] as const) {
const value = params.get(key);
if (value && value.length <= 512 && !/[\x00-\x1f\x7f]/.test(value)) scope[key] = value;
}
+2 -2
View File
@@ -1,5 +1,5 @@
const origin = 'https://electric-cherry.invalid';
const pagePath = /^\/(?:$|operations|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
const pagePath = /^\/(?:$|activity|operations|autoscaling|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
@@ -30,6 +30,6 @@ export function returnLabel(href: string): string {
if (path.startsWith('/matches/')) return 'Back to match';
if (path === '/hosts/operations') return 'Back to host operation history';
if (path.startsWith('/hosts/')) return 'Back to host';
const names: Record<string, string> = { '': 'Fleet', operations: 'Operations', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Provisioning', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
const names: Record<string, string> = { '': 'Servers', activity: 'Activity', operations: 'Activity', autoscaling: 'Autoscaling', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Server queues', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' };
return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`;
}
+1 -2
View File
@@ -20,8 +20,7 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
] },
{ id: 'activity', label: 'Activity', items: [
{ label: 'Operations', href: '/provisioning', shortcut: 'd', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Host operations', href: '/hosts/operations' },
{ label: 'Activity log', href: '/activity', shortcut: 'd', matches: path => path === '/activity' || path === '/operations' || path === '/hosts/operations' || /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }
] },
{ id: 'players', label: 'Players', items: [
+188
View File
@@ -0,0 +1,188 @@
import type Database from 'better-sqlite3';
import { normalizeActivityQuery, type ActivityPage, type ActivityQuery, type ActivityRow, type ActivitySource } from '../activity.js';
import { serverTitle } from '../ui/server-labels.js';
import { db } from './db/index.js';
import { ensureHostSchema } from './hosts/schema.js';
import { importantOperationSql } from './hosts/observations.js';
const initialized = new WeakSet<Database.Database>();
const quote = (value: string) => `'${value.replaceAll("'", "''")}'`;
const hostTitles: Record<string, string> = {
'instance.create': 'Create server container', 'instance.rebuild': 'Rebuild server container',
'instance.start': 'Start server container', 'instance.restart': 'Restart server container',
'instance.stop': 'Stop server container', 'instance.remove': 'Remove server container',
'instance.cancel-create': 'Cancel server creation', 'instance.inspect': 'Check server container',
'host.inspect': 'Check host', 'host.decommission': 'Remove host', 'runtime.pull': 'Prepare container runtime',
'library.download': 'Download game files', 'library.import': 'Import game files',
'plutonium.prepare': 'Prepare game update', 'files.stage': 'Stage server files',
'files.apply': 'Apply server files', 'files.read': 'Read server files', 'files.list': 'List server files',
'files.stat': 'Check server files', 'files.manifest': 'Check file versions', 'files.remove': 'Remove server files',
'rcon.status-batch': 'Check game status', 'rcon.execute': 'Game command', 'logs.read': 'Read server logs'
};
const labelCase = (column: string, labels: Record<string, string>, fallback: string) =>
`CASE ${column} ${Object.entries(labels).map(([key, value]) => `WHEN ${quote(key)} THEN ${quote(value)}`).join(' ')} ELSE ${quote(fallback)} END`;
// Old attempts may no longer be the native_provisioning pointer. Their stable
// idempotency keys still identify the same retained controller operation.
const lifecycleKey = `CASE
WHEN substr(o.idempotency_key,-6)=':stage' THEN substr(o.idempotency_key,1,length(o.idempotency_key)-6)
${[':migration-create:', ':migrate-remove:'].map(marker => `WHEN instr(o.idempotency_key,${quote(marker)})>0
AND substr(o.idempotency_key,instr(o.idempotency_key,${quote(marker)})+${marker.length})<>''
AND substr(o.idempotency_key,instr(o.idempotency_key,${quote(marker)})+${marker.length}) NOT GLOB '*[^0-9]*'
THEN substr(o.idempotency_key,1,instr(o.idempotency_key,${quote(marker)})-1)`).join('\n')}
ELSE o.idempotency_key END`;
/** One row per user-level operation or finding; no command payloads or observations are read. */
const activitySql = `WITH
links AS (
SELECT operation_id,kind,source_id,server FROM provisioning_sources
UNION SELECT id,substr(triggered_by,1,instr(triggered_by,':')-1),substr(triggered_by,instr(triggered_by,':')+1),server
FROM server_deployments WHERE substr(triggered_by,1,instr(triggered_by,':')-1) IN ('maintenance','watchdog','campaign','beta','autoscaling')
UNION SELECT provisioning_id,'maintenance',CAST(id AS TEXT),server FROM maintenance_jobs WHERE provisioning_id IS NOT NULL
UNION SELECT provisioning_id,'campaign',deployment_id,server FROM deployment_servers WHERE provisioning_id IS NOT NULL
UNION SELECT provisioning_id,'beta',CAST(beta_id AS TEXT),server FROM beta_servers WHERE provisioning_id IS NOT NULL
UNION SELECT a.operation_id,'watchdog',i.id,i.server FROM watchdog_actions a JOIN watchdog_incidents i ON i.id=a.incident_id
),
memberships AS (
SELECT operation_id,'|' || replace(group_concat(DISTINCT CASE kind WHEN 'watchdog' THEN 'recovery' ELSE kind END),',','|') || '|' AS sources
FROM links GROUP BY operation_id
),
maintenance_links AS (
SELECT DISTINCT l.operation_id,m.id,m.state,m.kind,m.target_revision
FROM links l JOIN maintenance_jobs m ON l.kind='maintenance' AND l.source_id=CAST(m.id AS TEXT) AND l.server=m.server
JOIN server_deployments d ON d.id=l.operation_id
WHERE m.provisioning_id=l.operation_id OR (m.provisioning_id IS NULL AND NOT EXISTS (
SELECT 1 FROM links newer JOIN server_deployments n ON n.id=newer.operation_id
WHERE newer.kind='maintenance' AND newer.source_id=l.source_id AND newer.server=l.server
AND (n.started_at>d.started_at OR (n.started_at=d.started_at AND n.rowid>d.rowid))))
),
maintenance_results AS (
SELECT operation_id,MAX(state='failed') AS failed,MAX(state IN ('queued','restarting')) AS pending,
MAX(state='cancelled') AS cancelled,MAX(kind='update') AS is_update,MAX(target_revision) AS revision
FROM maintenance_links GROUP BY operation_id
),
covered_host AS (
SELECT n.operation_id AS id FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id WHERE n.operation_id IS NOT NULL
UNION SELECT n.stage_operation_id FROM native_provisioning n JOIN server_deployments d ON d.id=n.deployment_id WHERE n.stage_operation_id IS NOT NULL
UNION SELECT m.remove_operation_id FROM ec_host_migrations m JOIN server_deployments d ON d.id=m.deployment_id WHERE m.remove_operation_id IS NOT NULL
),
items AS (
SELECT 'provisioning:' || d.id AS id,d.started_at AS at,
CASE WHEN m.is_update=1 THEN 'update' WHEN d.action='destroy' THEN 'remove' ELSE d.action END AS type,
COALESCE(ms.sources,'|provisioning|') AS sources,
CASE WHEN d.state IN ('failed','interrupted','cancelled','warning') THEN d.state
WHEN m.failed=1 THEN 'failed' WHEN m.pending=1 AND d.state='done' THEN 'verifying'
WHEN m.cancelled=1 THEN 'cancelled' ELSE d.state END AS rawState,
CASE WHEN m.is_update=1 THEN 'Update game' ELSE ${labelCase('d.action', { create: 'Create server', start: 'Start server', restart: 'Restart server', rebuild: 'Rebuild server', stop: 'Stop server', destroy: 'Remove server' }, 'Server operation')} END AS title,
CASE WHEN m.failed=1 AND d.state='done' THEN 'The restart completed, but maintenance verification failed.'
WHEN m.pending=1 AND d.state='done' THEN 'The restart completed. Maintenance verification is still in progress.'
WHEN m.revision IS NOT NULL THEN 'Target game revision r' || m.revision || '.'
WHEN d.state='waiting' THEN 'Waiting for eligibility and available capacity.'
WHEN d.state='awaiting-health' THEN 'Checking that the game is responding.' ELSE NULL END AS detail,
d.server,d.server_deleted_at AS serverDeletedAt,COALESCE(n.host_id,mg.source_host_id) AS hostId,mg.source_host_id AS secondaryHostId,
'provisioning' AS route,d.id AS recordId
FROM server_deployments d LEFT JOIN memberships ms ON ms.operation_id=d.id
LEFT JOIN maintenance_results m ON m.operation_id=d.id
LEFT JOIN native_provisioning p ON p.deployment_id=d.id LEFT JOIN native_instances n ON n.id=p.instance_id
LEFT JOIN ec_host_migrations mg ON mg.deployment_id=d.id
UNION ALL
SELECT 'maintenance:' || m.id,m.requested_at,CASE WHEN m.kind='update' THEN 'update' ELSE 'restart' END,
'|maintenance|',m.state,CASE WHEN m.kind='update' THEN 'Update game' WHEN m.kind='schedule' THEN 'Scheduled server maintenance' ELSE 'Server maintenance' END,
CASE WHEN m.target_revision IS NOT NULL THEN 'Target game revision r' || m.target_revision || '.' ELSE NULL END,
m.server,m.server_deleted_at,NULL,NULL,'maintenance-job',CAST(m.id AS TEXT)
FROM maintenance_jobs m WHERE NOT EXISTS (SELECT 1 FROM maintenance_links l WHERE l.id=m.id)
UNION ALL
SELECT 'recovery:' || i.id,i.first_seen_at,i.kind,'|recovery|',i.state,
CASE WHEN i.kind='health' THEN 'Game health finding' ELSE 'Server files differ from the promoted setup' END,
CASE WHEN i.state='recovering' THEN 'Recovery is in progress.' WHEN i.state='attention' THEN 'Automatic recovery needs review.'
WHEN i.state='resolved' THEN 'The finding was resolved.' WHEN i.state='dismissed' THEN 'The finding was dismissed.' ELSE 'A check found an issue that needs review.' END,
i.server,i.server_deleted_at,NULL,NULL,'recovery',i.id
FROM watchdog_incidents i
UNION ALL
SELECT 'campaign:' || d.id || ':' || ds.server,d.started_at,CASE WHEN d.action='drift' THEN 'drift' ELSE 'files' END,
CASE WHEN d.triggered_by='watchdog' OR EXISTS (SELECT 1 FROM watchdog_actions a WHERE a.campaign_id=d.id)
THEN '|campaign|recovery|' ELSE '|campaign|' END,ds.restart_state,
CASE d.action WHEN 'promote' THEN 'Promote files' WHEN 'rollback' THEN 'Roll back files' ELSE 'Reconcile files' END,
CAST(ds.files_changed AS TEXT) || CASE WHEN ds.files_changed=1 THEN ' file changed.' ELSE ' files changed.' END,
ds.server,ds.server_deleted_at,NULL,NULL,'campaign',d.id
FROM deployments d JOIN deployment_servers ds ON ds.deployment_id=d.id
UNION ALL
SELECT 'beta:' || b.id || ':' || bs.server,b.opened_at,'beta','|beta|',
CASE WHEN b.state IN ('promoted','rolled-back','cancelled') THEN b.state ELSE bs.stage END,
'Beta test #' || b.id,NULL,bs.server,bs.server_deleted_at,NULL,NULL,'beta',CAST(b.id AS TEXT)
FROM betas b JOIN beta_servers bs ON bs.beta_id=b.id
UNION ALL
SELECT 'host:' || o.id,o.created_at,'host','|host|',
CASE WHEN o.state='completed' AND o.routine=1 AND o.observation_success=0 THEN 'observation-failed'
WHEN o.state='completed' AND COALESCE(o.error,'')<>'' THEN 'warning' ELSE o.state END,
${labelCase('o.kind', hostTitles, 'Host operation')},
CASE WHEN o.routine=1 AND o.state='completed' AND o.observation_success=0 THEN 'The command completed, but the check reported a problem.'
WHEN o.state='unknown' THEN 'The host has not confirmed the result.' ELSE NULL END,
COALESCE(o.server_name,n.server),s.destroyed_at,o.host_id,NULL,'host',o.id
FROM ec_host_operations o LEFT JOIN native_instances n ON n.id=o.instance_id
LEFT JOIN servers s ON s.name=COALESCE(o.server_name,n.server)
WHERE o.history_expired_at IS NULL AND ${importantOperationSql}
AND NOT EXISTS (SELECT 1 FROM covered_host c WHERE c.id=o.id)
AND NOT (o.kind IN ('files.stage','instance.create','instance.rebuild','instance.start','instance.restart','instance.stop','instance.remove','instance.cancel-create')
AND EXISTS (SELECT 1 FROM server_deployments d WHERE d.id=${lifecycleKey}))
),
normalized AS (
SELECT i.*,h.name AS hostName,
CASE WHEN rawState IN ('queued','pending','waiting','held','awaiting-empty','awaiting-match') THEN 'queued'
WHEN rawState IN ('running','accepted','preparing','staging','testing','observing','deploying','restarting','recovering','awaiting-health','verifying','rolling-back') THEN 'running'
WHEN rawState IN ('done','completed','resolved','ready','ready-promote','promoted','rolled-back') THEN 'succeeded'
WHEN rawState IN ('failed','interrupted','observation-failed') THEN 'failed'
WHEN rawState IN ('cancelled','dismissed','skipped') THEN 'cancelled'
WHEN rawState IN ('manual','attention','open') THEN 'attention'
WHEN rawState='warning' THEN 'warning' ELSE 'unknown' END AS state
FROM items i LEFT JOIN ec_hosts h ON h.id=i.hostId
)
`;
type SqlRow = Omit<ActivityRow, 'source' | 'sources' | 'href'> & { sources: string; route: string; recordId: string; secondaryHostId: string | null };
const sourceOrder: ActivitySource[] = ['maintenance', 'recovery', 'campaign', 'beta', 'autoscaling', 'provisioning', 'host'];
function present(row: SqlRow): ActivityRow {
const sources = sourceOrder.filter(source => row.sources.includes(`|${source}|`));
const href = row.route === 'maintenance-job' ? `/maintenance?view=overview&job=${encodeURIComponent(row.recordId)}#job-${row.recordId}`
: row.route === 'recovery' ? `/maintenance/${encodeURIComponent(row.recordId)}`
: row.route === 'host' ? `/hosts/operations?operation=${encodeURIComponent(row.recordId)}&filter=all`
: `/${row.route === 'campaign' ? 'campaigns' : row.route === 'beta' ? 'betas' : 'provisioning'}/${encodeURIComponent(row.recordId)}`;
return { id: row.id, at: row.at, type: row.type, source: sources[0] ?? 'provisioning', sources,
state: row.state, rawState: row.rawState, title: row.title, detail: row.detail,
server: row.server, serverDeletedAt: row.serverDeletedAt, hostId: row.hostId, hostName: row.hostName, href };
}
/** All predicates, totals and pagination operate on the same retained SQL union. */
export function activityHistory(input: ActivityQuery = {}, now = Date.now()): ActivityPage {
const query = normalizeActivityQuery(input);
const handle = db();
ensureHostSchema(handle);
if (!initialized.has(handle)) {
handle.function('activity_server_title', { deterministic: true }, (name: unknown) => typeof name === 'string' ? serverTitle(name) : '');
initialized.add(handle);
}
const conditions: string[] = [];
const bindings: Record<string, string | number> = {};
for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; }
if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; }
if (query.source) {
// Maintenance is the user-facing umbrella; Recovery remains available as a narrower filter.
conditions.push(query.source === 'maintenance' ? "(instr(sources,'|maintenance|')>0 OR instr(sources,'|recovery|')>0)" : 'instr(sources,@source)>0');
if (query.source !== 'maintenance') bindings.source = `|${query.source}|`;
}
if (query.days) { conditions.push('julianday(at)>=julianday(@since)'); bindings.since = new Date(now - query.days * 86_400_000).toISOString(); }
if (query.q) {
conditions.push("instr(lower(COALESCE(server,'') || ' ' || activity_server_title(server) || ' ' || COALESCE(hostName,'') || ' ' || COALESCE(hostId,'') || ' ' || title || ' ' || COALESCE(detail,'') || ' ' || id),@q)>0");
bindings.q = query.q.toLocaleLowerCase();
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const limit = query.limit!;
return handle.transaction(() => {
const { total } = handle.prepare(`${activitySql} SELECT COUNT(*) AS total FROM normalized ${where}`).get(bindings) as { total: number };
const offset = Math.min(query.offset!, Math.max(0, Math.ceil(total / limit) - 1) * limit);
const rows = handle.prepare(`${activitySql} SELECT id,at,type,sources,state,rawState,title,detail,server,serverDeletedAt,hostId,hostName,secondaryHostId,route,recordId
FROM normalized ${where} ORDER BY julianday(at) DESC,id DESC LIMIT @limit OFFSET @offset`)
.all({ ...bindings, limit, offset }) as SqlRow[];
return { rows: rows.map(present), total, limit, offset, query: { ...query, offset } };
})();
}
+6 -2
View File
@@ -438,11 +438,15 @@ export function listHostOperationHistory(page = 1, filter: HostOperationFilter =
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); }
if (scope.operation) { conditions.push('o.id=?'); parameters.push(scope.operation); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
return handle.transaction((): HostOperationHistoryPage => {
const countColumn = filter === 'all' ? 'total' : filter;
const total = (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
// An exact operation seeks its unique ID instead of paging through history.
const total = scope.operation
? (handle.prepare(`SELECT 1 AS total FROM ec_host_operations o ${where}`).get(...parameters) as { total: number } | undefined)?.total ?? 0
: (handle.prepare(`SELECT ${countColumn} AS total FROM ec_host_operation_history_counts WHERE host_id=? AND server_name=?`)
.get(scope.host ?? '', scope.server ?? '') as { total: number } | undefined)?.total ?? 0;
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,
+2 -2
View File
@@ -3,8 +3,8 @@ import { watchdogOverview, watchdogTick } from './recovery.js';
import { maintenanceTick, plutoniumStatus } from './runtime.js';
import type { MaintenanceOverview } from '../../maintenance.js';
export function maintenanceOverview(): MaintenanceOverview {
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus() };
export function maintenanceOverview(selectedJob: number | null = null): MaintenanceOverview {
return { policies: maintenancePolicies(), recovery: watchdogOverview(), runtime: plutoniumStatus(selectedJob) };
}
export async function runMaintenance(): Promise<void> {
// One failing observer must not prevent the other capabilities from reconciling.
+2 -2
View File
@@ -18,14 +18,14 @@ export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone
const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
}
export function plutoniumStatus(): PlutoniumStatus {
export function plutoniumStatus(selectedJob: number | null = null): PlutoniumStatus {
return {
release: release(), schedule: schedule(), servers: runningVersions(),
jobs: db().prepare(`SELECT m.*, d.stage AS operation_stage, d.state AS operation_state
FROM maintenance_jobs m LEFT JOIN server_deployments d ON d.id = m.provisioning_id
WHERE m.state IN ('queued','restarting')
OR (m.state = 'failed' AND NOT EXISTS (SELECT 1 FROM maintenance_jobs n WHERE n.server = m.server AND n.kind = m.kind AND n.id > m.id))
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) ORDER BY m.id DESC`).all() as MaintenanceJob[]
OR m.id IN (SELECT id FROM maintenance_jobs ORDER BY id DESC LIMIT 100) OR m.id = ? ORDER BY m.id DESC`).all(selectedJob) as MaintenanceJob[]
};
}
+47
View File
@@ -0,0 +1,47 @@
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
import { matchStartedLabel } from './match-history.js';
export interface ActivityScope { server?: string; source?: ActivitySource }
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
/** Scoped views always retain their server/source, including after reset or a pasted URL. */
export function activityQuery(url: URL, scope: ActivityScope = {}): ActivityQuery {
const params = new URLSearchParams(url.searchParams);
if (scope.server !== undefined) params.set('server', scope.server);
if (scope.source !== undefined) params.set('source', scope.source);
return parseActivityQuery(params);
}
export function activityApiHref(url: URL, scope: ActivityScope = {}): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(activityQuery(url, scope))) {
if (value !== undefined) params.set(key, String(value));
}
return `/api/activity?${params}`;
}
/** Preserve the containing tab and return context while paging or changing filters. */
export function activityHistoryHref(url: URL, changes: Partial<Record<keyof ActivityQuery, string>>): string {
const next = new URL(url);
if (!Object.hasOwn(changes, 'offset')) next.searchParams.delete('offset');
for (const key of keys) {
if (changes[key] === undefined) continue;
const value = changes[key]?.trim();
if (value && !(['offset', 'days'].includes(key) && value === '0')) next.searchParams.set(key, value);
else next.searchParams.delete(key);
}
return next.pathname + next.search + next.hash;
}
/** Recent entries scan as relative time; exact timestamps stay on the time element. */
export function activityTimeLabel(value: string, zone: string, now: number): string {
const timestamp = Date.parse(/Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');
if (!Number.isFinite(timestamp)) return 'Time unavailable';
const age = now - timestamp;
if (age < 0) return matchStartedLabel(value, zone, new Date(now));
if (age < 5_000) return 'Just now';
if (age < 60_000) return `${Math.floor(age / 1000)} s ago`;
if (age < 3_600_000) return `${Math.floor(age / 60_000)} min ago`;
if (age < 86_400_000) return `${Math.floor(age / 3_600_000)} h ago`;
return matchStartedLabel(value, zone, new Date(now));
}
+1 -1
View File
@@ -17,7 +17,7 @@ function matches(query: string, ...values: Array<string | null | undefined>) {
}
export function palettePageResults(role: UserRole, query: string): PaletteItem[] {
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }];
const actions: PaletteItem[] = role === 'viewer' ? [] : [{ key: 'action:create', group: 'Actions', label: 'Create server', detail: 'Choose a map and host', href: '/?action=create' }, { key: 'action:manage', group: 'Actions', label: 'Manage servers', detail: 'Server queues and lifecycle actions', href: '/provisioning' }];
if (role === 'admin') actions.push({ key: 'action:add-host', group: 'Actions', label: 'Add host', detail: 'Enroll a host agent', href: '/hosts?action=add' });
return [...actions.filter(item => matches(query, item.label, item.detail)),
...visibleNavigation(role).flatMap(group => group.items.filter(item => matches(query, item.label, group.label)).map(item => ({
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { activityTypes, activitySources, activityStates, type ActivityPage, type ActivityQuery, type ActivitySource } from '$lib/activity.js';
import { contextHref } from '$lib/navigation-context.js';
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '$ui/activity-history.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import { fleet } from '$ui/stores/fleet.js';
import { uiNow } from '$ui/stores/servers.js';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Dialog from './primitives/Dialog.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import StatusBadge from './primitives/StatusBadge.svelte';
import Freshness from './Freshness.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
let { server, source, initial }: { server?: string; source?: ActivitySource; initial?: ActivityPage } = $props();
const id = $props.id();
const headingId = `${id}-activity-heading`;
let data = $state<ActivityPage | undefined>(untrack(() => initial));
let loading = $state(!untrack(() => initial));
let error = $state('');
let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let results = $state<HTMLOListElement>();
let search = $state(untrack(() => activityQuery($page.url, { server, source }).q ?? ''));
let pendingUrl: URL | undefined;
let navigationId = 0;
const filters = $derived(activityQuery($page.url, { server, source }));
const apiUrl = $derived(activityApiHref($page.url, { server, source }));
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
const serverOptions = $derived.by(() => {
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
if (filters.server) names.add(filters.server);
return [...names].sort((a, b) => serverTitle(a).localeCompare(serverTitle(b)));
});
const hostOptions = $derived.by(() => {
const hosts = new Map<string, string>();
for (const item of $fleet.servers) if (item.host) hosts.set(item.host.id, item.host.name);
for (const row of data?.rows ?? []) if (row.hostId) hosts.set(row.hostId, row.hostName ?? 'Historical host');
if (filters.host && !hosts.has(filters.host)) hosts.set(filters.host, 'Selected host');
return [...hosts].sort((a, b) => a[1].localeCompare(b[1]));
});
$effect(() => { search = filters.q ?? ''; });
$effect(() => {
const report = initial;
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
});
$effect(() => {
const url = apiUrl;
retry;
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
return pollVisible(async signal => {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok) throw new Error('Could not load activity.');
const body: ActivityPage = await response.json();
if (signal.aborted || apiUrl !== url) return;
// Keep a focused operation link in place when newer rows arrive.
if (loadedUrl !== url || !results?.contains(document.activeElement)) { data = body; checkedAt = Date.now(); }
loadedUrl = url; loading = false; error = '';
} catch (cause) {
if (signal.aborted || apiUrl !== url) return;
error = cause instanceof Error ? cause.message : 'Could not load activity.';
loading = false;
return false;
}
}, 10_000, untrack(() => loadedUrl !== url || retry > 0));
});
function update(changes: Partial<Record<keyof ActivityQuery, string>>) {
const href = activityHistoryHref(pendingUrl ?? $page.url, changes);
pendingUrl = new URL(href, $page.url);
const id = ++navigationId;
void goto(href, { noScroll: true, keepFocus: true })
.catch(() => { error = 'Could not update activity filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
}
function reset() { update({ type: '', state: '', source: '', server: '', host: '', days: '', q: '' }); }
function submitSearch() { update({ q: search.trim() }); }
</script>
<section class="activity-log" aria-labelledby={headingId}>
<h2 class="sr-only" id={headingId}>Activity history</h2>
{#snippet filterFields(suffix: string)}
<div class="filters">
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each Object.entries(activityTypes) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each Object.entries(activitySources) as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
<form class="search" role="search" onsubmit={event => { event.preventDefault(); submitSearch(); }}><label for={`activity-search${suffix}`}>Search activity<input id={`activity-search${suffix}`} type="search" bind:value={search} maxlength="200" placeholder="Server, host, or activity…" /></label><Button type="submit" size="sm">Search</Button></form>
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters"><span>{data?.total ?? '—'} {data?.total === 1 ? 'activity' : 'activities'}{source ? ` · ${activitySources[source]}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Activity filters" description="Choose the activity to show." drawer>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => { if (search.trim() !== (filters.q ?? '')) submitSearch(); filtersOpen = false; }}>Show results</Button>{/snippet}
</Dialog>
<div class="context"><span>Newest first · Times in {$timeZone}</span>{#if filterCount}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert"><span>{error}{loadedUrl === apiUrl && data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
{#if loading}<EmptyState title="Loading activity…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Activity unavailable" description="Retry to load this selection." />
{:else if data?.rows.length}
<ol class="activity-list" bind:this={results} aria-label="Newest activity first">
{#each data.rows as row (row.id)}
<li class="activity-row">
<div class="event"><h3><a href={contextHref(row.href, $page.url)} aria-label={`${row.title}${row.server ? ` · ${serverTitle(row.server)}` : row.hostName ? ` · ${row.hostName}` : ''} · ${$timeFormat.period(row.at)}`}>{row.title}</a></h3>{#if row.detail}<p>{row.detail}</p>{/if}
<div class="resources">{#if row.server && server === undefined}<HistoricalServerIdentity name={row.server} deletedAt={row.serverDeletedAt} href={contextHref(`/servers/${encodeURIComponent(row.server)}`, $page.url)} />{/if}
{#if row.hostName}<span class="host-name">{row.hostName}</span>{/if}
</div>
</div>
<div class="outcome"><StatusBadge state={row.state === 'succeeded' ? 'done' : row.state} /><time datetime={row.at} title={$timeFormat.dateTime(row.at)} aria-label={$timeFormat.dateTime(row.at)}>{activityTimeLabel(row.at, $timeZone, $uiNow)}</time>
<span class="sources">{(row.sources.length ? row.sources : [row.source]).map(item => activitySources[item]).join(' · ')}</span>
</div>
</li>
{/each}
</ol>
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if}
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
<div class="pagination"><span>{data.rows.length ? `${data.offset + 1}–${data.offset + data.rows.length} of ${data.total}` : 'No activities on this page'}</span>
<label>Per page<select aria-label="Activities per page" value={data.limit} onchange={event => update({ limit: event.currentTarget.value })}>{#each [...new Set([25, 50, 100, data.limit])].sort((a, b) => a - b) as size}<option value={size}>{size}</option>{/each}</select></label>
{#if data.total > data.limit || data.offset > 0}<nav aria-label="Activity pages"><Button size="sm" disabled={data.offset === 0} href={activityHistoryHref($page.url, { offset: String(Math.max(0, data.offset - data.limit)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.limit >= data.total} href={activityHistoryHref($page.url, { offset: String(data.offset + data.limit) })}>Next</Button></nav>{/if}
</div>
{/if}
</section>
<style>
.activity-log { display: grid; gap: var(--space-4); min-width: 0; }
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: var(--space-3); }
label { display: grid; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); min-width: 0; }
select, input { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
.search { grid-column: span 2; display: flex; align-items: end; gap: var(--space-2); min-width: 0; }
.search label { flex: 1; }
.context, .pagination { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-xs); color: var(--text-secondary); }
.context > :last-child { margin-left: auto; }
.text-button { padding: 4px 0; border: 0; background: none; color: var(--accent-primary-hover); cursor: pointer; font: inherit; }
.activity-list { padding: 0; margin: 0; list-style: none; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.activity-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, auto); gap: var(--space-3); padding: var(--space-4); }
.activity-row + .activity-row { border-top: 1px solid var(--border-subtle); }
.activity-row:focus-within { background: var(--surface-raised); }
h3, p { margin: 0; }
h3 { font-size: var(--text-sm); font-weight: var(--weight-medium); }
a { color: var(--accent-primary-hover); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.event { min-width: 0; overflow-wrap: anywhere; }
.event p { margin-top: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.resources { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-1) var(--space-3); margin-top: var(--space-2); font-size: var(--text-xs); }
.host-name, .sources, time { color: var(--text-secondary); font-size: var(--text-xs); }
.outcome { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2); text-align: right; }
time { font-variant-numeric: tabular-nums; }
.sources { max-width: 32ch; overflow-wrap: anywhere; }
.pagination { justify-content: space-between; }
.pagination label { display: flex; align-items: center; gap: var(--space-2); }
.pagination select { width: auto; min-height: 36px; }
.pagination nav { display: flex; gap: var(--space-2); }
.error { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
.mobile-filters { display: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 640px) {
.desktop-filters { display: none; }
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
.filters { grid-template-columns: minmax(0, 1fr); }
.search { grid-column: auto; }
.context { gap: var(--space-1) var(--space-2); }
.context > :last-child { margin-left: 0; }
.activity-row { grid-template-columns: minmax(0, 1fr); padding: var(--space-3); gap: var(--space-2); }
.outcome { flex-direction: row; flex-wrap: wrap; align-items: center; text-align: left; }
.sources { max-width: none; }
.pagination { gap: var(--space-3); }
.pagination nav { margin-left: auto; }
}
</style>
-30
View File
@@ -1,30 +0,0 @@
import type { FleetOperation } from '$lib/fleet-overview.js';
export interface InstanceCampaign {
id: string;
action: string;
started_at: string;
servers: Array<{ server: string; restart_state: string; error: string | null; files_changed: number }>;
}
/** Merge overlapping campaign snapshots without hiding distinct provisioning records. */
export function instanceActivity(name: string, operations: FleetOperation[], campaigns: InstanceCampaign[]) {
const campaignLinks = new Set(campaigns.map(item => `/campaigns/${encodeURIComponent(item.id)}`));
return [
...operations.filter(op => op.server === name && !(op.kind === 'campaign' && campaignLinks.has(op.href))).map(op => ({
key: op.key, at: op.startedAt,
label: op.activityLabel ?? (op.label === 'Complete' ? op.detail : op.label),
state: op.resultState ?? op.state,
source: op.kind === 'deployment' ? 'Provisioning' : op.kind.charAt(0).toUpperCase() + op.kind.slice(1),
href: op.href, detail: op.state === 'attention' ? op.detail : null
})),
...campaigns.flatMap(campaign => {
const result = campaign.servers.find(item => item.server === name);
if (!result) return [];
const action = campaign.action === 'promote' ? 'Promote files' : campaign.action === 'rollback' ? 'Roll back files' : 'Reconcile files';
return [{ key: `history-${campaign.id}`, at: campaign.started_at,
label: `${action} · ${result.files_changed} ${result.files_changed === 1 ? 'file' : 'files'}`,
state: result.restart_state, source: 'Campaign', href: `/campaigns/${encodeURIComponent(campaign.id)}`, detail: result.error }];
})
].sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || a.key.localeCompare(b.key));
}
+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>
+216
View File
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { parseActivityQuery } from '../../src/lib/activity.js';
import type { ActivityQuery } from '../../src/lib/activity.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
const server = 't5_zombie_theater_1';
const at = '2026-10-07T12:00:00.000Z';
async function setup() {
const { db } = await import('../../src/lib/server/db/index.js');
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const { activityHistory } = await import('../../src/lib/server/activity.js');
const host = createHost({ name: 'Dallas host' });
const other = createHost({ name: 'Denver host' });
const handle = db();
const insert = handle.prepare(`INSERT INTO server_deployments
(id,server,action,state,stage,started_at,triggered_by,server_deleted_at,observation_json,error)
VALUES(?,?,?,?,?,?,?,?,?,?)`);
function operation(id: string, options: { state?: string; action?: string; server?: string; source?: string; at?: string; deleted?: string } = {}) {
insert.run(id, options.server ?? server, options.action ?? 'restart', options.state ?? 'done', 'Private stage token=secret-stage',
options.at ?? at, options.source ?? 'ui', options.deleted ?? null, '{"token":"secret-observation"}', 'secret-error');
}
function remote(id: string, options: { kind?: string; state?: string; key?: string; routine?: number; success?: number; expired?: string; host?: string; instance?: string } = {}) {
handle.prepare(`INSERT INTO ec_host_operations (id,host_id,kind,instance_id,idempotency_key,payload,result,state,created_at,updated_at,
routine,observation_success,server_name,history_expired_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
.run(id, options.host ?? host.id, options.kind ?? 'instance.restart', options.instance ?? null, options.key ?? null,
'{"token":"secret-payload"}', '{"token":"secret-result"}', options.state ?? 'completed', at, at,
options.routine ?? 0, options.success ?? 0, server, options.expired ?? null);
}
function link(operationId: string, kind: string, id: string) {
handle.prepare('INSERT INTO provisioning_sources(operation_id,kind,source_id,server) VALUES(?,?,?,?)').run(operationId, kind, id, server);
}
function maintenance(id: number, state = 'done', operationId: string | null = null) {
handle.prepare(`INSERT INTO maintenance_jobs(id,server,state,target_revision,requested_at,kind,origin,provisioning_id,detail)
VALUES(?,?,?,123,?,'update','manual',?,'secret-maintenance-detail')`).run(id, server, state, at, operationId);
}
function incident(id: string, kind = 'health', state = 'resolved') {
handle.prepare(`INSERT INTO watchdog_incidents(id,server,kind,state,summary,details,first_seen_at,last_seen_at)
VALUES(?,?,?,?,'secret-summary','{"credential":"secret-details"}',?,?)`).run(id, server, kind, state, at, at);
}
function instance(id: string, hostId = host.id) {
handle.prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
VALUES(?,?,?,'library','image',28960,?)`).run(id, server, hostId, at);
}
return { handle, activityHistory, operation, remote, link, maintenance, incident, instance, host, other };
}
describe('Activity query parameters', () => {
it('keeps exact identities and valid filters while bounding pagination and ignoring invalid enum values', () => {
expect(parseActivityQuery(new URLSearchParams('server=t5_zombie_theater_1&host=a&type=restart&source=maintenance&state=failed&days=7&q=Kino&limit=999&offset=20')))
.toMatchObject({ server, host: 'a', type: 'restart', source: 'maintenance', state: 'failed', days: 7, q: 'Kino', limit: 100, offset: 20 });
expect(parseActivityQuery(new URLSearchParams('type=sql&state=bad&source=bad&days=NaN&limit=-5&offset=Infinity')))
.toEqual({ type: undefined, state: undefined, source: undefined, limit: 25, offset: 0 });
});
});
describe('unified retained Activity', () => {
it('paginates the full union with exact totals, stable ties and removed-server history', async () => {
const api = await setup();
api.handle.transaction(() => {
for (let i = 0; i < 130; i++) api.operation(`life-${String(i).padStart(3, '0')}`);
for (let i = 0; i < 33; i++) api.remote(`remote-${i}`);
api.operation('removed', { deleted: at, at: '2025-01-01T00:00:00.000Z' });
api.incident('finding'); api.maintenance(1);
})();
const all = [];
for (let offset = 0; offset < 166; offset += 25) {
const page = api.activityHistory({ offset });
expect(page.total).toBe(166);
expect(page.rows.length).toBeLessThanOrEqual(25);
all.push(...page.rows);
}
expect(new Set(all.map(row => row.id)).size).toBe(166);
expect(all.at(-1)).toMatchObject({ id: 'provisioning:removed', serverDeletedAt: at });
expect(api.activityHistory({ offset: 99_999 }).offset).toBe(150);
});
it('filters before pagination by exact server, host, state, type, days and friendly search', async () => {
const api = await setup();
for (let i = 0; i < 40; i++) api.operation(`other-${i}`, { server: 't5_zombie_moon_1' });
api.operation('match', { state: 'failed' });
api.operation('old', { state: 'failed', at: '2025-01-01T00:00:00.000Z' });
api.instance('instance');
api.handle.prepare('INSERT INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,1)').run('match', 'instance');
const filters: ActivityQuery = { server, host: api.host.id, state: 'failed', type: 'restart', q: 'Kino', days: 7, limit: 1 };
const page = api.activityHistory(filters, Date.parse(at));
expect(page.total).toBe(1);
expect(page.rows[0]).toMatchObject({ id: 'provisioning:match', hostId: api.host.id, hostName: 'Dallas host' });
expect(api.activityHistory({ ...filters, server: 'Kino' }, Date.parse(at)).total).toBe(0);
expect(api.activityHistory({ ...filters, host: api.other.id }, Date.parse(at)).total).toBe(0);
expect(api.activityHistory({ q: "' OR 1=1 --" }).total).toBe(0);
expect(api.activityHistory({ q: 'Dallas' }).total).toBe(1);
});
it('compares instants across SQLite and ISO timestamp formats for date ranges and ordering', async () => {
const api = await setup();
api.operation('iso-noon', { at: '2026-10-07T12:00:00.000Z' });
api.operation('sql-afternoon', { at: '2026-10-07 13:00:00' });
api.operation('sql-yesterday', { at: '2026-10-06 14:00:00' });
api.operation('too-old', { at: '2026-10-06T11:00:00.000Z' });
const page = api.activityHistory({ days: 1 }, Date.parse(at));
expect(page.rows.map(row => row.id)).toEqual(['provisioning:sql-afternoon', 'provisioning:iso-noon', 'provisioning:sql-yesterday']);
expect(page.total).toBe(3);
});
it('merges maintenance verification into its lifecycle row and retains every trigger membership', async () => {
const api = await setup();
api.operation('shared');
api.maintenance(1, 'failed', 'shared');
api.link('shared', 'maintenance', '1');
api.link('shared', 'campaign', 'expired-campaign');
api.link('shared', 'beta', '99');
const row = api.activityHistory().rows[0];
expect(api.activityHistory().total).toBe(1);
expect(row).toMatchObject({ id: 'provisioning:shared', type: 'update', state: 'failed', source: 'maintenance',
sources: ['maintenance', 'campaign', 'beta'], href: '/provisioning/shared', detail: 'The restart completed, but maintenance verification failed.' });
for (const source of ['maintenance', 'campaign', 'beta'] as const) expect(api.activityHistory({ source }).total).toBe(1);
api.handle.prepare("UPDATE maintenance_jobs SET state='restarting' WHERE id=1").run();
expect(api.activityHistory().rows[0]).toMatchObject({ state: 'running', rawState: 'verifying' });
api.handle.prepare("UPDATE maintenance_jobs SET state='done' WHERE id=1").run();
expect(api.activityHistory().rows[0].state).toBe('succeeded');
});
it('includes recovery in the maintenance umbrella and keeps findings distinct from recovery actions', async () => {
const api = await setup();
api.operation('recovery', { source: 'watchdog:finding', action: 'rebuild' });
api.incident('finding', 'drift');
api.maintenance(1);
expect(api.activityHistory({ source: 'maintenance' }).total).toBe(3);
const recovery = api.activityHistory({ source: 'recovery' });
expect(recovery.total).toBe(2);
expect(recovery.rows.map(row => row.type).sort()).toEqual(['drift', 'rebuild']);
expect(api.activityHistory({ source: 'provisioning' }).total).toBe(0);
});
it('merges legacy source links without applying a retried verification result to the earlier operation', async () => {
const api = await setup();
api.operation('old', { source: 'maintenance:1', at: '2026-10-06T12:00:00.000Z' });
api.operation('new', { source: 'maintenance:1' });
api.maintenance(1, 'failed');
const rows = api.activityHistory().rows;
expect(rows).toHaveLength(2);
expect(rows.find(row => row.id === 'provisioning:old')?.state).toBe('succeeded');
expect(rows.find(row => row.id === 'provisioning:new')?.state).toBe('failed');
});
it('deduplicates current command pointers and historical idempotency attempts without dropping independent work', async () => {
const api = await setup();
api.operation('operation'); api.instance('instance');
api.remote('pointed'); api.remote('staged'); api.remote('migration');
api.handle.prepare(`INSERT INTO native_provisioning(deployment_id,instance_id,generation,operation_id,stage_operation_id)
VALUES('operation','instance',1,'pointed','staged')`).run();
api.handle.prepare(`INSERT INTO ec_host_migrations(deployment_id,source_host_id,instance_id,remove_operation_id)
VALUES('operation',?,'instance','migration')`).run(api.host.id);
for (const key of ['operation', 'operation:stage', 'operation:migration-create:1', 'operation:migrate-remove:2']) api.remote(`linked-${key}`, { key });
api.remote('independent');
api.remote('not-a-retry', { key: 'operation:migration-create:not-a-number' });
api.remote('source-retained-away', { key: 'expired-operation' });
expect(api.activityHistory().rows.map(row => row.id).sort()).toEqual([
'host:independent', 'host:not-a-retry', 'host:source-retained-away', 'provisioning:operation'
]);
});
it('keeps failed host observations, hides successful routine polls and expired records, and never projects private data', async () => {
const api = await setup();
api.operation('operation'); api.incident('finding'); api.maintenance(1);
api.remote('routine', { kind: 'instance.inspect', routine: 1, success: 1 });
api.remote('bad-check', { kind: 'instance.inspect', routine: 1 });
api.remote('failed-check', { kind: 'instance.inspect', routine: 1, state: 'failed' });
api.remote('expired', { expired: at });
const result = api.activityHistory();
expect(result.rows.filter(row => row.source === 'host').map(row => row.id).sort()).toEqual(['host:bad-check', 'host:failed-check']);
expect(result.rows.find(row => row.id === 'host:bad-check')).toMatchObject({ state: 'failed', rawState: 'observation-failed', href: '/hosts/operations?operation=bad-check&filter=all' });
expect(JSON.stringify(result)).not.toContain('secret-');
expect(JSON.stringify(result)).not.toMatch(/payload|observation_json|result|credential/);
});
it('retains file campaigns and beta tests as distinct work with friendly titles', async () => {
const api = await setup();
api.handle.exec(`INSERT INTO scripts(id,name,target_rel_path) VALUES(1,'Script','script.gsc');
INSERT INTO script_versions(id,script_id,version,sha256,size) VALUES(1,1,1,'hash',0);
INSERT INTO betas(id,script_id,script_version_id,trigger_kind,state,opened_at) VALUES(1,1,1,'ui','promoted','${at}');
INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(1,'${server}',0,'ready');
INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote','${at}');
INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state,server_deleted_at) VALUES('campaign','${server}',1,'done','${at}');`);
const rows = api.activityHistory().rows;
expect(rows).toEqual(expect.arrayContaining([
expect.objectContaining({ source: 'campaign', title: 'Promote files', detail: '1 file changed.', state: 'succeeded', serverDeletedAt: at, href: '/campaigns/campaign' }),
expect.objectContaining({ source: 'beta', title: 'Beta test #1', state: 'succeeded', href: '/betas/1' })
]));
api.handle.prepare("UPDATE betas SET state='running' WHERE id=1").run();
for (const stage of ['preparing', 'staging', 'testing', 'observing']) {
api.handle.prepare('UPDATE beta_servers SET stage=? WHERE beta_id=1').run(stage);
expect(api.activityHistory({ source: 'beta' }).rows[0].state).toBe('running');
}
api.handle.prepare("UPDATE deployments SET triggered_by='watchdog' WHERE id='campaign'").run();
expect(api.activityHistory({ source: 'maintenance' }).rows[0]).toMatchObject({ sources: ['recovery', 'campaign'] });
expect(api.activityHistory({ source: 'recovery' }).total).toBe(1);
expect(api.activityHistory({ source: 'campaign' }).total).toBe(1);
});
it('uses the same query contract for API and page loaders with no-store API responses', async () => {
const api = await setup();
api.operation('a'); api.operation('b', { state: 'failed' });
const url = new URL('http://localhost/activity?state=failed&limit=1');
const { GET } = await import('../../src/routes/api/activity/+server.js');
const { load } = await import('../../src/routes/activity/+page.server.js');
const response = GET({ url });
expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.json()).toEqual(JSON.parse(JSON.stringify(load({ url }).activity)));
expect(load({ url }).activity.rows[0].id).toBe('provisioning:b');
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ describe('command palette destinations', () => {
it('keeps administrator destinations and operations out of inappropriate roles', () => {
const viewer = palettePageResults('viewer', '');
expect(viewer.some(item => item.group === 'Actions' || item.href.startsWith('/settings') || item.href === '/server-keys')).toBe(false);
expect(palettePageResults('operator', '').filter(item => item.group === 'Actions').map(item => item.href)).toEqual(['/?action=create']);
expect(palettePageResults('operator', '').filter(item => item.group === 'Actions').map(item => item.href)).toEqual(['/?action=create', '/provisioning']);
expect(palettePageResults('admin', 'enroll')).toContainEqual(expect.objectContaining({ href: '/hosts?action=add' }));
expect(palettePageResults('admin', 'campaigns')).toContainEqual(expect.objectContaining({ label: 'Campaigns', href: '/campaigns' }));
});
+35
View File
@@ -102,6 +102,27 @@ describe('host operation history', () => {
expect(api.listHostOperationHistory(1, 'important', { host: api.second.id }).operations.map(operation => operation.id)).toEqual([other.id]);
});
it('finds an exact old operation without walking pages and keeps all scope and retention filters', async () => {
const api = await seedHistory(1003);
const operation = api.ids[0];
api.handle.prepare("UPDATE ec_host_operations SET server_name='server-one' WHERE id=?").run(operation);
const prepare = vi.spyOn(api.handle, 'prepare');
const history = api.listHostOperationHistory(999, 'all', { operation, host: api.first.id, server: 'server-one' });
const exactCount = prepare.mock.calls.map(([sql]) => sql).find(sql => sql.startsWith('SELECT 1 AS total'))!;
prepare.mockRestore();
expect(history).toMatchObject({ page: 1, total: 1, totalPages: 1 });
expect(history.operations.map(row => row.id)).toEqual([operation]);
expect(JSON.stringify(history)).not.toContain('secret');
const plan = api.handle.prepare(`EXPLAIN QUERY PLAN ${exactCount}`).all(api.first.id, 'server-one', operation) as { detail: string }[];
expect(plan.some(row => row.detail.includes('USING INDEX sqlite_autoindex_ec_host_operations_1'))).toBe(true);
expect(api.listHostOperationHistory(1, 'all', { operation, host: api.second.id }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'all', { operation, server: 'another-server' }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'routine', { operation }).total).toBe(0);
expect(api.listHostOperationHistory(1, 'all', { operation: 'missing-operation' }).total).toBe(0);
api.handle.prepare('UPDATE ec_host_operations SET history_expired_at=? WHERE id=?').run(new Date().toISOString(), operation);
expect(api.listHostOperationHistory(1, 'all', { operation })).toMatchObject({ operations: [], page: 1, total: 0 });
});
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, 'all'));
@@ -309,6 +330,17 @@ describe('host operation history', () => {
expect(await request('?filter=all')).toEqual(api.listHostOperationHistory(1, 'all'));
});
it('serves an exact old operation through the API without exposing command data', async () => {
const api = await seedHistory();
const operation = api.ids[0];
const { GET } = await import('../../src/routes/api/hosts/operations/+server.js');
const response = GET({ request: new Request(`https://ec.test/api/hosts/operations?filter=all&operation=${operation}`) });
const body = await response.json();
expect(body).toMatchObject({ page: 1, total: 1, totalPages: 1 });
expect(body.operations.map((row: { id: string }) => row.id)).toEqual([operation]);
expect(JSON.stringify(body)).not.toContain('secret');
});
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';
@@ -318,6 +350,9 @@ describe('host operation history', () => {
for (const filter of ['', 'unknown', 'ALL', 'important%27%20OR%201=1']) {
expect(() => GET({ request: new Request(`${url}?filter=${filter}`) })).toThrow(expect.objectContaining({ status: 400 }));
}
for (const operation of ['', 'x'.repeat(513), 'operation%00bad', 'operation%0Abad']) {
expect(() => GET({ request: new Request(`${url}?operation=${operation}`) })).toThrow(expect.objectContaining({ status: 400 }));
}
const blockedHeaders: HeadersInit[] = [
{ origin: 'https://other.test' },
{ 'sec-fetch-site': 'cross-site' },
+14
View File
@@ -30,6 +30,20 @@ describe('Maintenance API', () => {
}
expect(await GET().json()).toEqual(before);
});
it('opens one retained request outside the recent history window without changing normal history', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const insert = db().prepare("INSERT INTO maintenance_jobs(server,state,requested_at,kind,origin) VALUES(?,'done',?,'schedule','schedule')");
db().transaction(() => { for (let i = 0; i < 105; i++) insert.run('t5_zombie_theater_1', new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString()); })();
const { GET } = await import('../../src/routes/api/maintenance/+server.js');
const ordinary = await GET().json();
expect(ordinary.runtime.jobs).toHaveLength(100);
expect(ordinary.runtime.jobs.some((job: { id: number }) => job.id === 1)).toBe(false);
const selected = await GET({ url: new URL('http://localhost/api/maintenance?job=1') }).json();
expect(selected.runtime.jobs).toHaveLength(101);
expect(selected.runtime.jobs.find((job: { id: number }) => job.id === 1)).toMatchObject({ state: 'done', kind: 'schedule' });
for (const job of ['0', '-1', '', '1.5', 'bad', '99999999999999999999']) expect(GET({ url: new URL(`http://localhost/api/maintenance?job=${job}`) }).status).toBe(400);
expect(await GET().json()).toEqual(ordinary);
});
it('keeps old Watchdog URLs working through query-preserving redirects', async () => {
const index = await import('../../src/routes/watchdog/+page.server.js');
const detail = await import('../../src/routes/watchdog/[id]/+page.server.js');
+7
View File
@@ -10,6 +10,13 @@ describe('navigation context', () => {
expect(returnLabel('/collections?view=history&launches=30')).toBe('Back to launch history');
});
it('retains Activity filters and a selected result when opening a detail', () => {
const source = '/activity?source=maintenance&state=failed&offset=25#operation-12';
const target = new URL(contextHref('/provisioning/12', source), 'http://localhost');
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to Activity');
});
it('preserves the selected diagnostic tab on both sides of a contextual link', () => {
const url = new URL(contextHref('/servers/t5_zombie_theater_1?tab=logs&log=console', '/maintenance/incident?returnTo=%2Fmaintenance%3Fview%3Dactivity'), 'http://localhost');
expect(url.searchParams.get('tab')).toBe('logs');
+5 -4
View File
@@ -6,8 +6,9 @@ describe('application navigation', () => {
['/servers/t5_zombie_theater_1', 'fleet', '/'],
['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'activity', '/hosts/operations'],
['/provisioning', 'activity', '/provisioning'],
['/hosts/operations', 'activity', '/activity'],
['/provisioning', 'activity', '/activity'],
['/activity', 'activity', '/activity'],
['/betas/new', 'releases', '/betas'],
['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'releases', '/configs'],
@@ -20,9 +21,9 @@ describe('application navigation', () => {
});
it('keeps Operations reachable from Activity and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/provisioning');
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/activity');
const { load } = await import('../../src/routes/operations/+page.server.js');
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/provisioning' }));
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/activity' }));
});
it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
+92
View File
@@ -0,0 +1,92 @@
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable, type Writable } from 'svelte/store';
import { page } from '$app/stores';
import ActivityLog from '../../src/lib/ui/components/ActivityLog.svelte';
import ActivityPage from '../../src/routes/activity/+page.svelte';
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '../../src/lib/ui/activity-history.js';
import type { ActivityPage as ActivityReport, ActivityRow } from '../../src/lib/activity.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/activity') }) }));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$ui/stores/fleet.js', () => ({ fleet: writable({ servers: [], operations: [], receivedAt: 0 }) }));
vi.mock('$ui/stores/servers.js', () => ({ uiNow: writable(Date.parse('2026-10-07T18:00:00Z')) }));
const row: ActivityRow = { id: 'operation-1', at: '2026-10-07T17:59:45Z', type: 'restart', source: 'maintenance', sources: ['maintenance', 'campaign'], state: 'succeeded', rawState: 'done', title: 'Restart server', detail: 'Verified game readiness.', server: 't6_zombie_town_1', serverDeletedAt: null, hostId: 'host-a', hostName: 'Dallas 1', href: '/provisioning/operation-1' };
const report = (): ActivityReport => ({ rows: [{ ...row }], total: 52, limit: 25, offset: 0, query: { limit: 25, offset: 0 } });
function scope(path = '/activity') { (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost${path}`) }); }
beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-07T18:00:00Z')); });
afterEach(() => vi.useRealTimers());
it('sends only supported normalized filters and enforces scoped identities', () => {
const url = new URL('https://example.test/servers/town?tab=history&server=wrong&source=host&state=failed&offset=50&returnTo=%2F');
const href = activityApiHref(url, { server: 't6_zombie_town_1', source: 'maintenance' });
const params = new URL(href, url).searchParams;
expect(params.get('server')).toBe('t6_zombie_town_1'); expect(params.get('source')).toBe('maintenance');
expect(params.get('state')).toBe('failed'); expect(params.get('offset')).toBe('50'); expect(params.get('limit')).toBe('25');
expect(params.has('tab')).toBe(false); expect(params.has('returnTo')).toBe(false);
expect(activityQuery(new URL('https://example.test/activity?type=unknown&days=0')).type).toBeUndefined();
});
it('resets pagination when filtering and preserves the containing page, hash, and return context', () => {
const current = new URL('https://example.test/servers/town?tab=history&offset=50&source=campaign&returnTo=%2F%3Ffilter%3Dattention#history');
const filtered = new URL(activityHistoryHref(current, { state: 'failed', q: 'Dallas lobby' }), current);
expect(filtered.searchParams.get('offset')).toBeNull(); expect(filtered.searchParams.get('tab')).toBe('history');
expect(filtered.searchParams.get('source')).toBe('campaign'); expect(filtered.searchParams.get('returnTo')).toBe('/?filter=attention');
expect(filtered.hash).toBe('#history'); expect(filtered.searchParams.get('q')).toBe('Dallas lobby');
expect(new URL(activityHistoryHref(filtered, { offset: '25' }), current).searchParams.get('state')).toBe('failed');
});
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
const now = Date.parse('2026-10-07T18:00:00Z');
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
expect(activityTimeLabel('2026-10-07T17:50:00Z', 'America/New_York', now)).toBe('10 min ago');
expect(activityTimeLabel('2026-10-07T16:00:00Z', 'America/New_York', now)).toBe('2 h ago');
expect(activityTimeLabel('2026-10-06T17:00:00Z', 'America/New_York', now)).toBe('Yesterday, 1:00 PM');
expect(activityTimeLabel('not a date', 'UTC', now)).toBe('Time unavailable');
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2:00 PM');
});
it('renders one page heading and keeps management tools available only to operators', () => {
const body = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'admin' }, setupRequired: false } as any } }).body;
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).toContain('Manage servers'); expect(body).toContain('href="/provisioning"');
const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body;
expect(viewer).not.toContain('Manage servers');
});
it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => {
scope('/activity?state=succeeded&offset=25');
const data = report(); data.offset = 25;
const body = render(ActivityLog, { props: { initial: data } }).body;
expect(body).toContain('Town Survival'); expect(body).toContain('Dallas 1'); expect(body).toContain('Restart server · Town Survival · Lobby 1');
expect(body).toContain('Maintenance &amp; recovery · Campaigns'); expect(body).toContain('Completed'); expect(body).toContain('shape-circle');
expect(body).toContain('15 s ago'); expect(body).toContain('datetime="2026-10-07T17:59:45Z"');
expect(body).toContain('returnTo=%2Factivity'); expect(body).toContain('state%3Dsucceeded'); expect(body).toContain('offset%3D25');
expect(body).toContain('26–26 of 52'); expect(body).toContain('offset=50');
expect(body.match(/Updates every 10 seconds/g)).toHaveLength(1); expect(body).not.toContain('aria-live');
});
it('preserves deleted server evidence without linking to a replacement instance', () => {
const data = report(); data.rows = [{ ...row, serverDeletedAt: '2026-10-07T17:59:46Z' }];
const body = render(ActivityLog, { props: { initial: data } }).body;
expect(body).toContain('Town Survival'); expect(body).toContain('Deleted'); expect(body).not.toContain('href="/servers/');
expect(body).toContain('/provisioning/operation-1?returnTo=');
});
it('offers one mobile filter sheet with distinct IDs and hides fixed scope controls', () => {
const body = render(ActivityLog, { props: { initial: report() } }).body;
expect(body).toContain('Activity filters'); expect(body).toContain('id="activity-host-mobile"'); expect(body).toContain('id="activity-search-mobile"');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); expect(new Set(ids).size).toBe(ids.length);
scope('/maintenance?view=activity&state=failed');
const fixed = render(ActivityLog, { props: { initial: report(), server: row.server!, source: 'maintenance' } }).body;
expect(fixed).not.toContain('id="activity-server'); expect(fixed).not.toContain('id="activity-source');
expect(fixed).toContain('view=activity'); expect(fixed).toContain('state=failed'); expect(fixed).toContain('Filters · 1');
});
it('offers useful empty and out-of-range page recovery', () => {
scope('/activity?q=missing&offset=100');
const data = report(); data.rows = []; data.offset = 100;
const body = render(ActivityLog, { props: { initial: data } }).body;
expect(body).toContain('No matching activity'); expect(body).toContain('Clear filters'); expect(body).toContain('No activities on this page');
expect(body).toContain('offset=75'); expect(body).toContain('Previous');
});
+1 -1
View File
@@ -19,7 +19,7 @@ describe('sidebar navigation', () => {
expect(body).toContain('Main navigation');
expect(body).toMatch(/<a[^>]*href="\/campaigns"[^>]*aria-current="page"/);
expect(body).toContain('href="/betas"');
expect(body).toContain('href="/hosts/operations"');
expect(body).toContain('href="/activity"');
expect(body).toContain('href="/metrics"');
expect(body).not.toContain('href="/settings"');
expect(body).not.toContain('href="/server-keys"');
+9 -14
View File
@@ -4,12 +4,10 @@ import { writable } from 'svelte/store';
import { get } from 'svelte/store';
import type { ComponentProps } from 'svelte';
import type { GameTelemetry } from '../../src/lib/telemetry.js';
import type { FleetOperation } from '../../src/lib/fleet-overview.js';
import ServerPage from '../../src/routes/servers/[name]/+page.svelte';
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
import ConfigInspector from '../../src/lib/ui/components/ConfigInspector.svelte';
import { uiNow } from '../../src/lib/ui/stores/servers.js';
import { instanceActivity } from '../../src/lib/ui/server-activity.js';
import { gameEvent } from '../helpers/telemetry.js';
import { page } from '$app/stores';
@@ -57,18 +55,8 @@ it('omits the fixed instance selector while keeping promoted/live distinction',
expect(body).toContain('Live files and active betas may differ');
});
const op = (changes: Partial<FleetOperation> = {}): FleetOperation => ({ key: 'deployment:1', group: 'deployment:1', server: 'one', kind: 'deployment', label: 'Complete', activityLabel: 'Restart instance', resultState: 'cancelled', detail: 'Checking game status', href: '/provisioning/1', state: 'done', startedAt: '2026-10-06T12:00:00Z', ...changes });
it('merges duplicate campaign records, retains provisioning and preserves the real outcome', () => {
const rows = instanceActivity('one', [op(), op({ key: 'campaign:x:one', kind: 'campaign', href: '/campaigns/x' }), op({ key: 'other', server: 'two' })], [{
id: 'x', action: 'promote', started_at: '2026-10-06T13:00:00Z', servers: [{ server: 'one', restart_state: 'failed', error: 'Restart failed', files_changed: 1 }]
}]);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ label: 'Promote files · 1 file', state: 'failed', detail: 'Restart failed' });
expect(rows[1]).toMatchObject({ label: 'Restart instance', state: 'cancelled', href: '/provisioning/1' });
});
function pageBody(tab: string) {
(page as unknown as ReturnType<typeof writable>).set({ params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) });
(page as unknown as ReturnType<typeof writable>).set({ data: { user: { role: 'admin' } }, params: { name: 't5_zombie_theater_1' }, url: new URL(`http://localhost/servers/t5_zombie_theater_1?tab=${tab}&returnTo=%2F%3Ffilter%3Doccupied`) });
const telemetry = report();
const server = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', health: 'online', last_players: 1,
last_seen_at: telemetry.receivedAt, last_error: null, held: [], telemetry, status_source: 'telemetry', host_id: 'host-1', native_instance_id: 'instance-1',
@@ -79,7 +67,7 @@ function pageBody(tab: string) {
}
it('keeps match content out of the shared header and preserves the fleet return filter', () => {
const body = pageBody('configuration');
expect(body).toContain('Instance actions');
expect(body).toContain('More actions');
expect(body).toContain('href="/?filter=occupied"');
expect(body).toContain('ec-1');
expect(body).not.toContain('Active for');
@@ -96,3 +84,10 @@ it('reports RCON failures independently of fresh telemetry and defers the reques
expect(body).not.toContain('Telemetry API requests');
expect(body).not.toContain('>Healthy<');
});
it('uses the shared Activity view for server history', () => {
const body = pageBody('history');
expect(body).toContain('Open in Activity');
expect(body).not.toContain('Loading campaign history');
expect(body).not.toContain('>Provisioning history</a>');
});