Unify retained operation history in Activity
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' }));
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 & 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');
|
||||
});
|
||||
@@ -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"');
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user