Unify UI workflows and streamline fleet management

This commit is contained in:
2026-09-18 14:00:28 -04:00
parent 9cf0bd87f4
commit f54cac8c50
63 changed files with 1706 additions and 1286 deletions
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
describe('server detail data', () => {
it('filters history before the global limit and includes the matching rollout members', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
handle.exec("INSERT INTO servers (name, komodo_name) VALUES ('target', 'target'), ('other', 'other')");
handle.transaction(() => {
const deployment = handle.prepare("INSERT INTO deployments (id, action, started_at) VALUES (?, 'promote', ?)");
const member = handle.prepare("INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state) VALUES (?, ?, 1, 'done')");
deployment.run('older-target', '2026-01-01');
member.run('older-target', 'target'); member.run('older-target', 'other');
for (let n = 0; n < 501; n++) { deployment.run('other-' + n, '2026-09-18'); member.run('other-' + n, 'other'); }
})();
const { GET } = await import('../../src/routes/api/campaigns/+server.js');
const all = await (await GET({ url: new URL('http://localhost/api/campaigns') })).json();
expect(all.deployments).toHaveLength(500);
const filtered = await (await GET({ url: new URL('http://localhost/api/campaigns?server=target') })).json();
expect(filtered.deployments.map((row: { id: string }) => row.id)).toEqual(['older-target']);
expect(filtered.deployments[0].servers).toHaveLength(2);
expect((await (await GET({ url: new URL('http://localhost/api/campaigns?server=unknown') })).json()).deployments).toEqual([]);
});
it('identifies plugins, scripts, composed configs and unknown inventory files', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
db().exec("INSERT INTO servers (name, komodo_name) VALUES ('target', 'target')");
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
const { version } = createScript({ name:'Plugin', kind:'plugin', targetRelPath:'native.bin', content:Buffer.from('plugin') });
recordScript('target','native.bin',version.id,'hash',6,'beta');
recordScript('target','dedicated_zm.cfg',null,'hash',10,'compose');
recordScript('target','main.gsc',null,'hash',10,'beta');
recordScript('target','unknown.dat',null,'hash',10,'manual');
const { GET } = await import('../../src/routes/api/servers/[name]/scripts/+server.js');
const body = await (await GET({ params: {name:'target'} })).json();
expect(Object.fromEntries(body.scripts.map((file: {relPath:string;kind:string}) => [file.relPath,file.kind]))).toEqual({
'dedicated_zm.cfg':'config','main.gsc':'script','native.bin':'plugin','unknown.dat':'file'
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { serverIdentity } from '../../src/lib/ui/server-labels.js';
import { statusPresentation } from '../../src/lib/ui/status.js';
describe('operator-facing identities and states', () => {
it('preserves lobby numbers and distinguishes T6 variants sharing an engine map', () => {
expect(serverIdentity('t6_zombie_town_12')).toEqual({ map:'Town Survival', lobby:12, game:'Black Ops II' });
expect(serverIdentity('t6_zombie_towngrief_2')).toEqual({ map:'Town Grief', lobby:2, game:'Black Ops II' });
expect(serverIdentity('custom-server')).toEqual({ map:'custom-server', lobby:null, game:'' });
});
it('keeps normal waiting neutral and distinguishes test success from promotion', () => {
expect(statusPresentation('awaiting-empty').tone).toBe('neutral');
expect(statusPresentation('failed').tone).toBe('danger');
expect(statusPresentation('tested').label).toBe('Test passed');
expect(statusPresentation('promoted').label).toBe('Promoted');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { pollVisible } from '../../src/lib/ui/poll.js';
let documentStub: EventTarget & { visibilityState: string };
let stop: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
documentStub = Object.assign(new EventTarget(), { visibilityState: 'visible' });
vi.stubGlobal('document', documentStub);
});
afterEach(() => { stop?.(); stop = undefined; vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); });
const visibility = (state: string) => { documentStub.visibilityState = state; documentStub.dispatchEvent(new Event('visibilitychange')); };
describe('visible page polling', () => {
it('waits for a request to finish before scheduling the next request', async () => {
let finish!: () => void;
const task = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
stop = pollVisible(task, 1000);
await vi.advanceTimersByTimeAsync(5000);
visibility('visible');
expect(task).toHaveBeenCalledTimes(1);
finish(); await vi.advanceTimersByTimeAsync(999);
expect(task).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(task).toHaveBeenCalledTimes(2);
});
it('pauses when hidden and refreshes on return, including hiding during a request', async () => {
let finish!: () => void;
const task = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
stop = pollVisible(task, 1000);
visibility('hidden'); finish();
await vi.advanceTimersByTimeAsync(5000);
expect(task).toHaveBeenCalledTimes(1);
visibility('visible');
expect(task).toHaveBeenCalledTimes(2);
finish(); stop();
visibility('visible'); await vi.advanceTimersByTimeAsync(5000);
expect(task).toHaveBeenCalledTimes(2);
});
it('recovers after a failed request and supports a delayed initial refresh', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const task = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
stop = pollVisible(task, 1000, false);
expect(task).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(task).toHaveBeenCalledTimes(2);
});
});