Simplify host overview and move enrollment into drawer

This commit is contained in:
2026-10-06 17:18:31 -04:00
parent a991198d69
commit ef290ae0ee
11 changed files with 310 additions and 61 deletions
+11 -1
View File
@@ -31,6 +31,14 @@ describe('host detail instances', () => {
.run(host.id, now, now);
db().prepare("UPDATE servers SET container_state='exited' WHERE name='unavailable'").run();
const { hostOverview } = await import('../../src/lib/server/hosts/overview.js');
const overview = hostOverview();
expect(overview.find(row => row.id === host.id)?.serverCount).toBe(2);
expect(overview.find(row => row.id === other.id)?.serverCount).toBe(1);
const { GET: list } = await import('../../src/routes/api/hosts/+server.js');
const listed = await list({ request: new Request('https://ec.test/api/hosts') }).json();
expect(listed.serverNow).toEqual(expect.any(Number));
expect(listed.hosts.find((row: { id: string }) => row.id === host.id)).toMatchObject({ serverCount: 2 });
const { GET } = await import('../../src/routes/api/hosts/[id]/+server.js');
const read = async (id: string) => (await GET({ request: new Request(`https://ec.test/api/hosts/${id}`), params: { id } }).json());
const body = await read(host.id);
@@ -42,6 +50,8 @@ describe('host detail instances', () => {
db().prepare("UPDATE servers SET last_players=0 WHERE name='running'").run();
expect((await read(host.id)).instances[0].last_players).toBe(0);
expect((await read(other.id)).instances.map((instance: { name: string }) => instance.name)).toEqual(['other-host']);
expect((await read(createHost({ name: 'Empty host' }).id)).instances).toEqual([]);
const empty = createHost({ name: 'Empty host' });
expect((await read(empty.id)).instances).toEqual([]);
expect(hostOverview().find(row => row.id === empty.id)?.serverCount).toBe(0);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import type { Host } from '../../src/lib/hosts.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace, stableHostOrder } from '../../src/lib/ui/host-overview.js';
import HostList from '../../src/lib/ui/components/HostList.svelte';
const now = Date.parse('2026-10-06T12:00:00Z');
function host(id = 'ec-1', changes: Partial<Host> = {}): Host {
return { id, name: id, hostname: id, mode: 'active', portMin: 28960, portMax: 28980,
bindAddress: '0.0.0.0', publicAddress: '', minimumFreeBytes: 100, createdAt: new Date(now).toISOString(),
lastHeartbeat: new Date(now).toISOString(), connected: true, connectionState: 'online', readiness: 'ready', readinessReasons: [],
inventory: { cpuUsagePercent: 0, memoryTotal: 1024 ** 3, memoryAvailable: 512 * 1024 ** 2, storageTotal: 10 * 1024 ** 3, storageAvailable: 8 * 1024 ** 3 },
enrolled: true, credentialActive: true, decommission: null, preparation: { state: 'ready', items: [] }, ...changes };
}
it('does not show an old Ready state as available when the heartbeat becomes stale', () => {
expect(hostListStatus(host(), now).label).toBe('Ready');
expect(hostReadingsStale(host(), now + 90_001)).toBe(true);
expect(hostListStatus(host(), now + 90_001)).toMatchObject({ key: 'offline', priority: 0 });
expect(hostReadingsStale(host('bad-date', { lastHeartbeat: 'invalid' }), now)).toBe(true);
expect(hostListStatus(host('new', { enrolled: false, connected: false, lastHeartbeat: null }), now).label).toBe('Awaiting enrollment');
});
it('keeps deliberate disablement and deletion distinct from unexpected outages', () => {
expect(hostListStatus(host('disabled', { mode: 'disabled', connected: false }), now).label).toBe('Disabled');
expect(hostListStatus(host('draining', { mode: 'draining' }), now).label).toBe('Draining');
const deleting = host('deleting', { connected: false, decommission: { operationId: 'delete', stage: 'cleaned', requestedAt: '', updatedAt: '', error: null, retryable: false } });
expect(hostListStatus(deleting, now).label).toBe('Deleting');
deleting.decommission!.stage = 'failed'; deleting.decommission!.error = 'Cleanup did not finish';
expect(hostListStatus(deleting, now)).toMatchObject({ key: 'attention', label: 'Cleanup failed', detail: 'Cleanup did not finish' });
});
it('surfaces setup and update failures without treating ordinary preparation as failure', () => {
const preparing = host('preparing', { preparation: { state: 'preparing', items: [{ kind: 'runtime', label: 'Runtime', state: 'preparing', detail: 'Downloading runtime' }] } });
expect(hostListStatus(preparing, now)).toMatchObject({ key: 'preparing', detail: 'Downloading runtime' });
preparing.preparation!.state = 'blocked'; preparing.preparation!.items[0].state = 'blocked';
expect(hostListStatus(preparing, now).key).toBe('attention');
expect(hostListStatus(host('update', { inventory: { agentUpdate: { enabled: true, state: 'failed', currentVersion: '1.0', error: 'Invalid signature' } } }), now)).toMatchObject({ key: 'attention', detail: 'Agent update failed: Invalid signature' });
expect(hostListStatus(host('revoked', { credentialActive: false }), now)).toMatchObject({ key: 'attention', tab: 'settings' });
});
it('sorts issues first initially and preserves existing positions when polls change status', () => {
const healthy = host('a'); const offline = host('z', { connected: false });
const order = stableHostOrder([], [healthy, offline], now);
expect(order).toEqual(['z', 'a']);
expect(stableHostOrder(order, [host('z'), host('a', { connected: false }), host('new')], now)).toEqual(['z', 'a', 'new']);
expect(stableHostOrder(order, [healthy], now)).toEqual(['a']);
});
it('distinguishes real zero usage from missing or invalid metrics', () => {
expect(hostCpuUsage(0)).toBe('0.0%');
for (const value of [undefined, null, NaN, Infinity, -1, 101]) expect(hostCpuUsage(value)).toBe('Not reported');
expect(hostUsedSpace(1024, 1024)).toBe('0 B / 1.0 KiB');
expect(hostUsedSpace(1024, undefined)).toBe('Not reported');
expect(hostUsedSpace(1024, 2048)).toBe('Not reported');
});
it('renders compact host links and workload without duplicate identity, setup metadata or deletion actions', () => {
const body = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3 }], now } }).body;
expect(body).toContain('3 assigned servers on ec-1');
expect(body).toContain('0.0%');
expect(body).toContain('512.0 MiB / 1.0 GiB');
expect(body.match(/>ec-1</g)).toHaveLength(1);
expect(body).not.toContain('Delete host');
expect(body).not.toContain('Game ports');
expect(body).not.toContain('Automatic setup');
expect(body).not.toContain('Resource readings stale');
const stale = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3 }], now: now + 90_001 } }).body;
expect(stale).toContain('Resource readings stale');
expect(stale).toContain('Offline');
});