Commit Graph
453 Commits
Author SHA1 Message Date
josh e6b5705478 Probe tagged-union ListTags shape + surface raw bodies on miss
read/ListTags with an empty body was returning something we couldn't
parse, so every tag lookup silently missed and the CreateTag duplicate
fallback dead-ended with no diagnostic.

Now the tag lookup tries two request styles in order — the per-endpoint
`read/ListTags {}` used by the rest of this file, then the tagged-union
`read {type: "ListTags", params: {}}` used by mbecker20/komodo's
canonical API. If both fail to yield a match, both raw response bodies
get logged so the actual Komodo shape is diagnosable on the next run
without another commit-and-deploy cycle.
2026-09-17 09:26:07 -04:00
josh 66f70cd9f3 Handle Komodo tag response shape variance + duplicate recovery
Two real-world Komodo responses broke the first pass:

1. read/ListTags didn't surface tag "t5" in a matchable shape, so we
   fell through to CreateTag and got HTTP 500 "duplicate key: t5".
2. write/CreateTag returned 200 with an id field named "id" (not "_id"),
   which the parser rejected as an unexpected shape even on success.

Fixes:
- extractTagId accepts either "id" or "_id"
- unwrapTagList peels back common list wrappers (bare array, {tags},
  {data}, {results}, {items}) instead of requiring a top-level array
- CreateTag duplicate-key error triggers a re-list so the existing tag
  can be resolved to its id instead of failing the whole tag pass
- Both ListTags and CreateTag branches log the raw body when parsing
  fails, so the next unfamiliar shape is diagnosable at a glance
2026-09-17 09:20:20 -04:00
josh 5c674615d3 Tag Komodo deployments with game + canonical map name
New deployments get two tags attached (e.g. "t5" and "zombie_pentagon")
so operators can filter the Komodo deployment list by game and by map.
Tags are ensured via read/ListTags + write/CreateTag fallback, then
attached with write/UpdateTagsOnResource against the deployment name.

Tag application runs fire-and-forget after CreateDeployment: any failure
logs a warning and the server creation proceeds. Tags are cosmetic
organization inside Komodo, not a correctness requirement.
2026-09-17 09:15:00 -04:00
josh 5142a672ee Default test-server selection to none on beta open 2026-09-14 21:16:26 -04:00
josh 0fcdd18136 Fix HTTP 413 (redux): BODY_SIZE_LIMIT="Infinity", not "0"
The adapter treats "0" as "block anything > 0 bytes" rather than
"unlimited" — SvelteKit's own error message says: "To disable body size
limits, specify Infinity rather than 0." Previous commit set "0", which
kept the 413 in place.
2026-09-14 21:14:38 -04:00
josh e9dcc058eb Fix HTTP 413 on plugin upload: lift BODY_SIZE_LIMIT
SvelteKit's Node adapter caps request bodies at 512KB by default, which
rejects real-world plugin .dll uploads with HTTP 413. Same trust
boundary as the CSRF disable (LAN-only), so 0 = unlimited is fine.
2026-09-14 21:02:24 -04:00
josh a2d4a38925 Redesign UI with new token system, primitives, and motion
Restraint-first design language: neutral gray surfaces + one blue accent
(cyan/amber/red reserved for state), 8pt spacing, softer radii, one
ambient + one raised shadow, standardized motion tokens. Alfa Slab One
demoted to BrandMark only; Inter carries all page copy.

New primitives (Button, Card, Badge, Stat, SectionHeader, Skeleton,
EmptyState, Tabs, Breadcrumbs, StatusPip, Field, Input, Select, Textarea)
plus motion utilities (tweenNumber, viewTransition) and a global toast
system. All eight route surfaces rewritten on top: Fleet gains an
overview hero, Server detail restructures into tabs, Deployments gets a
collapsible drift summary + per-server pipeline visual, and the
scripts/configs/plugins/betas flows adopt the new form primitives.

Sticky translucent nav with backdrop blur, view-transition cross-fades
between routes, staggered card entrance on Fleet, breathing status pip
halo, tabular-numeric player counts. Gmail-style g-key nav shortcuts.
Mobile stacks below 720px; prefers-reduced-motion neutralizes durations.
Legacy tokens preserved as aliases so any un-migrated code still renders.
2026-09-14 19:48:48 -04:00
josh efd4a4fa66 Fix game timer: cycle-key tracking, wall-clock only, active gate
games_sp.log is not wiped on restart, so old J; lines from prior
sessions were poisoning the anchor. Now the scanner walks the tail
backward to the LATEST InitGame and only considers lines after it,
returning an opaque cycle key (the first J; line) plus a state. The
worker stamps game_started_at/ended_at with Date.now() at observation
time instead of inferring from the log's own timestamps. Same-players
game restarts produce a new key and cleanly re-anchor. When the server
transitions to 0 players, game state is cleared server-side and the
UI also gates the timer display on activeSince.

Adds servers.game_cycle_key column with additive migration.
2026-09-13 22:47:03 -04:00
josh 8186bb1ccc Anchor game timer to first player join, not InitGame
An idle map load emits InitGame in games_sp.log without anyone joining,
which was falsely surfacing the game timer on empty servers. Track the
first `J;<guid>;<slot>;<name>` line inside each InitGame cycle instead
and back-compute the wall-clock start from that line's in-game MM:SS.
Removes the coarse client-side activeSince gate since the scanner is
now the source of truth.
2026-09-13 21:55:05 -04:00
josh 3d111880d3 Add active/inactive/game-time timers to fleet + server pages
Fleet cards and the per-server detail hero now show three live-ticking
timers: Active (since players > 0), Inactive (since server emptied), and
Game (since InitGame in games_sp.log, back-computed from the log's own
MM:SS prefix so it stays accurate across restarts). Anchors are
persisted on `servers` and fanned out via the SSE bus; a single 1s
ticker feeds every card. Idle map cycles are filtered out so the game
timer doesn't flicker.
2026-09-13 21:45:54 -04:00
josh 79b2ba1464 corrected map aliases 2026-09-13 19:30:17 -04:00
joshandClaude Opus 4.7 be9cf10e02 Wait for container teardown before deleting Komodo deployment entity
execute/DestroyDeployment queues the container removal and returns before
it actually completes. write/DeleteDeployment refuses while a container
still exists for the deployment, so the entity survived every teardown.

Poll komodoInspectHealthy between the two calls until it stops reporting
a running container (up to 30s), then delete the entity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 19:01:13 -04:00
joshandClaude Opus 4.7 58f48c6906 Fix container port mapping to match host port
PLUTAINER_PORT sets the port the container listens on, so the mapping
should be <port>:<port>, not <port>:28960 — otherwise the container is
bound to a port nothing inside is listening on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 18:47:24 -04:00
joshandClaude Opus 4.7 21d9b54953 Fix deploy modal closing when opening the map dropdown
The bounding-rect click-outside check treated native <select> option clicks
(which render below the dialog) as backdrop clicks. Switch to the standard
e.target === dialog check — only clicks on the dialog element itself close.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 18:44:34 -04:00
joshandClaude Opus 4.7 13a29e5ace Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming
Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto
key in the Fleet page, EC provisions the deployment via Komodo, allocates a
port in 28960-28980, creates the 0o777 host dir, and starts the container.
Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir,
transactional DB cleanup) and surfaces any partial-failure warnings inline.

Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.).
Komodo entity names now match EC server names 1:1 (no more _/- transform), and
the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie
maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id,
image, created_by, and destroyed_at columns for provisioning + audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 18:38:42 -04:00
joshandClaude Opus 4.7 8c15cae80d Replace -beta sidecar and .OLD backup with DB snapshots
Beta deploys used to rename foo.gsc -> foo.OLD and write beta bytes to
a foo-beta.gsc sidecar, forcing the mod loader to know the naming
convention. Now the beta content lives at the actual target filename
(same as configs already did) and the pre-beta bytes are captured in
a new beta_file_snapshots table for rollback.

- schema.sql: new beta_file_snapshots(beta_id, server, rel_path,
  prior_content BLOB, prior_sha256, prior_size). Handles scripts,
  plugins, and the composed dedicated_sp.cfg uniformly.
- db/index.ts: assertSnapshotSchemeSafe boot guard refuses to start
  if a legacy running/ready-promote beta is present on first upgrade.
  A settings sentinel (snapshots_v1) makes it one-shot.
- beta.ts openBeta: read prior bytes, insert snapshot, overwrite
  target directly.
- beta.ts rollbackBeta: restore from snapshot if had_original, else
  delete the fresh file. Drop snapshot rows after restore.
- beta.ts promoteBeta: no more rm -beta / rm .OLD calls, pull prior
  content from the snapshot table for the deployment audit, drop
  snapshots after promote.
- files.ts: delete betaFile() and oldFile().
- drift.ts: docstring updated; no special-casing needed since beta
  bytes and inventory now agree on the target rel_path.
- servers/[name]/+page.svelte: drop dead -beta/.OLD row filters.
- tests: plugin write asserts the target filename; unused
  betaFile/oldFile tests removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 17:57:43 -04:00
joshandClaude Opus 4.7 30b0d1d4da Fix delete FK exhaustively; unset-scope surfaces as drift
deleteScript: previous fix missed beta_servers.prior_version_id,
beta_server_scripts.prior_version_id, and deployments.beta_id. Cascade
from betas.script_id -> beta_servers/beta_server_scripts left dangling
prior_version_id refs, and deletions of cascaded betas dangled
deployments.beta_id. Now nulls every NO ACTION reference before the
DELETE inside a single transaction.

Scope unset no longer touches disk from the API. Instead it detaches
inventory rows for scripts/plugins so drift Pass 1 flags the on-disk
copies as untracked; configs rely on drift Pass 2's compose-diff, which
already handles scope=null naturally. The operator reconciles from
/deployments using the existing rescan + reconcile buttons — same
visible flow as any other drift, no more silent synchronous file
removal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 23:59:43 -04:00
joshandClaude Opus 4.7 34dc2ff00f Fix delete-script FK error; trigger drift on scope change
- deleteScript: null out deployments.script_id/script_version_id/prior_version_id
  and deployment_scripts.prior_version_id inside a transaction before the
  cascade. These are historical audit FKs (NO ACTION), so the cascade from
  script_versions was blowing up any delete of a promoted script.
- Scope endpoint fires runDriftDeployment in the background after any scope
  mutation so orphan/missing files from the change get reconciled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 23:46:56 -04:00
joshandClaude Opus 4.7 f716036d89 Small UI cleanup, auto-refresh, and per-kind artifact routes
- Remove redundant "manage scripts" button and pipeline sidebar from Fleet page
- Bump log tail default 200 -> 1000 lines
- Auto-refresh (5s) on deployments list/detail, betas list, server detail, and artifact detail
- Drift ignores dedicated.cfg (Plutonium MP config; regenerates if deleted)
- Extract ArtifactDetail component; mount under /scripts/[id], /configs/[id], /plugins/[id] so configs and plugins no longer land on /scripts URLs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 23:30:04 -04:00
josh f10f0cefef drift update 2026-09-12 23:15:14 -04:00
josh bb1e5c6a95 fix config 2026-09-12 19:41:13 -04:00
josh b86b3d5cc1 config upload fix 2026-09-12 19:34:25 -04:00
josh 6ae9004ed2 Drift now covers all three destinations 2026-09-12 19:16:14 -04:00
josh 8c40bbeff1 config and plugin 2026-09-12 19:11:44 -04:00
josh 85bcb05a7d multi script beta 2026-09-12 18:23:56 -04:00
joshandClaude Opus 4.7 30e59c900c Read-only script detail; move beta creation to /betas
The script detail page is now an informational card (target, scope, matched
servers, current + latest version) with edit-scope and upload-new-version
behind native <dialog> modals. The Open Beta panel is gone from the script
page entirely.

/betas gains an Open a beta section at the top. Its script dropdown filters
to entries that actually have something to test (unpromoted latest version,
no active beta), and pre-populates version + servers on script pick.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 14:40:56 -04:00
joshandClaude Opus 4.7 c78d0c62fd Drop trigger option from beta detail; stack remaining opts vertically
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 14:28:28 -04:00
joshandClaude Opus 4.7 1afdfddc87 Promote as a real deployment; version state and scope dropdowns
Turns promotion into a first-class deployment (action='promote') so it lands
in the deployments UI with before/after content, per-server restart tracking,
and one-click rollback. Non-beta matching-scope servers get restart_state=
pending so the existing sweep drains and restarts them the same way drift does.

Also adds a per-version state (untested/tested/test-failed) so the upload
flow can visibly signal that a beta must land before promotion, and swaps
the scope-value text box for game/map dropdowns populated from the fleet.
Beta rollback at ready-promote is now labelled Reject and marks the version
test-failed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 14:24:35 -04:00
joshandClaude Opus 4.7 3f934fd579 Disable SvelteKit CSRF origin check for multipart uploads
SvelteKit's built-in cross-site POST guard was blocking legitimate
multipart uploads from our own UI (HTTP 403 'Cross-site POST form
submissions are forbidden') whenever the operator reaches the app via
a hostname/port that doesn't match the browser's Origin header — LAN
IPs, reverse proxies, and .local hostnames all trip it. This tool is
scoped to private networks and has no cross-origin surface to defend
against, so the guard is more friction than protection here.

If Electric Cherry ever gets exposed to the public internet, do
per-route origin checks in hooks.server.ts instead of re-enabling
this blanket rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:55:29 -04:00
joshandClaude Opus 4.7 11e47b2a3f Widen Komodo health probe: try container endpoints, snippet on failure
GetDeployment returns config, not runtime state, so the health check was
always coming back with 'no container state found in response'. For
deployment-type servers the probe now tries GetDeploymentContainer,
InspectDeploymentContainer, and GetDeploymentStatus first, falling back
to GetDeployment only if those aren't available. The extractor also
recognizes more shapes (Health.Status, container_state, deployment_state,
etc.) and accepts more healthy strings (running, healthy, ok, started,
up).

When nothing matches, the truncated raw response snippet rides along in
the error field so the deployment/beta UI shows exactly which field to
teach the extractor next time.

Currently-stuck deployment_server rows are still 'pending', so the sweep
will retry them on its next 15s tick — no manual reset needed. Once one
of the widened endpoints resolves, those rows advance to 'done'
automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:51:04 -04:00
joshandClaude Opus 4.7 edfe4dfc09 Add drift detection and removal as a real deployment
A drift deployment scans each server's sp/ tree for files not in the
server_scripts inventory (untracked scripts left over from before EC was
managing the fleet), deletes them, and records the removed bytes in
deployment_files for audit. The existing sweep then runs the standard
wait-for-empty → Komodo restart cycle on each affected server.

To make that cycle safer, the sweep now has a second phase: after Komodo
restart succeeds we wait for komodoInspectHealthy + RCON to both come
back green before marking the deployment_server row 'done'. If either
stays broken the row stays pending and keeps retrying.

New: /api/drift (GET scan, POST run), drift panel on /deployments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:44:52 -04:00
joshandClaude Opus 4.7 590f803d93 Auto-derive server.game and server.map from the server name
Server folders encode the game and map (e.g. t5_ascension_1 = game t5, map
alias ascension, lobby 1). A T5_MAP_ALIASES table translates the friendly
alias to the canonical mapname RCON reports (ascension → zombie_cosmodrome),
covering every base + Rezurrection map. seedServerRegistry now populates
game and map at insert time and backfills existing NULL rows on every boot;
unknown aliases log a warning so we know when to add them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:35:43 -04:00
joshandClaude Opus 4.7 1a0f06403d Drop target rel path field from script upload
Every T5 script lands in the flat sp/ directory, so the target path is
always just the uploaded filename. Removed the field from the Scripts
upload form and the server derives targetRelPath from file.name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:30:05 -04:00
joshandClaude Opus 4.7 93d5a8e2e5 Rework as scripts-centric, upload-only, gated beta lifecycle
Scripts become first-class: named, versioned, uploaded via the UI, optionally
scoped to a game or map. Any change that would land on scope-matched servers
(marking-required or new version of a scoped script) must first pass a beta.
The beta runs a per-server state machine — deploy -beta.gsc → wait empty →
Komodo restart → wait healthy → optionally wait for a real match end
(games_sp.log 'ExitLevel: executed') — before it's eligible for promotion.
Rollback is always available and restores from .OLD.

The shared/scripts mirror and its betas/ staging folder are gone. Every
server now has a tracked script inventory (server_scripts table), populated
only by explicit upload flows.

New: /scripts, /scripts/[id], /betas/[id] UI; /api/scripts + /api/betas +
/api/servers/[name]/scripts endpoints; scripts.ts, inventory.ts, beta.ts
(rewritten), beta-runner.ts, match-log.ts, komodoInspectHealthy().
Removed: push.ts, /api/push, BetaPanel, legacy importer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 13:25:06 -04:00
joshandClaude Opus 4.7 47051dc79a Repaint theme blue with larger typography
Swap the cherry-red primary for the logo's blue, bump body 13→15px
and h1 32→40px, and add semantic tokens (--bg-*, --accent-*, --text-*)
so future palette shifts are a single-file edit. Cherry-red survives
only for danger states and the brand cherry mark.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 12:29:02 -04:00
joshandClaude Opus 4.7 c3c799a409 Emit self-hosted fonts by importing from JS, not CSS @import
Firefox's console was spammed with "downloadable font: download failed
status=2147746065" for every @fontsource family — Inter, Alfa Slab One,
JetBrains Mono. Root cause: the built CSS referenced
./files/inter-latin-400-normal.woff2 but no font files were ever emitted
to build/client/_app/immutable/assets/.

Vite's CSS `@import` inlines the imported file's *text* but doesn't
crawl the resulting url() references to emit their assets. Since
tokens.css was doing `@import '@fontsource/inter/400.css'` and being
pulled into +layout.svelte via a Svelte <style> @import, the fonts
lived entirely in CSS space where Vite's asset emission never saw them.

Moved the @fontsource imports into src/lib/ui/styles/fonts.ts and
side-effect-import that module from +layout.svelte's <script>. JS
imports go through the module graph, so Vite walks the CSS, sees the
url() references, and emits the woff/woff2 binaries.

Verified: build now emits 80 font files (14 subsets × 5-6 weights)
into build/client/_app/immutable/assets/ and the CSS url()s resolve
to the correct hashed filenames in the same directory.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 12:08:17 -04:00
joshandClaude Opus 4.7 dd4314df72 Clean up server detail page: remove debug scaffolding, tighten hero
Now that the pipeline works end-to-end, the "RCON reply" panel with its
parsed-value grid and hex dump was pure debug scaffolding that ran on
every page load — it duplicated every field the hero already surfaces
and shoved a 13-line hex block into the fold. Deleted the whole
section; the on-demand raw dump lives inside the RCON diagnostic panel
(which still only renders when health isn't online, so it stays out of
the way on healthy servers).

Hero metadata compressed: status and last-seen pairs only render when
health isn't online, since the pulsing dot conveys "healthy + fresh"
better than a redundant text label. Mode pair hides when the gametype
isn't reported (Plutonium T5 doesn't include it in the status header).

Config panel hides the "held by beta" row when nothing is held —
matches how the deployment history section already only renders when
there's history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:59:11 -04:00
joshandClaude Opus 4.7 6aae72f508 Add per-server log viewer (console.log + games_sp.log)
New Logs section on the server detail page — tabbed viewer with
auto-refresh every 5s (toggleable), sticky-to-bottom scrolling, and a
manual refresh button. Two whitelisted files per server:

  /root/zombies/t5/<name>/logs/console.log
  /root/zombies/t5/<name>/logs/games_sp.log

Backend:
  - src/lib/server/fleet/logs.ts — safe path resolution + tailLog()
    that reads only the last 256 KB of the file, splits on newlines,
    drops the (potentially partial) first line if we didn't start at
    byte 0, and returns the last N lines with size + mtime + truncated.
    Two path-traversal guards: server directory must be a real
    subdirectory of SERVERS_ROOT, and the target must resolve inside
    it.
  - GET /api/servers/[name]/logs/[file]?lines=200 — whitelisted keys
    only (console, games_sp), 400 on unknown keys, ok:true+missing:
    true when the log file hasn't been written yet.

Frontend:
  - LogPanel.svelte with tabs, auto-refresh checkbox, size/mtime meta,
    and a <pre> body that snaps to bottom on refresh unless the user
    has scrolled up.
  - 7 unit tests covering path safety (including .. traversal) and
    tail behavior (missing file → null, small file, oversized file
    → truncated, trailing newline handling).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:50:19 -04:00
joshandClaude Opus 4.7 d21e8187ff Roster parser: header-driven fixed-width, handle Plutonium T5's 9 columns
The raw hex dump showed Plutonium T5's `status` reply is a 9-column
format, not the 5-column shape the parser assumed:

  num score ping guid    name           lastmsg address              qport rate
  --- ----- ---- ------- ---------------- ------- --------------------- ----- -----
    0  3040   36 2741626 JoshWright^7          0 68.235.46.100:28960  -14733 25000

Three bugs in the old regex: it required 16+ hex chars for guid (T5
uses 7-digit decimal IDs), it expected name to run to end-of-line
(4 trailing columns get glued on), and it left Plutonium color codes
(^0..^9) in the rendered name.

Rewrote parsePlayerRoster to detect column boundaries from the header
line word positions instead — column N runs from word N's start to
word N+1's start, matching how CoD's status output actually lays out
(values borrow the padding between adjacent columns). Same code path
handles the old 5-column format and the T5 9-column format cleanly.

stripColorCodes() removes ^\d sequences from names in both dialects.
PlayerRoster component only appends `…` when the guid is actually
long enough to need truncation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:37:25 -04:00
joshandClaude Opus 4.7 d8ed0d2572 Always show RCON reply on server detail — raw dump + parsed values
Player count says 1 but roster shows 0 for t5_ascension_1 — a parser
mismatch (likely Plutonium's status body uses Steam IDs instead of the
16+ char hex GUIDs my roster regex expects). To diagnose these without
guessing, always keep the raw RCON reply around and expose it on the
detail page.

rconStatusResult now includes rawReply on success too (used to only
persist it on bad-reply). Worker + probe-rcon endpoint persist it to
last_reply_snippet on every tick, so the byte-level truth about the
reply survives page loads.

New "RCON reply" panel on the detail page (visible whenever we have a
snippet or an endpoint) shows:
  - Probe RCON button (was buried inside the offline-only diagnostic
    panel; now accessible when healthy too)
  - Parsed values in a grid: endpoint, players, map, gametype, roster
    row count, last seen — so mismatches between "what the parser
    extracted" and "what the reply actually said" are one glance apart
  - Raw hex+ASCII dump of the last reply so the operator can read
    literal player rows and see why the roster parser gave 0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:29:32 -04:00
joshandClaude Opus 4.7 57746eef19 Use status for the RCON probe (Plutonium doesn't wrap-recognize getstatus)
The hex dump surfaced Plutonium's actual reply:
  ff ff ff ff 70 72 69 6e 74 0a  ....print.
  55 6e 6b 6e 6f 77 6e 20 63 6f  Unknown co
  6d 6d 61 6e 64 20 22 67 65 74  mmand "get
  73 74 61 74 75 73 22 0a        status".

Auth was fine — Plutonium just doesn't recognize `getstatus` as an rcon
subcommand. In Q3-derived engines, `getstatus` is an anonymous query
(no rcon wrapper); we were sending `rcon <password> getstatus`, which
made Plutonium treat it as an unknown subcommand.

Switch rconStatusResult to use `status`, same command rconGetPlayerCount
has been using successfully all along. The response is a `print` body
with a `key: value` header (`map: mp_kino\n...`) followed by the
scoreboard, not the `\info\` line the statusResponse dialect uses.

New parseStatusHeader extracts key:value dvars from the header, stopping
at the `--- ---` separator (or the first player row) so guids and names
can't leak into the metadata. rconStatusResult now merges header values
with the statusResponse info line so both dialects produce map/gametype
in the RconStatus shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:19:26 -04:00
joshandClaude Opus 4.7 8affb11b57 Surface raw RCON reply bytes + Probe RCON button
"unparseable reply (34 bytes)" told us Plutonium answered but hid the
actual bytes, forcing another guess-and-check round. rconStatusResult
now emits a formatRawReply() hex + ASCII dump of the raw UDP payload on
every bad-reply, persisted in a new last_reply_snippet column so it
survives page loads.

Wrong-password detection widens beyond the "bad rcon" phrasing to
"(bad|invalid|wrong)…(rcon|password|pass)"; print-body replies that
don't match get their first non-header line surfaced verbatim so at
least *some* server-supplied text reaches the UI.

Probe RCON button (POST /api/servers/[name]/probe-rcon) fires the same
code path as the periodic sweep on demand, cuts the RCON iteration loop
from 30s per attempt to sub-second, and refreshes the header/roster/
health when it succeeds. Snippet <details> auto-opens on bad-reply so
the bytes are one glance away.

migrateServers() picks up the new column automatically via the
introspection path added last commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:12:50 -04:00
joshandClaude Opus 4.7 a5b9733bd0 Auto-migrate any missing servers column, not just recent additions
Legacy cherry.db files created before commit 854f553 lack the game_port
column (it was added via CREATE TABLE which is a no-op on existing
tables). Users upgrading through several versions were left with a
partial schema, and setServerField('game_port', ...) crashed with
"no such column: game_port" — which the worker's generic catch mislabeled
as an RCON send-error in the UI.

Replaces the hardcoded post-v0.1 migration list with an introspection-
based one: PRAGMA table_info(servers), diff against the desired column
set, ADD COLUMN for anything missing. Covers base columns too (komodo_
server, game_port, rcon_host, last_players, last_checked_at) so legacy
DBs from any past version heal themselves on boot.

Verified manually: created a 3-column legacy servers table, booted the
app, all 13 desired columns present after PRAGMA re-check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 11:03:50 -04:00
joshandClaude Opus 4.7 15ea2ddfce extractUdpPort: handle Komodo's newline-string ports form
Real Komodo GetDeployment responses render config.ports as a single
newline-delimited string ("28963:28963/udp\n28963:28963/tcp\n"), not as
the array shapes we already handled. The walker only descended into
arrays and objects, so the string was skipped entirely and every Komodo
probe on a real deployment fell through to no-udp-in-response.

When the walker hits a value under a ports-keyed field that's a string,
split it on \r\n , ; and treat the resulting parts as a preferred string
array — the existing "host:container/udp" and "container/udp" regexes
handle each part.

Fixture pulled verbatim from the t5-nacht-1 response the fleet UI
surfaced. Also covers single-line, host-network (no host prefix), and
TCP-only-shouldn't-match cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 10:59:07 -04:00
joshandClaude Opus 4.7 1e9d08f27e Diagnose network button: raw TCP probe matrix
The Komodo probe answers a specific question (can we resolve a port from
Komodo's API), but leaves ambiguity when it fails: is DNS broken, is the
route blocked, is the target port unreachable, or is the target refusing?
A 10-second timeout looks the same for all of those.

Adds a second button, "diagnose network", that fires parallel raw TCP
connect attempts (no HTTP, no auth, no bytes sent) at a fixed matrix of
candidate hosts against the configured Komodo port:

  - 127.0.0.1  (sanity — container loopback)
  - host.docker.internal  (bridge-gateway alias)
  - 172.17.0.1  (raw default bridge gateway)
  - the KOMODO_ADDRESS host (if different)
  - RCON_HOST (if different)

Each row shows resolved IP, outcome (connected/refused/timeout/dns-fail/
unreachable/error), elapsed ms, and detail. Together they pinpoint which
network layer the traffic dies at without guessing.

New helper: src/lib/server/integrations/netProbe.ts — DNS lookup + net.
createConnection with explicit timeout, classifying errors by errno
(ECONNREFUSED, EHOSTUNREACH, ENOTFOUND). Runs trials in parallel so the
whole diagnostic completes in one probe-timeout budget.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 09:48:27 -04:00
joshandClaude Opus 4.7 08de5a4064 Unwrap fetch errors + show URL in Komodo probe result
The probe surfaced "unreachable: This operation was aborted" — technically
correct (an AbortController fired the fetch timeout) but useless for
diagnosing whether it's a wrong URL, blocked route, or firewall. Node's
fetch also hides real network causes (ECONNREFUSED, ENOTFOUND,
EHOSTUNREACH) under `.cause`, and the default message just says
"fetch failed".

describeFetchError() now unwraps AbortError to "timed out after 10000ms
dialing <url>" and pulls `.cause.code/.cause.message` for network
failures. KomodoPortResult gains a `url` field, always populated, so the
probe panel can surface the exact URL we dialed in a code block —
operators can then `curl` it from the container to confirm reachability.

komodoRestart benefits from the same unwrapping since it uses the same
komodoCall() transport.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 09:28:15 -04:00
joshandClaude Opus 4.7 1d05f5df1b Structured Komodo port diagnostics + probe button
komodoResolveGamePort silently returned null for six distinct failure
modes (unconfigured, no-name, unreachable, http-error, unparseable,
no-udp-in-response), which made "no game_port — Komodo did not return
one" indistinguishable from an auth failure or an unrecognized response
shape. Adds komodoResolvePortDetailed returning a discriminated union
with the failure reason, HTTP status, and a truncated response snippet.

extractUdpPort broadens to cover four more shapes seen in the wild:
extra host-port field names (HostPort/hostPort/host_port/PublicPort),
`type` as a protocol alias, host-network strings ("28962/udp"), and the
docker-inspect container-keyed form ({ "28960/udp": [{ HostPort }] }).
Arrays reached via a ports-flavored key now win over accidental matches
elsewhere in the tree.

New POST /api/servers/[name]/probe-komodo triggers a fresh lookup,
caches the port on success, and returns the structured result. The RCON
diagnostic panel gains a Probe Komodo button (shown when endpoint is
unresolved and Komodo is configured) that renders the snippet inline —
so unrecognized shapes surface directly instead of vanishing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 09:21:36 -04:00
joshandClaude Opus 4.7 7b4e6e0565 Surface structured RCON failure reasons in the fleet UI
rconGetStatus previously collapsed at least five distinct failure modes
(no password, no port, socket error, timeout, bad reply) into a single
null return, so the UI could only say "rcon unreachable" — useless when
diagnosing a real Alcatraz outage.

Refactored to rconStatusResult() returning a discriminated union with a
RconFailure reason and human-facing detail. The worker persists these as
"<reason>:<detail>" in last_error (no schema change). /api/servers now
returns the derived endpoint (host:port) per server plus a top-level
integrations block (rcon/komodo configured, timeout, default host).

Server detail page gains an RCON diagnostic panel when health is not
online: shows the exact endpoint being dialed, integration status,
timeout, a color-coded reason tag, the detail, and a targeted fix hint.
Wrong-password replies are detected via "bad rcon" text in the print
body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 09:06:27 -04:00
joshandClaude Opus 4.7 9bff47a9a4 Perk-a-cola redesign + surface RCON map/gametype/roster/health
Themes the fleet manager around the Electric Cherry perk-a-cola from Mob of
the Dead — cherry-red art-deco label with electric cyan discharge. New
design tokens (Alfa Slab One + Inter + JetBrains Mono, self-hosted), full
redesign of layout/dashboard/cards/detail page, tokens pass on
deployments/betas/pipeline/beta/diff, cherry+lightning favicon.

Lights up server data that RCON was already fetching but the UI dropped.
parsePlayerRoster() extracts num/score/ping/guid/name from both status
dialects. Worker persists last_map/last_gametype/last_roster/last_seen_at/
last_error (new columns with idempotent boot-time ALTERs) and emits them
over SSE. Fleet cards now show health dot, map · gametype, top-3 roster
preview, and last-seen. Detail page adds a sortable full roster with
distinct empty-lobby vs offline states. New health classifier
(online/stale/offline) drives the visual state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-12 08:20:54 -04:00
josh 854f5532a9 Replace IW4MAdmin with direct RCON for player counts
IW4M kept hitting a network wall (container -> host LAN IP -> IW4M
loopback bind) that we couldn't cleanly punch through, and the two-minute
!rt token added enough operational friction that the pause-all-deployments
gate never got a fair test. RCON gives us the same fact — how many
players are on a server right now — direct from each game server over
UDP, with no session, no token, no proxy.

- Add integrations/rcon.ts: dgram UDP client. rconGetPlayerCount() and
  rconGetStatus() parse both `status` (print + scoreboard) and
  `statusResponse` (info line + rows) dialects. Timeout via
  RCON_TIMEOUT_MS.
- Add komodoResolveGamePort() + extractUdpPort(). Reads Komodo's read
  API for a server, walks both docker-compose-style port objects and
  docker-run-style "host:container/udp" strings, caches into
  servers.game_port on first success.
- Replace pipelineReady() guard in sweep with a simple
  integrations-configured check. Same held/failed/manual outcomes,
  but keyed on RCON+Komodo instead of IW4M+auth-state.
- Worker's status refresh fans out per-server over RCON at concurrency=4
  so a 15-server fleet doesn't spike UDP.
- Schema: drop iw4m_match/iw4m_value, add game_port + rcon_host.
- Delete every auth artifact: TokenModal, auth store, auth-state module,
  /api/auth/token, /api/events/auth, iw4m.test.ts. Layout no longer
  mounts the modal, pipeline store no longer tracks a frozen state.
- README + .env.example + docker-compose.yml + komodo/stack.toml swap
  IW4M_* env for RCON_PASSWORD / RCON_HOST / RCON_TIMEOUT_MS. Same
  host.docker.internal:host-gateway extra_hosts entry, repurposed
  for RCON reach.
- Tests: add rcon.test.ts and komodo.test.ts, update sweep.test.ts to
  drop the auth-freeze case. 23/23 passing.
2026-09-12 01:33:06 -04:00