diff --git a/src/lib/server/hosts/instances.ts b/src/lib/server/hosts/instances.ts new file mode 100644 index 0000000..3dfb31e --- /dev/null +++ b/src/lib/server/hosts/instances.ts @@ -0,0 +1,15 @@ +import type { ServerObservation } from '../../server-observation.js'; +import { db } from '../db/index.js'; +import type { ServerRow } from '../fleet/servers.js'; +import { observationReader } from '../fleet/status.js'; + +/** Current instances remain visible when their host or game stops reporting. */ +export function hostInstances(hostId: string): ServerObservation[] { + const servers = db().prepare(`SELECT s.* FROM servers s + JOIN native_instances i ON i.server = s.name + WHERE i.host_id = ? AND i.retired_at IS NULL AND s.destroyed_at IS NULL + ORDER BY s.name`).all(hostId) as ServerRow[]; + if (!servers.length) return []; + const observe = observationReader(); + return servers.map(observe); +} diff --git a/src/routes/api/hosts/[id]/+server.ts b/src/routes/api/hosts/[id]/+server.ts index 32041d5..a5f2725 100644 --- a/src/routes/api/hosts/[id]/+server.ts +++ b/src/routes/api/hosts/[id]/+server.ts @@ -4,10 +4,11 @@ import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js'; import { presentHostPreparation } from '$lib/server/hosts/preparation.js'; import { config } from '$lib/server/config.js'; +import { hostInstances } from '$lib/server/hosts/instances.js'; type Event = { request: Request; params: { id: string } }; export function GET({ request, params }: Event) { requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.'); - return json({ host: presentHostPreparation(host), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id) }); + return json({ host: presentHostPreparation(host), instances: hostInstances(host.id), serverNow: Date.now(), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id) }); } export async function PATCH({ request, params }: Event) { requireManagementOrigin(request); diff --git a/src/routes/hosts/[id]/+page.svelte b/src/routes/hosts/[id]/+page.svelte index 9338e18..7269980 100644 --- a/src/routes/hosts/[id]/+page.svelte +++ b/src/routes/hosts/[id]/+page.svelte @@ -11,7 +11,11 @@ import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import HostPreparation from '$ui/components/HostPreparation.svelte'; import CapacitySamples from '$ui/components/CapacitySamples.svelte'; + import ServerCard from '$ui/components/ServerCard.svelte'; + import type { ServerObservation } from '$lib/server-observation.js'; + import { syncServerTime } from '$ui/stores/servers.js'; let host = $state(null); + let instances = $state([]); let calibration = $state(null); let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state(''); let deleteOpen = $state(false); @@ -24,7 +28,7 @@ async function load(initialize = false, signal?: AbortSignal) { const target = endpoint; const sequence = ++requestSequence; try { - const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; calibration = body.calibration ?? null; error = ''; + const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; instances = body.instances ?? []; syncServerTime(body.serverNow); calibration = body.calibration ?? null; error = ''; if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; } } catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; } } @@ -45,7 +49,7 @@ {host?.name ?? 'Host'} · Electric Cherry -
← All hosts +
← All hosts {#snippet actions()}{#if canManage && host && (!cleaning || host.decommission?.retryable)}{/if}{/snippet} {#if error}{/if}{#if notice}

{notice}

{/if} @@ -56,6 +60,22 @@ {#if canManage && !cleaning}
{/if}

Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.

+
+

Running instances · {instances.length}

+

Current instances on this host, including any that are starting or unavailable.

+ {#if !host.connected && instances.length}

The host is offline. Instance details reflect the latest reports.

{/if} + {#if instances.length} +
+ {#each instances as instance (instance.name)} + + {/each} +
+ {:else}

No instances are currently deployed on this host.

{/if} +
{#if !cleaning}

Automatic host setup

{/if} {#if calibration}

Learned server capacity

Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.

@@ -90,5 +110,6 @@ { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} /> diff --git a/tests/unit/host-instances.test.ts b/tests/unit/host-instances.test.ts new file mode 100644 index 0000000..33082f4 --- /dev/null +++ b/tests/unit/host-instances.test.ts @@ -0,0 +1,47 @@ +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('host detail instances', () => { + it('returns only current instances belonging to the requested host and refreshes their health', async () => { + const { createHost } = await import('../../src/lib/server/hosts/index.js'); + const host = createHost({ name: 'Selected host' }); + const other = createHost({ name: 'Other host' }); + const { db } = await import('../../src/lib/server/db/index.js'); + const now = new Date().toISOString(); + const add = (name: string, hostId: string, retired = false, destroyed = false) => { + db().prepare(`INSERT INTO native_instances (id, server, host_id, generation, library_id, image, port, created_at, retired_at) + VALUES (?, ?, ?, 1, 'library', 'runtime:test', 28960, ?, ?)`) + .run(name, name, hostId, now, retired ? now : null); + db().prepare(`INSERT INTO servers (name, komodo_name, + container_state, container_checked_at, last_seen_at, last_players, destroyed_at) + VALUES (?, ?, 'running', ?, ?, 2, ?)`) + .run(name, name, now, now, destroyed ? now : null); + }; + add('running', host.id); + add('unavailable', host.id); + add('other-host', other.id); + add('retired', host.id, true); + add('destroyed', host.id, false, true); + db().prepare(`INSERT INTO native_instances (id, server, host_id, library_id, image, port, created_at, retired_at) + VALUES ('old-placement', 'other-host', ?, 'library', 'runtime:test', 28960, ?, ?)`) + .run(host.id, now, now); + db().prepare("UPDATE servers SET container_state='exited' WHERE name='unavailable'").run(); + + 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); + expect(body.instances.map((instance: { name: string }) => instance.name)).toEqual(['running', 'unavailable']); + expect(body.instances[0]).toMatchObject({ health: 'online', last_players: 2 }); + expect(body.instances[1]).toMatchObject({ health: 'offline', last_players: null }); + expect(body.serverNow).toEqual(expect.any(Number)); + + 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([]); + }); +});