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
+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>');
});