diff --git a/src/lib/server/hosts/overview.ts b/src/lib/server/hosts/overview.ts index 8833d59..e5bbd9d 100644 --- a/src/lib/server/hosts/overview.ts +++ b/src/lib/server/hosts/overview.ts @@ -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(); + 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 }) })); } diff --git a/src/lib/ui/components/HostList.svelte b/src/lib/ui/components/HostList.svelte index e829f3e..47e756b 100644 --- a/src/lib/ui/components/HostList.svelte +++ b/src/lib/ui/components/HostList.svelte @@ -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();
- + {#each hosts as host (host.id)} {@const status = hostListStatus(host, now)} {@const stale = hostReadingsStale(host, now)} @@ -15,7 +15,8 @@ - + + diff --git a/tests/unit/host-instances.test.ts b/tests/unit/host-instances.test.ts index d040bc0..69e41a0 100644 --- a/tests/unit/host-instances.test.ts +++ b/tests/unit/host-instances.test.ts @@ -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 }); }); }); diff --git a/tests/unit/ui-host-overview.test.ts b/tests/unit/ui-host-overview.test.ts index 7666e7b..7af27cb 100644 --- a/tests/unit/ui-host-overview.test.ts +++ b/tests/unit/ui-host-overview.test.ts @@ -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'); + expect(body).toContain('>Players'); + expect(body).toContain('>12'); expect(body).toContain('0.0%'); expect(body).toContain('512.0 MiB / 1.0 GiB'); expect(body.match(/>ec-1 { + const partial = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }], now } }).body; + expect(partial).toContain('>4+'); + 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('>—'); + const empty = render(HostList, { props: { hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }], now } }).body; + expect(empty).toContain('>0'); +});
HostStatusServersCPUMemory used / totalDisk used / total
HostStatusInstancesPlayersCPUMemory used / totalDisk used / total
{host.name}{#if host.hostname && host.hostname !== host.name}{host.hostname}{/if} {#if stale && host.lastHeartbeat}Resource readings stale{/if} {status.label}{#if status.detail}{status.detail}{/if}{host.serverCount}{host.serverCount}{host.unreportedInstances === host.serverCount && host.serverCount > 0 ? '—' : `${host.playerCount}${host.unreportedInstances ? '+' : ''}`} {hostCpuUsage(host.inventory.cpuUsagePercent)} {hostUsedSpace(host.inventory.memoryTotal, host.inventory.memoryAvailable)} {hostUsedSpace(host.inventory.storageTotal, host.inventory.storageAvailable)}