Show instance and player counts on hosts

This commit is contained in:
2026-10-06 17:23:15 -04:00
parent ef290ae0ee
commit 5ad76e6590
4 changed files with 51 additions and 16 deletions
+19 -6
View File
@@ -1,16 +1,29 @@
import { db } from '../db/index.js';
import { listHosts } from './index.js';
import { presentHostPreparation } from './preparation.js';
import { observationReader } from '../fleet/status.js';
import type { ServerRow } from '../fleet/servers.js';
import type { Host } from '../../hosts.js';
export interface HostOverview extends Host { serverCount: number }
export interface HostOverview extends Host { serverCount: number; playerCount: number; unreportedInstances: number }
/** Count assigned servers without loading their telemetry or historical placements. */
/** Read current placements once and use the fleet's shared telemetry/RCON selection. */
export function hostOverview(): HostOverview[] {
const hosts = listHosts();
const counts = new Map((db().prepare(`SELECT i.host_id, COUNT(*) AS count FROM native_instances i
const instances = db().prepare(`SELECT s.*, i.host_id FROM native_instances i
JOIN servers s ON s.name = i.server
WHERE i.retired_at IS NULL AND s.destroyed_at IS NULL
GROUP BY i.host_id`).all() as { host_id: string; count: number }[]).map(row => [row.host_id, row.count]));
return hosts.map(host => ({ ...presentHostPreparation(host), serverCount: counts.get(host.id) ?? 0 }));
WHERE i.retired_at IS NULL AND s.destroyed_at IS NULL`).all() as (ServerRow & { host_id: string })[];
const counts = new Map<string, { serverCount: number; playerCount: number; unreportedInstances: number }>();
if (instances.length) {
const observe = observationReader();
for (const instance of instances) {
const count = counts.get(instance.host_id) ?? { serverCount: 0, playerCount: 0, unreportedInstances: 0 };
count.serverCount++;
const observation = observe(instance);
if (observation.health === 'online' && observation.last_players !== null) count.playerCount += observation.last_players;
else count.unreportedInstances++;
counts.set(instance.host_id, count);
}
}
return hosts.map(host => ({ ...presentHostPreparation(host), ...(counts.get(host.id) ?? { serverCount: 0, playerCount: 0, unreportedInstances: 0 }) }));
}
+4 -3
View File
@@ -3,11 +3,11 @@
import { contextHref } from '$lib/navigation-context.js';
import { hostListStatus, hostReadingsStale, hostCpuUsage, hostUsedSpace } from '$ui/host-overview.js';
import Badge from './primitives/Badge.svelte';
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number })[]; now: number; returnTo?: string } = $props();
let { hosts, now, returnTo = '/hosts' }: { hosts: (Host & { serverCount: number; playerCount: number; unreportedInstances: number })[]; now: number; returnTo?: string } = $props();
</script>
<div class="table-wrap"><table>
<thead><tr><th>Host</th><th>Status</th><th title="Assigned servers, including stopped instances">Servers</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
<thead><tr><th>Host</th><th>Status</th><th title="Assigned instances, including stopped instances">Instances</th><th title="Players reported by online instances">Players</th><th>CPU</th><th>Memory <span>used / total</span></th><th>Disk <span>used / total</span></th></tr></thead>
<tbody>{#each hosts as host (host.id)}
{@const status = hostListStatus(host, now)}
{@const stale = hostReadingsStale(host, now)}
@@ -15,7 +15,8 @@
<td><a class="name" href={contextHref(`/hosts/${host.id}`, returnTo)}>{host.name}</a>{#if host.hostname && host.hostname !== host.name}<span class="secondary">{host.hostname}</span>{/if}
{#if stale && host.lastHeartbeat}<span class="secondary">Resource readings stale</span>{/if}</td>
<td class="status"><Badge variant={status.tone}>{status.label}</Badge>{#if status.detail}<a class="reason" href={contextHref(`/hosts/${host.id}?tab=${status.tab}`, returnTo)}>{status.detail}</a>{/if}</td>
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned servers on ${host.name}`}>{host.serverCount}</a></td>
<td><a href={contextHref(`/hosts/${host.id}?tab=servers`, returnTo)} aria-label={`${host.serverCount} assigned instances on ${host.name}`}>{host.serverCount}</a></td>
<td title={host.unreportedInstances ? `Player counts unavailable for ${host.unreportedInstances} of ${host.serverCount} instances` : 'Players reported by online instances'}>{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`}</td>
<td class:stale>{hostCpuUsage(host.inventory.cpuUsagePercent)}</td>
<td class:stale>{hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)}</td>
<td class:stale>{hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}</td>
+12 -4
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent } from '../helpers/telemetry.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
@@ -33,12 +34,12 @@ describe('host detail instances', () => {
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);
expect(overview.find(row => row.id === host.id)).toMatchObject({ serverCount: 2, playerCount: 2, unreportedInstances: 1 });
expect(overview.find(row => row.id === other.id)).toMatchObject({ serverCount: 1, playerCount: 2, unreportedInstances: 0 });
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 });
expect(listed.hosts.find((row: { id: string }) => row.id === host.id)).toMatchObject({ serverCount: 2, playerCount: 2, unreportedInstances: 1 });
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);
@@ -52,6 +53,13 @@ describe('host detail instances', () => {
expect((await read(other.id)).instances.map((instance: { name: string }) => instance.name)).toEqual(['other-host']);
const empty = createHost({ name: 'Empty host' });
expect((await read(empty.id)).instances).toEqual([]);
expect(hostOverview().find(row => row.id === empty.id)?.serverCount).toBe(0);
expect(hostOverview().find(row => row.id === empty.id)).toMatchObject({ serverCount: 0, playerCount: 0, unreportedInstances: 0 });
const report = { ...gameEvent('heartbeat', 15, 4, 'running'), receivedAt: now };
db().prepare('INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?)')
.run('running', 15, 4, now, JSON.stringify(report));
expect(hostOverview().find(row => row.id === host.id)).toMatchObject({ playerCount: 4, unreportedInstances: 1 });
db().prepare("DELETE FROM server_telemetry WHERE server='running'").run();
db().prepare("UPDATE servers SET last_seen_at='2000-01-01T00:00:00Z',last_players=99 WHERE name='running'").run();
expect(hostOverview().find(row => row.id === host.id)).toMatchObject({ playerCount: 0, unreportedInstances: 2 });
});
});
+16 -3
View File
@@ -56,8 +56,11 @@ it('distinguishes real zero usage from missing or invalid metrics', () => {
});
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');
const body = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 12, unreportedInstances: 0 }], now } }).body;
expect(body).toContain('3 assigned instances on ec-1');
expect(body).toContain('>Instances</th>');
expect(body).toContain('>Players</th>');
expect(body).toContain('>12</td>');
expect(body).toContain('0.0%');
expect(body).toContain('512.0 MiB / 1.0 GiB');
expect(body.match(/>ec-1</g)).toHaveLength(1);
@@ -65,7 +68,17 @@ it('renders compact host links and workload without duplicate identity, setup me
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;
const stale = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 12, unreportedInstances: 0 }], now: now + 90_001 } }).body;
expect(stale).toContain('Resource readings stale');
expect(stale).toContain('Offline');
});
it('marks partial player totals and distinguishes unavailable counts from empty hosts', () => {
const partial = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }], now } }).body;
expect(partial).toContain('>4+</td>');
expect(partial).toContain('Player counts unavailable for 1 of 3 instances');
const unknown = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 0, unreportedInstances: 3 }], now } }).body;
expect(unknown).toContain('>—</td>');
const empty = render(HostList, { props: { hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }], now } }).body;
expect(empty).toContain('>0</td>');
});