@@ -65,9 +95,16 @@
{:else}
{#each servers as s (s.name)}
+ {@const live = liveFor(s.name, s)}
{/each}
@@ -84,7 +121,7 @@
.grid {
display: grid;
grid-template-columns: 1fr 380px;
- gap: 20px;
+ gap: 24px;
}
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
@@ -92,32 +129,77 @@
.hdr {
display: flex;
justify-content: space-between;
- align-items: baseline;
- margin-bottom: 16px;
+ align-items: flex-end;
+ margin-bottom: 20px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--cherry-700);
}
+ .title-block { display: flex; align-items: baseline; gap: 14px; }
h1 {
margin: 0;
- font-size: 22px;
- color: #ff6060;
+ font-family: var(--font-display);
+ font-size: 32px;
+ color: var(--cream);
+ letter-spacing: 2px;
+ line-height: 1;
+ }
+ .count {
+ font-family: var(--font-mono);
+ font-size: 12px;
+ color: var(--ink-500);
letter-spacing: 0.5px;
}
.actions { display: flex; gap: 8px; }
button {
- background: #222;
- color: #ddd;
- border: 1px solid #444;
- border-radius: 4px;
- padding: 8px 14px;
+ background: var(--cherry-875);
+ color: var(--ink-100);
+ border: 1px solid var(--cherry-700);
+ border-radius: var(--r-sm);
+ padding: 8px 16px;
cursor: pointer;
+ font-family: var(--font-body);
font-size: 13px;
+ font-weight: 500;
+ letter-spacing: 0.3px;
+ transition: border-color 0.15s, box-shadow 0.15s;
}
- button.primary { background: #ff3030; color: #fff; border-color: #ff3030; font-weight: 600; }
- button:disabled { opacity: 0.5; cursor: default; }
+ button:hover:not(:disabled) {
+ border-color: var(--spark-500);
+ box-shadow: var(--spark-glow);
+ }
+ button.primary {
+ background: linear-gradient(180deg, var(--cherry-500), var(--cherry-600));
+ color: var(--cream);
+ border-color: var(--cherry-400);
+ font-weight: 600;
+ }
+ button.primary:hover:not(:disabled) {
+ border-color: var(--spark-500);
+ box-shadow: var(--spark-glow), 0 0 20px rgba(200, 16, 46, 0.4);
+ }
+ button:disabled { opacity: 0.4; cursor: default; }
.cards {
display: grid;
- gap: 10px;
- grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 14px;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ }
+ .empty {
+ color: var(--ink-500);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ padding: 40px;
+ text-align: center;
+ border: 1px dashed var(--cherry-700);
+ border-radius: var(--r-md);
+ }
+ .flash {
+ color: var(--spark-400);
+ margin: 0 0 16px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 10px 14px;
+ background: rgba(34, 224, 255, 0.06);
+ border: 1px solid rgba(34, 224, 255, 0.25);
+ border-radius: var(--r-sm);
}
- .empty { color: #666; }
- .flash { color: #aaa; margin: 0 0 12px; font-family: ui-monospace, monospace; font-size: 12px; }
diff --git a/src/routes/api/servers/+server.ts b/src/routes/api/servers/+server.ts
index 7ff464c..2121a83 100644
--- a/src/routes/api/servers/+server.ts
+++ b/src/routes/api/servers/+server.ts
@@ -1,9 +1,22 @@
import { json } from '@sveltejs/kit';
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
+import { classifyHealth } from '$lib/server/fleet/health.js';
+import type { RconPlayer } from '$lib/server/integrations/rcon.js';
+
+function safeJson(raw: string | null): T | null {
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+}
export async function GET() {
const servers = listServers().map((s) => ({
...s,
+ roster: safeJson(s.last_roster),
+ health: classifyHealth(s.last_seen_at, s.last_error),
held: [...heldByBeta(s.name).entries()].map(([target, slug]) => ({ target, slug }))
}));
return json({ servers });
diff --git a/src/routes/betas/+page.svelte b/src/routes/betas/+page.svelte
index 64a3678..805b9ed 100644
--- a/src/routes/betas/+page.svelte
+++ b/src/routes/betas/+page.svelte
@@ -27,6 +27,16 @@
{/if}
diff --git a/src/routes/deployments/+page.svelte b/src/routes/deployments/+page.svelte
index f260397..3a4b2ea 100644
--- a/src/routes/deployments/+page.svelte
+++ b/src/routes/deployments/+page.svelte
@@ -31,14 +31,8 @@
return d.servers.some((s) => s.restart_state === 'pending' || s.restart_state === 'failed');
}
- function chipColor(state: string): { bg: string; fg: string } {
- switch (state) {
- case 'done': return { bg: '#2d4a2d', fg: '#a8e6a8' };
- case 'failed': return { bg: '#4a2d2d', fg: '#ff8080' };
- case 'pending': return { bg: '#3a2f00', fg: '#ffcc55' };
- case 'manual': return { bg: '#333', fg: '#aaa' };
- default: return { bg: '#222', fg: '#888' };
- }
+ function chipClass(state: string): string {
+ return `chip chip-${state}`;
}
function ago(iso: string): string {
@@ -89,8 +83,7 @@
{#each d.servers as s (s.server)}
- {@const c = chipColor(s.restart_state)}
-
+
{s.server}{s.players !== null && s.players > 0 ? ` (${s.players}p)` : ''}
{/each}
@@ -100,35 +93,78 @@
{/snippet}
diff --git a/src/routes/deployments/[id]/+page.svelte b/src/routes/deployments/[id]/+page.svelte
index 7c9e8fe..881deda 100644
--- a/src/routes/deployments/[id]/+page.svelte
+++ b/src/routes/deployments/[id]/+page.svelte
@@ -116,88 +116,146 @@
{/if}
diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte
index d509a72..41281e2 100644
--- a/src/routes/servers/[name]/+page.svelte
+++ b/src/routes/servers/[name]/+page.svelte
@@ -2,6 +2,10 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { statusByServer } from '$ui/stores/servers.js';
+ import type { RosterPlayer, Health } from '$ui/stores/servers.js';
+ import StatusDot from '$ui/components/StatusDot.svelte';
+ import PlayerRoster from '$ui/components/PlayerRoster.svelte';
+ import { gametypeLabel } from '$ui/gametype.js';
interface Server {
name: string;
@@ -12,6 +16,12 @@
rcon_host: string | null;
last_players: number | null;
last_checked_at: string | null;
+ last_map: string | null;
+ last_gametype: string | null;
+ last_seen_at: string | null;
+ last_error: string | null;
+ roster: RosterPlayer[] | null;
+ health: Health;
held: Array<{ target: string; slug: string }>;
}
interface DeploymentRow {
@@ -34,96 +44,243 @@
history = depBody.deployments.filter((d) => d.servers.some((s) => s.server === name));
});
- let live = $derived($statusByServer.get(name)?.players);
+ let live = $derived($statusByServer.get(name));
+ let effectivePlayers = $derived(live?.players ?? server?.last_players ?? null);
+ let effectiveMap = $derived(live?.map ?? server?.last_map ?? null);
+ let effectiveGametype = $derived(live?.gametype ?? server?.last_gametype ?? null);
+ let effectiveRoster = $derived(live?.roster ?? server?.roster ?? null);
+ let effectiveHealth = $derived(live?.health ?? server?.health ?? 'offline');
+ let effectiveSeen = $derived(live?.lastSeenAt ?? server?.last_seen_at ?? null);
+ let effectiveErr = $derived(live?.lastError ?? server?.last_error ?? null);
+
+ function ago(iso: string | null): string {
+ if (!iso) return 'never';
+ const d = Date.now() - new Date(iso).getTime();
+ if (d < 60_000) return `${Math.max(1, Math.floor(d / 1000))}s ago`;
+ if (d < 3_600_000) return `${Math.floor(d / 60_000)}m ago`;
+ if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h ago`;
+ return `${Math.floor(d / 86_400_000)}d ago`;
+ }
{#if server}
- {server.name}
-
-
-
players
-
{live ?? server.last_players ?? '—'}
+
+
+
+
+
{server.name}
+
+
+
0}>
+ {effectivePlayers ?? '—'}
+
+
players
+
-
-
komodo
-
{server.komodo_type} · {server.komodo_name}
+
+ map {effectiveMap ?? '—'}
+ mode {gametypeLabel(effectiveGametype) || '—'}
+ status {effectiveHealth}
+ last seen {ago(effectiveSeen)}
-
-
rcon
-
{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}
-
-
-
last check
-
{server.last_checked_at ?? '—'}
-
-
-
held by beta
-
{server.held.length === 0 ? '—' : server.held.map((h) => h.target).join(', ')}
-
-
+ {#if effectiveErr && effectiveHealth !== 'online'}
+ RCON: {effectiveErr}
+ {/if}
+
-
Deployment history
- {#if history.length === 0}
-
no deployments recorded on this server
- {:else}
- {#each history as d (d.id)}
- {@const mine = d.servers.find((s) => s.server === name)!}
-
- {d.action}
- {mine.restart_state}{mine.players !== null && mine.players > 0 ? ` (${mine.players}p)` : ''}
- {mine.files_changed} file{mine.files_changed === 1 ? '' : 's'}
- {d.started_at}
- {#if mine.error}{mine.error}{/if}
-
- {/each}
- {/if}
+
+
+
+ Config
+
+
+
komodo
+
{server.komodo_type} · {server.komodo_name}
+
+
+
rcon
+
{server.rcon_host ?? 'default'} · port {server.game_port ?? '—'}
+
+
+
held by beta
+
{server.held.length === 0 ? '—' : server.held.map((h) => h.target).join(', ')}
+
+
+
+
+
{:else}
loading…
{/if}
diff --git a/static/favicon.svg b/static/favicon.svg
new file mode 100644
index 0000000..46e98cf
--- /dev/null
+++ b/static/favicon.svg
@@ -0,0 +1,17 @@
+
diff --git a/tests/unit/rcon.test.ts b/tests/unit/rcon.test.ts
index ebe07ea..e5caac8 100644
--- a/tests/unit/rcon.test.ts
+++ b/tests/unit/rcon.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { parseInfoLine, parsePlayerCount } from '../../src/lib/server/integrations/rcon.js';
+import { parseInfoLine, parsePlayerCount, parsePlayerRoster } from '../../src/lib/server/integrations/rcon.js';
describe('parsePlayerCount', () => {
it('counts rows after --- --- separator (status dialect)', () => {
@@ -57,3 +57,59 @@ describe('parseInfoLine', () => {
expect(parseInfoLine('nothing here')).toEqual({});
});
});
+
+describe('parsePlayerRoster', () => {
+ it('extracts num/score/ping/guid/name from print dialect', () => {
+ const body = [
+ 'print',
+ 'map: mp_kino',
+ 'num score ping guid name',
+ '--- ----- ---- -------- ----',
+ ' 0 12 45 aaaaaaaabbbbbbbbccccccccdddddddd joe',
+ ' 1 0 72 11112222333344445555666677778888 jill h.',
+ ' 2 999 128 abcdef0123456789abcdef0123456789 Toaster'
+ ].join('\n');
+ const roster = parsePlayerRoster(body);
+ expect(roster).toHaveLength(3);
+ expect(roster[0]).toEqual({
+ num: 0,
+ score: 12,
+ ping: 45,
+ guid: 'aaaaaaaabbbbbbbbccccccccdddddddd',
+ name: 'joe'
+ });
+ // Names with spaces stay intact
+ expect(roster[1].name).toBe('jill h.');
+ expect(roster[2].ping).toBe(128);
+ });
+
+ it('extracts score/ping/quoted-name from statusResponse dialect', () => {
+ const body = [
+ 'statusResponse',
+ '\\mapname\\mp_kino\\g_gametype\\zclassic',
+ '10 45 "joe"',
+ '0 72 "jill h."'
+ ].join('\n');
+ const roster = parsePlayerRoster(body);
+ expect(roster).toHaveLength(2);
+ expect(roster[0]).toEqual({
+ num: null,
+ score: 10,
+ ping: 45,
+ guid: null,
+ name: 'joe'
+ });
+ expect(roster[1].name).toBe('jill h.');
+ });
+
+ it('returns empty array when no rows are present', () => {
+ const empty = [
+ 'print',
+ 'map: mp_ascension',
+ 'num score ping guid name',
+ '--- ----- ---- -------- ----'
+ ].join('\n');
+ expect(parsePlayerRoster(empty)).toEqual([]);
+ expect(parsePlayerRoster('nonsense')).toEqual([]);
+ });
+});