Show running instances on host pages

This commit is contained in:
2026-10-05 13:56:37 -04:00
parent a6c40cde9b
commit a142f06c77
4 changed files with 87 additions and 3 deletions
+15
View File
@@ -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);
}
+2 -1
View File
@@ -4,10 +4,11 @@ import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js'; import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js'; import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js'; import { config } from '$lib/server/config.js';
import { hostInstances } from '$lib/server/hosts/instances.js';
type Event = { request: Request; params: { id: string } }; type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) { export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.'); 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) { export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request); requireManagementOrigin(request);
+23 -2
View File
@@ -11,7 +11,11 @@
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
import HostPreparation from '$ui/components/HostPreparation.svelte'; import HostPreparation from '$ui/components/HostPreparation.svelte';
import CapacitySamples from '$ui/components/CapacitySamples.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<Host | null>(null); let host = $state<Host | null>(null);
let instances = $state<ServerObservation[]>([]);
let calibration = $state<CapacityCalibration | null>(null); let calibration = $state<CapacityCalibration | null>(null);
let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state(''); let loading = $state(true); let busy = $state(false); let error = $state(''); let notice = $state('');
let deleteOpen = $state(false); let deleteOpen = $state(false);
@@ -24,7 +28,7 @@
async function load(initialize = false, signal?: AbortSignal) { async function load(initialize = false, signal?: AbortSignal) {
const target = endpoint; const sequence = ++requestSequence; const target = endpoint; const sequence = ++requestSequence;
try { 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; } 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; } } catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
} }
@@ -45,7 +49,7 @@
</script> </script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head> <svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
<section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Readiness, inventory, settings, and credentials."> <section class="page"><a href="/hosts">← All hosts</a><SectionHeader title={host?.name ?? 'Host'} subtitle="Running instances, readiness, inventory, settings, and credentials.">
{#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet} {#snippet actions()}<Button href="/hosts/operations">Operation history</Button>{#if canManage && host && (!cleaning || host.decommission?.retryable)}<Button variant="danger" disabled={busy} onclick={() => deleteOpen = true}>{cleaning ? 'Retry cleanup' : 'Delete host'}</Button>{/if}{/snippet}
</SectionHeader> </SectionHeader>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}{#if notice}<p role="status">{notice}</p>{/if}
@@ -56,6 +60,22 @@
{#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if} {#if canManage && !cleaning}<div class="actions"><Button disabled={busy || host.mode === 'active'} onclick={() => mode('active')}>Enable</Button><Button disabled={busy || host.mode === 'draining'} onclick={() => mode('draining')}>Drain host</Button><Button disabled={busy || host.mode === 'disabled'} onclick={() => mode('disabled')}>Disable host</Button></div>{/if}
<p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p> <p>Draining prevents new deployments and retires instances as they become safe to remove. Disabled hosts receive no new commands; running game containers continue.</p>
</section> </section>
<section class="panel" aria-labelledby="running-instances-heading">
<h2 id="running-instances-heading">Running instances · {instances.length}</h2>
<p>Current instances on this host, including any that are starting or unavailable.</p>
{#if !host.connected && instances.length}<p role="status">The host is offline. Instance details reflect the latest reports.</p>{/if}
{#if instances.length}
<div class="instances">
{#each instances as instance (instance.name)}
<ServerCard name={instance.name} health={instance.health} players={instance.last_players}
map={instance.last_map} gametype={instance.last_gametype} roster={instance.roster}
lastSeenAt={instance.last_seen_at} lastError={instance.last_error} telemetry={instance.telemetry}
activeSince={instance.active_since} inactiveSince={instance.inactive_since}
gameStartedAt={instance.game_started_at} gameEndedAt={instance.game_ended_at} />
{/each}
</div>
{:else}<p>No instances are currently deployed on this host.</p>{/if}
</section>
{#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if} {#if !cleaning}<section class="panel"><h2>Automatic host setup</h2><HostPreparation {host} /></section>{/if}
{#if calibration}<section class="panel"><h2>Learned server capacity</h2> {#if calibration}<section class="panel"><h2>Learned server capacity</h2>
<p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p> <p>Allowances update from observed container usage. Hosts reserve 5% CPU and 500 MB of memory. Startup and running usage are tracked separately.</p>
@@ -90,5 +110,6 @@
<DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} /> <DeleteHostDialog bind:open={deleteOpen} {host} ondeleted={async (result) => { if (result.deleted) await goto('/hosts'); else { enrollment = null; await load(); } }} />
<style> <style>
.instances { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr)); gap:16px; }
.page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } } .page { display:grid; gap:24px; } .panel { border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); padding:24px; min-width:0; } h2 { margin:0 0 16px; font-size:var(--text-md); text-transform:capitalize; } p, li, dd, dt, span { font-size:var(--text-sm); line-height:1.65; } p,li,dt,span { color:var(--text-secondary); } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; } .row,.actions,.fields { display:flex; gap:12px; flex-wrap:wrap; } .row { justify-content:space-between; } .actions { margin-top:20px; } dl { display:grid; grid-template-columns:1fr 1.3fr; gap:12px; margin:0; } dd { margin:0; overflow-wrap:anywhere; } fieldset { display:grid; gap:16px; border:0; padding:0; margin:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; min-width:0; padding:10px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-sm); } .fields>* { flex:1; min-width:120px; } pre { white-space:pre-wrap; overflow-wrap:anywhere; padding:16px; background:var(--surface-sunken); font-size:var(--text-xs); } @media(max-width:900px) { .grid { grid-template-columns:1fr; } }
</style> </style>
+47
View File
@@ -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([]);
});
});