initial commit

This commit is contained in:
SowinskiBraeden committed 2026-07-05 16:54:59 -07:00
commit ce8f719a05
106 files changed
+24584

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
node_modules
**/node_modules
**/dist
.env
.env.*
!.env.example
.git
coverage
*.log
+52
View File
@@ -0,0 +1,52 @@
# --- Core ---
# Postgres connection string. Matches docker-compose.yml defaults.
DATABASE_URL=postgresql://reforger:reforger@127.0.0.1:5433/reforger_panel
# Random string, at least 32 characters. Used to sign OAuth state.
SESSION_SECRET=change-me-to-a-long-random-string-1234
# API listen port and the origin the web app is served from (CORS + OAuth redirects).
PORT=3001
WEB_ORIGIN=http://localhost:5173
NODE_ENV=development
# --- Discord OAuth ---
# Create an application at https://discord.com/developers/applications,
# add the redirect URI below under OAuth2 -> Redirects.
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=http://localhost:3001/api/auth/discord/callback
# Your Discord user ID. This account is auto-assigned the "owner" role at login.
OWNER_DISCORD_ID=
# Local development only: set to true to enable POST /api/auth/dev-login,
# which signs you in as a fake owner without Discord credentials.
# Hard-disabled when NODE_ENV=production.
DEV_AUTH_BYPASS=false
# --- Reforger Workshop API (backend-only, never called from the browser) ---
REFORGER_WORKSHOP_API_BASE_URL=https://api.reforgermods.net
# --- Pterodactyl (Client API, not Application API) ---
# Leave USE_MOCK_PTERODACTYL=true to run everything locally with mock data.
PTERODACTYL_BASE_URL=
# Create under Account Settings -> API Credentials in Pterodactyl.
PTERODACTYL_CLIENT_API_KEY=
# The short server identifier from the Pterodactyl server URL, e.g. "1a2b3c4d".
PTERODACTYL_SERVER_ID=
USE_MOCK_PTERODACTYL=true
# --- Reforger config import ---
# Path of the server's config.json in the Pterodactyl file manager. Imported
# read-only to populate the Configuration pages, server name, and max players.
REFORGER_CONFIG_PATH=/config.json
REFORGER_CONFIG_SYNC_INTERVAL_SECONDS=300
# --- Reforger log ingestion ---
# Recommended: set REFORGER_LOG_DIRECTORY (e.g. /profile/logs) and the panel
# follows the newest logs_* dated subfolder automatically on every sync.
# REFORGER_ADMIN_LOG_PATH pins one exact file and overrides discovery.
REFORGER_ADMIN_LOG_PATH=
REFORGER_LOG_DIRECTORY=
REFORGER_LOG_FILE_PATTERN=console.log
REFORGER_LOG_POLL_INTERVAL_SECONDS=20
REFORGER_LOG_MAX_DOWNLOAD_BYTES=2097152
REFORGER_LOG_STALE_AFTER_SECONDS=90
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
build/
*.log
.env
.env.local
.env.*.local
coverage/
.DS_Store
*.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
build/
coverage/
apps/api/drizzle/
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
+30
View File
@@ -0,0 +1,30 @@
# Build stage: compile the API bundle and the web app.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build
# Runtime stage: production dependencies + built artifacts only.
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
COPY --from=build /app/apps/api/dist apps/api/dist
COPY --from=build /app/apps/api/drizzle apps/api/drizzle
COPY --from=build /app/apps/web/dist apps/web/dist
ENV WEB_DIST_PATH=/app/apps/web/dist
WORKDIR /app/apps/api
EXPOSE 3001
# Apply migrations, ensure the server row exists, then start.
CMD ["sh", "-c", "node dist/migrate.js && node dist/seed.js && node dist/index.js"]
+183
View File
@@ -0,0 +1,183 @@
# Reforger Panel
A private, purpose-built control panel for one community Arma Reforger training/recruiting server. It sits **on top of Pterodactyl** — Pterodactyl (and Wings) keep running the game container, files, backups, and allocations; this panel is the curated management experience for the owner and trusted crew admins.
Not included by design: billing, public sign-up, multi-tenancy, arbitrary file management, raw console access, or anything that replaces Pterodactyl.
## Architecture
```text
Browser (React SPA)
│ same-origin /api only — no upstream credentials ever reach the browser
Panel API (Express + TypeScript)
├─ Discord OAuth → local users, sessions (Postgres), roles
├─ PostgreSQL (Drizzle ORM)
├─ Workshop client → https://api.reforgermods.net (backend-only)
├─ GameServerProvider abstraction
│ ├─ PterodactylProvider (Client API: status, resources, power, read-only files)
│ └─ MockGameServerProvider (full local dev without credentials)
└─ Log ingestion worker: Pterodactyl log download → parser → players/sessions/events
```
Monorepo layout:
```text
apps/api Express API, Drizzle schema/migrations, ingestion worker, tests
apps/web Vite + React + Tailwind dashboard
packages/shared Roles/capabilities, DTO types, Reforger config model
```
## Quick start (mock mode, no Pterodactyl or Discord needed)
```bash
cp .env.example .env # defaults are fine for local dev
# set DEV_AUTH_BYPASS=true in .env to log in without Discord
docker compose up -d # Postgres on 127.0.0.1:5433
npm install
npm run db:migrate
npm run db:seed # creates the server row (real data is imported from the server)
npm run dev # API on :3001, web on :5173
```
Open http://localhost:5173 and use **Local development login** (requires `DEV_AUTH_BYPASS=true`; the endpoint refuses to exist in production). Mock mode serves a generated `console.log`, so within ~20 s the dashboard shows players, sessions, and events produced by the real ingestion pipeline.
Useful scripts: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`, `npm run format`, `npm run db:generate` (new migration after schema changes).
## Environment variables
See `.env.example` for the full annotated list. Highlights:
| Variable | Purpose |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `DATABASE_URL`, `SESSION_SECRET` | Postgres + cookie/state signing (32+ chars) |
| `DISCORD_CLIENT_ID/SECRET`, `DISCORD_REDIRECT_URI` | Discord OAuth app |
| `OWNER_DISCORD_ID` | This Discord account is auto-assigned `owner` at login |
| `DEV_AUTH_BYPASS` | Local-only fake owner login; rejected when `NODE_ENV=production` |
| `REFORGER_WORKSHOP_API_BASE_URL` | Workshop metadata API (backend-only) |
| `PTERODACTYL_BASE_URL`, `PTERODACTYL_CLIENT_API_KEY`, `PTERODACTYL_SERVER_ID` | Client API (not Application API) |
| `USE_MOCK_PTERODACTYL` | `true` = run entirely against the in-process mock |
| `REFORGER_CONFIG_PATH`, `REFORGER_CONFIG_SYNC_INTERVAL_SECONDS` | Where the server's config.json lives; imported read-only at startup + interval |
| `REFORGER_LOG_DIRECTORY`, `REFORGER_LOG_FILE_PATTERN` | Recommended: directory listed each sync; newest `logs_*` subfolder is followed |
| `REFORGER_ADMIN_LOG_PATH` | Optional: pins one exact log file, overriding directory discovery |
| `REFORGER_LOG_POLL_INTERVAL_SECONDS` / `_MAX_DOWNLOAD_BYTES` / `_STALE_AFTER_SECONDS` | Ingestion pacing, download cap, staleness threshold |
Environment is validated with zod at startup; the process refuses to boot with missing/contradictory settings (e.g. real mode without Pterodactyl credentials).
## Users, roles, and enforcement
Roles live in the panel database (not Discord roles). New users default to `viewer`; the account matching `OWNER_DISCORD_ID` becomes `owner` automatically. The owner manages roles under **Settings**.
| Capability | owner | server_admin | mission_lead | viewer |
| -------------------------------------- | :---: | :----------: | :----------: | :----: |
| View dashboard/server/players/activity | ✓ | ✓ | ✓ | ✓ |
| Start / stop | ✓ | ✓ | — | — |
| Restart | ✓ | ✓ | ✓ | — |
| Operational health diagnostics | ✓ | ✓ | — | — |
| Manual log sync | ✓ | — | — | — |
| User/role management, settings | ✓ | — | — | — |
Enforcement is backend middleware (`requireAuth` + `requireCapability`); the frontend only hides buttons. Sessions are 7-day HTTP-only cookies (`SameSite=Lax`, `Secure` in production), stored in Postgres as SHA-256 hashes and revocable server-side. State-changing requests additionally require a custom `X-CSRF-Protection` header and pass an Origin allowlist; OAuth uses a signed `state` cookie. Auth, power, and sync endpoints are rate limited.
## Log ingestion
Flow: panel backend → Pterodactyl Client API (signed download URL, streamed with a byte cap) → parser → Postgres. Wings is never touched directly and the browser never downloads logs.
- **Scheduler** — one poll loop per server (default 20 s), per-server lock so syncs never overlap, exponential backoff (up to 8×) after consecutive failures, graceful shutdown that waits for in-flight syncs. Starts only when a provider and log path are configured. Owner can trigger `POST /api/servers/:slug/logs/sync` manually.
- **Cursoring** — a `log_cursors` row per (server, path) stores byte offset, file fingerprint (hash of the first line when visible), hash of the last processed line, and any partial trailing line.
- _First sync_: only a bounded tail (512 KiB) is imported, never full history.
- _Append_: only bytes after the cursor are parsed; a stored partial line is prepended.
- _Rotation/truncation/replacement_: detected via size decrease, fingerprint change, or a continuity mismatch at the cut point → cursor resets and a bounded tail of the new file is processed.
- _Partial trailing lines_ are never parsed; they wait for the next sync.
- _Large files_: downloads are capped (`REFORGER_LOG_MAX_DOWNLOAD_BYTES`); if the file grew past the window, the gap is noted and a bounded tail is processed. Retrieval is isolated in `pterodactyl-log-source.ts` so range/tail requests can be added without touching parsing.
- **Deduplication** — events carry a unique `(server_id, source_log_path, sha256(raw line))` key enforced by a Postgres unique index, so rotation boundaries and cursor resets cannot double-import.
- **Sessions** — connect opens a session; disconnect closes it with duration; a reconnect without a disconnect closes the stale session (`missed_disconnect`); a fresh server start closes all open sessions (`server_restart`) and emits `server_restart_detected`.
### Supported log events and known limitations
Recognized today (patterns centralized in `apps/api/src/modules/reforger-logs/parser/patterns.ts`, verified against real server logs):
- `Player #N Name (ip:port) connected` (BattlEye wrapper) → `player_connected`
- `Player #N Name disconnected``player_disconnected`
- `Player #N Name - BE GUID: …` → merged into the player as a stable identity
- `Authenticated player: … identityId=<uuid> name=<name>` (BACKEND channel) → engine-level identity, available even without BattlEye
- `Game successfully created` / `Server is ready to accept connections``server_started`
When both identity lines appear for a player, the first one wins and the other is ignored, so a player is never split into duplicates.
Limitations to keep in mind:
- **Patterns can change between game versions.** They were validated against a live 2026 server log, but Bohemia can change the format; adjust `patterns.ts` (each pattern has a fixture-backed test).
- **Player identity**: when logs provide no GUID, players are matched by display name only — two people with the same name would merge, and renames create a new player record. The GUID line, when present, upgrades matching to a stable ID.
- **Timestamps** in Reforger logs are time-of-day only; the date comes from the `Log started` header or falls back to the sync date, with midnight-rollover and future-timestamp guards. Cross-midnight logs without a header can be off by a day in pathological cases.
- Player data is **log-polled, not real-time** — the UI always shows "last synchronized" and flags staleness rather than pretending to be live.
### Finding the log location in Pterodactyl
Open your server in Pterodactyl → **Files**. Reforger writes a new dated folder per boot (e.g. `/profile/logs/logs_2026-07-04_12-54-04/console.log`). Set `REFORGER_LOG_DIRECTORY` to the parent (e.g. `/profile/logs`) — the panel lists it on every sync and follows the newest `logs_*` folder automatically, so restarts need no reconfiguration. `REFORGER_ADMIN_LOG_PATH` exists to pin one exact file and overrides discovery.
### Configuration import
The panel downloads the server's real `config.json` (default `/config.json`, override with `REFORGER_CONFIG_PATH`) at startup, every `REFORGER_CONFIG_SYNC_INTERVAL_SECONDS`, and on demand via **Configurations → Sync from server** (owner/server admin). Each change creates a new `ConfigRevision`, and the server's displayed name and max players always come from the imported config — nothing is hand-seeded. Credentials in config.json (admin password, RCON password) are never copied into the panel's model.
## Deploying privately (you + friends)
The API serves the built web app itself in production, so the whole panel is one container plus Postgres:
```bash
cp .env.example .env # set Discord creds, OWNER_DISCORD_ID, Pterodactyl vars,
# a fresh SESSION_SECRET (openssl rand -base64 32),
# USE_MOCK_PTERODACTYL=false, DEV_AUTH_BYPASS=false
docker compose -f docker-compose.prod.yml up -d --build
```
Then put HTTPS in front of port 3001 — any of:
- **Tailscale** (easiest for a private group): `tailscale serve https / http://localhost:3001`, share the tailnet with your friends.
- **Caddy**: `reverse_proxy localhost:3001` with a domain (automatic HTTPS).
- **nginx + certbot** if you already run it.
Finally set `WEB_ORIGIN` and `DISCORD_REDIRECT_URI` in `.env` to the public URL (e.g. `https://panel.example.com` and `https://panel.example.com/api/auth/discord/callback`), register that redirect URI in your Discord application, and restart the stack. Production mode enforces `Secure` cookies (HTTPS required), refuses `DEV_AUTH_BYPASS`, and requires Discord credentials at boot.
Access model for a private group: anyone with the URL can log in with Discord but lands as a **viewer** with read-only access; hand out **invite links** (Settings → Invites) to grant server admin / mission lead roles, and manage roles under Settings → Users.
## Connecting a real Pterodactyl server safely
1. In Pterodactyl, log in as a user that has access to **only** this game server (create a dedicated sub-user if needed).
2. Account Settings → API Credentials → create a **Client API** key. This scopes the panel to that user's servers — do not use an admin/Application API key.
3. Set `PTERODACTYL_BASE_URL`, `PTERODACTYL_CLIENT_API_KEY`, `PTERODACTYL_SERVER_ID` (the short identifier from the server URL), `USE_MOCK_PTERODACTYL=false`, and `REFORGER_ADMIN_LOG_PATH`.
4. Update the seeded server row if needed (the seed stores `PTERODACTYL_SERVER_ID` when present).
5. Restart the API and check **Settings → Integrations** and the dashboard's Operational health card.
The API key stays server-side; requests have 1030 s timeouts, size-capped downloads, and errors are sanitized (no key, no host, no stack traces) before storage or display.
## API surface
```text
GET /api/auth/me POST /api/auth/logout
GET /api/auth/discord GET /api/auth/discord/callback
POST /api/auth/dev-login (dev only)
GET /api/servers GET /api/servers/:slug
GET /api/servers/:slug/resources GET /api/servers/:slug/players
GET /api/servers/:slug/players/known GET /api/servers/:slug/activity
GET /api/servers/:slug/configuration GET /api/servers/:slug/mod-packs
POST /api/servers/:slug/power/{start,stop,restart}
POST /api/servers/:slug/logs/sync GET /api/servers/:slug/logs/health
GET /api/workshop/health GET /api/workshop/search?q=&page=&sort=
GET /api/workshop/mods/:id
GET /api/users PATCH /api/users/:id/role (owner only)
```
Errors are structured: `{ "error": { "code", "message", "requestId" } }`.
## Implemented vs scaffolded
**Implemented**: Discord OAuth + sessions + role enforcement, dashboard + server pages, provider abstraction with mock and real Pterodactyl Client API, power controls with per-role limits (audited to the activity feed, simulated in mock mode), Workshop health/search/detail proxy, read-only config preview + revision history, full log ingestion pipeline (scheduler, cursoring, rotation, dedupe, sessions), operational health card, owner user/role management, 63 tests.
**Scaffolded / later phases**: mod-pack editing and deployment ("Add to pack" is intentionally disabled), config generation/writing to the server (no file writes through Pterodactyl yet), config presets for mission leads, Discord-role sync, multi-server support (schema is ready; UI assumes one), historical playtime analytics.
**Recommended next steps**: (1) capture real `console.log` samples from your server and harden the parser fixtures; (2) mod-pack builder writing `ModPackRevision`s from Workshop search; (3) config generation producing a real `config.json` diff/preview from `ConfigRevision`, then a guarded deploy (file write + restart) for owner/server admin; (4) preset selection for mission leads; (5) session-history charts from `player_sessions`.
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? 'postgresql://reforger:reforger@127.0.0.1:5433/reforger_panel',
},
});
+152
View File
@@ -0,0 +1,152 @@
CREATE TABLE "config_revisions" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"created_by_user_id" text,
"version" integer NOT NULL,
"summary" text NOT NULL,
"config" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "log_cursors" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"log_path" text NOT NULL,
"file_fingerprint" text,
"last_byte_offset" bigint DEFAULT 0 NOT NULL,
"last_line_hash" text,
"partial_trailing_line" text,
"last_event_timestamp" timestamp with time zone,
"last_successful_sync_at" timestamp with time zone,
"last_error_at" timestamp with time zone,
"last_error_message" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "mod_pack_revisions" (
"id" text PRIMARY KEY NOT NULL,
"mod_pack_id" text NOT NULL,
"version" integer NOT NULL,
"notes" text,
"mods" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "mod_packs" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"name" text NOT NULL,
"description" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "player_sessions" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"player_id" text NOT NULL,
"connected_at" timestamp with time zone NOT NULL,
"disconnected_at" timestamp with time zone,
"duration_seconds" integer,
"disconnect_reason" text,
"source_log_path" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "players" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"external_player_id" text,
"display_name" text NOT NULL,
"first_seen_at" timestamp with time zone NOT NULL,
"last_seen_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_activity" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"actor_user_id" text,
"action" text NOT NULL,
"summary" text NOT NULL,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_events" (
"id" text PRIMARY KEY NOT NULL,
"server_id" text NOT NULL,
"event_type" text NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"player_id" text,
"player_session_id" text,
"summary" text NOT NULL,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"source_log_path" text NOT NULL,
"source_line_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "servers" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"name" text NOT NULL,
"provider_type" text DEFAULT 'pterodactyl' NOT NULL,
"pterodactyl_server_id" text,
"status" text DEFAULT 'unknown' NOT NULL,
"max_players" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"discord_id" text NOT NULL,
"username" text NOT NULL,
"display_name" text,
"avatar_url" text,
"role" text DEFAULT 'viewer' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "config_revisions" ADD CONSTRAINT "config_revisions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "config_revisions" ADD CONSTRAINT "config_revisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "log_cursors" ADD CONSTRAINT "log_cursors_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mod_pack_revisions" ADD CONSTRAINT "mod_pack_revisions_mod_pack_id_mod_packs_id_fk" FOREIGN KEY ("mod_pack_id") REFERENCES "public"."mod_packs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mod_pack_revisions" ADD CONSTRAINT "mod_pack_revisions_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "mod_packs" ADD CONSTRAINT "mod_packs_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_sessions" ADD CONSTRAINT "player_sessions_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_sessions" ADD CONSTRAINT "player_sessions_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_activity" ADD CONSTRAINT "server_activity_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_activity" ADD CONSTRAINT "server_activity_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_events" ADD CONSTRAINT "server_events_server_id_servers_id_fk" FOREIGN KEY ("server_id") REFERENCES "public"."servers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_events" ADD CONSTRAINT "server_events_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "server_events" ADD CONSTRAINT "server_events_player_session_id_player_sessions_id_fk" FOREIGN KEY ("player_session_id") REFERENCES "public"."player_sessions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "config_revisions_server_version_unique" ON "config_revisions" USING btree ("server_id","version");--> statement-breakpoint
CREATE UNIQUE INDEX "log_cursors_server_path_unique" ON "log_cursors" USING btree ("server_id","log_path");--> statement-breakpoint
CREATE UNIQUE INDEX "mod_pack_revisions_pack_version_unique" ON "mod_pack_revisions" USING btree ("mod_pack_id","version");--> statement-breakpoint
CREATE INDEX "player_sessions_server_open_idx" ON "player_sessions" USING btree ("server_id","disconnected_at");--> statement-breakpoint
CREATE INDEX "player_sessions_player_idx" ON "player_sessions" USING btree ("player_id");--> statement-breakpoint
CREATE UNIQUE INDEX "players_server_external_id_unique" ON "players" USING btree ("server_id","external_player_id");--> statement-breakpoint
CREATE INDEX "players_server_name_idx" ON "players" USING btree ("server_id","display_name");--> statement-breakpoint
CREATE INDEX "server_activity_server_created_idx" ON "server_activity" USING btree ("server_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "server_events_dedupe_unique" ON "server_events" USING btree ("server_id","source_log_path","source_line_hash");--> statement-breakpoint
CREATE INDEX "server_events_server_occurred_idx" ON "server_events" USING btree ("server_id","occurred_at");--> statement-breakpoint
CREATE UNIQUE INDEX "servers_slug_unique" ON "servers" USING btree ("slug");--> statement-breakpoint
CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "users_discord_id_unique" ON "users" USING btree ("discord_id");
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "invites" (
"id" text PRIMARY KEY NOT NULL,
"code" text NOT NULL,
"role" text DEFAULT 'viewer' NOT NULL,
"created_by_user_id" text,
"expires_at" timestamp with time zone NOT NULL,
"used_by_user_id" text,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invites" ADD CONSTRAINT "invites_used_by_user_id_users_id_fk" FOREIGN KEY ("used_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "invites_code_unique" ON "invites" USING btree ("code");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+20
View File
@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1783229645037,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1783279251170,
"tag": "0001_invites",
"breakpoints": true
}
]
}
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@reforger-panel/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch --env-file-if-exists=../../.env src/index.ts",
"build": "tsup",
"start": "node --env-file-if-exists=../../.env dist/index.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx --env-file-if-exists=../../.env src/db/migrate.ts",
"db:seed": "tsx --env-file-if-exists=../../.env src/db/seed.ts"
},
"dependencies": {
"@reforger-panel/shared": "*",
"cookie": "^1.0.2",
"drizzle-orm": "^0.44.0",
"express": "^5.1.0",
"pg": "^8.16.0",
"pino": "^9.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"@types/pg": "^8.15.0",
"@types/supertest": "^6.0.0",
"drizzle-kit": "^0.31.0",
"pino-pretty": "^13.0.0",
"supertest": "^7.1.0",
"tsup": "^8.5.0",
"tsx": "^4.20.0",
"vitest": "^3.2.0"
}
}
+166
View File
@@ -0,0 +1,166 @@
import express from 'express';
import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { ApiErrorBody } from '@reforger-panel/shared';
import type { Env } from './env.js';
import type { Db } from './db/client.js';
import { ApiError } from './lib/errors.js';
import type { Logger } from './lib/logger.js';
import { createAuthRouter } from './modules/auth/auth-routes.js';
import { csrfProtection, sessionResolver } from './modules/auth/auth-middleware.js';
import type { SessionService } from './modules/auth/session-service.js';
import type { ConfigSyncService } from './modules/config/config-sync.js';
import type { ServerModsService } from './modules/config/mods-service.js';
import type { PerformanceSettingsService } from './modules/config/performance-service.js';
import type { ResourceHistoryService } from './modules/servers/resource-history.js';
import type { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
import type { GameServerProvider } from './modules/pterodactyl/types.js';
import type { LogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
import { createServerRouter } from './modules/servers/server-routes.js';
import type { ServerService } from './modules/servers/server-service.js';
import { createInviteRouter } from './modules/invites/invite-routes.js';
import { createUserRouter } from './modules/users/user-routes.js';
import { createWorkshopRouter } from './modules/workshop/workshop-routes.js';
import type { WorkshopClient } from './modules/workshop/workshop-client.js';
export type AppDeps = {
env: Env;
logger: Logger;
db: Db;
sessions: SessionService;
servers: ServerService;
provider: GameServerProvider;
workshop: WorkshopClient;
scheduler: IngestionScheduler | null;
resolveLogPath: LogPathResolver | null;
configSync: ConfigSyncService | null;
mods: ServerModsService | null;
performance: PerformanceSettingsService | null;
resourceHistory: ResourceHistoryService | null;
missions: MissionCatalog | null;
};
export function createApp(deps: AppDeps) {
const { env, logger } = deps;
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '64kb' }));
// Request id + minimal structured request logging.
app.use((req, res, next) => {
const requestId = randomUUID();
res.locals.requestId = requestId;
res.setHeader('X-Request-Id', requestId);
res.on('finish', () => {
logger.debug(
{ requestId, method: req.method, path: req.path, status: res.statusCode },
'request',
);
});
next();
});
// Safe CORS default: only the configured web origin, with credentials.
const allowedOrigin = env.WEB_ORIGIN.replace(/\/$/, '');
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && origin.replace(/\/$/, '') === allowedOrigin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-CSRF-Protection');
res.status(204).end();
return;
}
next();
});
app.use(sessionResolver(deps.sessions));
// CSRF protection for all state-changing requests. The OAuth callback is a
// GET and is protected by the signed state parameter instead.
const csrf = csrfProtection([allowedOrigin, `http://localhost:${env.PORT}`]);
app.use('/api', (req, res, next) => {
if (req.method === 'GET' || req.method === 'HEAD') {
next();
return;
}
csrf(req, res, next);
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
app.use('/api/auth', createAuthRouter(env, deps.sessions));
app.use('/api/users', createUserRouter(deps.db));
app.use('/api/invites', createInviteRouter(deps.db));
app.use(
'/api/servers',
createServerRouter({
service: deps.servers,
provider: deps.provider,
scheduler: deps.scheduler,
resolveLogPath: deps.resolveLogPath,
configSync: deps.configSync,
mods: deps.mods,
performance: deps.performance,
resourceHistory: deps.resourceHistory,
missions: deps.missions,
workshop: deps.workshop,
staleAfterSeconds: env.REFORGER_LOG_STALE_AFTER_SECONDS,
mockMode: env.USE_MOCK_PTERODACTYL,
}),
);
app.use('/api/workshop', createWorkshopRouter(deps.workshop));
app.use('/api', (_req, _res, next) => {
next(ApiError.notFound('Unknown API route.'));
});
// Production: serve the built SPA from the same process (no separate web
// server needed). In development Vite serves the frontend instead.
const webDist = env.WEB_DIST_PATH || path.resolve(process.cwd(), '../web/dist');
const webIndex = path.join(webDist, 'index.html');
if (env.NODE_ENV === 'production' && existsSync(webIndex)) {
app.use(express.static(webDist, { index: false, maxAge: '1h' }));
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api')) {
next();
return;
}
res.sendFile(webIndex);
});
logger.info({ webDist }, 'serving web app from API process');
}
// Structured error responses; internals are logged, never sent to clients.
app.use((error: unknown, req: Request, res: Response, _next: NextFunction) => {
const requestId = String(res.locals.requestId ?? '');
if (error instanceof ApiError) {
const body: ApiErrorBody = {
error: { code: error.code, message: error.message, requestId },
};
res.status(error.status).json(body);
return;
}
logger.error(
{ requestId, path: req.path, err: error instanceof Error ? error.message : String(error) },
'unhandled error',
);
const body: ApiErrorBody = {
error: { code: 'INTERNAL_ERROR', message: 'Something went wrong.', requestId },
};
res.status(500).json(body);
});
return app;
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
export function createDb(databaseUrl: string) {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 10 });
const db = drizzle(pool, { schema });
return { db, pool };
}
export type Db = ReturnType<typeof createDb>['db'];
export { schema };
+17
View File
@@ -0,0 +1,17 @@
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import path from 'node:path';
import { createDb } from './client.js';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is not set');
process.exit(1);
}
// Run from apps/api (the npm scripts do this); migrations live in ./drizzle.
const migrationsFolder = path.resolve(process.cwd(), 'drizzle');
const { db, pool } = createDb(databaseUrl);
await migrate(db, { migrationsFolder });
await pool.end();
console.log('Migrations applied.');
+247
View File
@@ -0,0 +1,247 @@
import {
bigint,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
} from 'drizzle-orm/pg-core';
import { randomUUID } from 'node:crypto';
const id = () =>
text('id')
.primaryKey()
.$defaultFn(() => randomUUID());
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
const updatedAt = () =>
timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date());
export const users = pgTable(
'users',
{
id: id(),
discordId: text('discord_id').notNull(),
username: text('username').notNull(),
displayName: text('display_name'),
avatarUrl: text('avatar_url'),
role: text('role', { enum: ['owner', 'server_admin', 'mission_lead', 'viewer'] })
.notNull()
.default('viewer'),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('users_discord_id_unique').on(t.discordId)],
);
export const sessions = pgTable(
'sessions',
{
// sha256 hash of the cookie token; the raw token is never stored.
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
},
(t) => [index('sessions_user_id_idx').on(t.userId)],
);
export const invites = pgTable(
'invites',
{
id: id(),
code: text('code').notNull(),
role: text('role', { enum: ['owner', 'server_admin', 'mission_lead', 'viewer'] })
.notNull()
.default('viewer'),
createdByUserId: text('created_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
usedByUserId: text('used_by_user_id').references(() => users.id, { onDelete: 'set null' }),
usedAt: timestamp('used_at', { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [uniqueIndex('invites_code_unique').on(t.code)],
);
export const servers = pgTable(
'servers',
{
id: id(),
slug: text('slug').notNull(),
name: text('name').notNull(),
providerType: text('provider_type').notNull().default('pterodactyl'),
pterodactylServerId: text('pterodactyl_server_id'),
status: text('status').notNull().default('unknown'),
maxPlayers: integer('max_players'),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('servers_slug_unique').on(t.slug)],
);
export const serverActivity = pgTable(
'server_activity',
{
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
actorUserId: text('actor_user_id').references(() => users.id, { onDelete: 'set null' }),
action: text('action').notNull(),
summary: text('summary').notNull(),
metadata: jsonb('metadata').notNull().default({}),
createdAt: createdAt(),
},
(t) => [index('server_activity_server_created_idx').on(t.serverId, t.createdAt)],
);
export const modPacks = pgTable('mod_packs', {
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
status: text('status').notNull().default('draft'),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
export const modPackRevisions = pgTable(
'mod_pack_revisions',
{
id: id(),
modPackId: text('mod_pack_id')
.notNull()
.references(() => modPacks.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
notes: text('notes'),
mods: jsonb('mods').notNull().default([]),
createdByUserId: text('created_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
createdAt: createdAt(),
},
(t) => [uniqueIndex('mod_pack_revisions_pack_version_unique').on(t.modPackId, t.version)],
);
export const configRevisions = pgTable(
'config_revisions',
{
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
createdByUserId: text('created_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
version: integer('version').notNull(),
summary: text('summary').notNull(),
config: jsonb('config').notNull(),
createdAt: createdAt(),
},
(t) => [uniqueIndex('config_revisions_server_version_unique').on(t.serverId, t.version)],
);
export const logCursors = pgTable(
'log_cursors',
{
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
logPath: text('log_path').notNull(),
fileFingerprint: text('file_fingerprint'),
lastByteOffset: bigint('last_byte_offset', { mode: 'number' }).notNull().default(0),
lastLineHash: text('last_line_hash'),
partialTrailingLine: text('partial_trailing_line'),
lastEventTimestamp: timestamp('last_event_timestamp', { withTimezone: true }),
lastSuccessfulSyncAt: timestamp('last_successful_sync_at', { withTimezone: true }),
lastErrorAt: timestamp('last_error_at', { withTimezone: true }),
lastErrorMessage: text('last_error_message'),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('log_cursors_server_path_unique').on(t.serverId, t.logPath)],
);
export const players = pgTable(
'players',
{
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
// Stable identity from logs (e.g. a GUID) when available. Display names are
// NOT unique identities; see README for the fallback limitations.
externalPlayerId: text('external_player_id'),
displayName: text('display_name').notNull(),
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex('players_server_external_id_unique').on(t.serverId, t.externalPlayerId),
index('players_server_name_idx').on(t.serverId, t.displayName),
],
);
export const playerSessions = pgTable(
'player_sessions',
{
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
playerId: text('player_id')
.notNull()
.references(() => players.id, { onDelete: 'cascade' }),
connectedAt: timestamp('connected_at', { withTimezone: true }).notNull(),
disconnectedAt: timestamp('disconnected_at', { withTimezone: true }),
durationSeconds: integer('duration_seconds'),
disconnectReason: text('disconnect_reason'),
sourceLogPath: text('source_log_path').notNull(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
index('player_sessions_server_open_idx').on(t.serverId, t.disconnectedAt),
index('player_sessions_player_idx').on(t.playerId),
],
);
export const serverEvents = pgTable(
'server_events',
{
id: id(),
serverId: text('server_id')
.notNull()
.references(() => servers.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
playerId: text('player_id').references(() => players.id, { onDelete: 'set null' }),
playerSessionId: text('player_session_id').references(() => playerSessions.id, {
onDelete: 'set null',
}),
summary: text('summary').notNull(),
payload: jsonb('payload').notNull().default({}),
sourceLogPath: text('source_log_path').notNull(),
sourceLineHash: text('source_line_hash').notNull(),
createdAt: createdAt(),
},
(t) => [
// Event deduplication key: one event per (server, log file, line fingerprint).
uniqueIndex('server_events_dedupe_unique').on(t.serverId, t.sourceLogPath, t.sourceLineHash),
index('server_events_server_occurred_idx').on(t.serverId, t.occurredAt),
],
);
+47
View File
@@ -0,0 +1,47 @@
import { eq } from 'drizzle-orm';
import { createDb, schema } from './client.js';
// Seeds only the server row. Everything else (configuration, players,
// sessions, events, activity) comes from the real server: config revisions are
// imported from config.json and player data from log ingestion. No fake data.
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is not set');
process.exit(1);
}
const { db, pool } = createDb(databaseUrl);
const existing = await db
.select()
.from(schema.servers)
.where(eq(schema.servers.slug, 'training-server'));
const pterodactylServerId = process.env.PTERODACTYL_SERVER_ID || null;
if (existing[0]) {
if (pterodactylServerId && existing[0].pterodactylServerId !== pterodactylServerId) {
await db
.update(schema.servers)
.set({ pterodactylServerId })
.where(eq(schema.servers.id, existing[0].id));
console.log(`Updated pterodactylServerId to ${pterodactylServerId}.`);
} else {
console.log('Server "training-server" already exists; nothing to do.');
}
} else {
await db.insert(schema.servers).values({
slug: 'training-server',
// Placeholder until the first config.json import overwrites it.
name: 'Reforger Server',
providerType: 'pterodactyl',
pterodactylServerId,
status: 'unknown',
maxPlayers: null,
});
console.log('Seeded server row (name/maxPlayers will be imported from config.json).');
}
await pool.end();
console.log('Seed complete.');
+105
View File
@@ -0,0 +1,105 @@
import { z } from 'zod';
const booleanString = z
.enum(['true', 'false'])
.default('false')
.transform((v) => v === 'true');
const envSchema = z
.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3001),
WEB_ORIGIN: z.string().url().default('http://localhost:5173'),
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
/** Directory of the built web app; when it exists the API serves it. */
WEB_DIST_PATH: z.string().default(''),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
DISCORD_CLIENT_ID: z.string().default(''),
DISCORD_CLIENT_SECRET: z.string().default(''),
DISCORD_REDIRECT_URI: z.string().default('http://localhost:3001/api/auth/discord/callback'),
OWNER_DISCORD_ID: z.string().default(''),
DEV_AUTH_BYPASS: booleanString,
REFORGER_WORKSHOP_API_BASE_URL: z.string().url().default('https://api.reforgermods.net'),
PTERODACTYL_BASE_URL: z.string().default(''),
PTERODACTYL_CLIENT_API_KEY: z.string().default(''),
PTERODACTYL_SERVER_ID: z.string().default(''),
USE_MOCK_PTERODACTYL: booleanString,
REFORGER_CONFIG_PATH: z.string().default('/config.json'),
REFORGER_CONFIG_SYNC_INTERVAL_SECONDS: z.coerce.number().int().min(60).max(86400).default(300),
REFORGER_ADMIN_LOG_PATH: z.string().default(''),
REFORGER_LOG_DIRECTORY: z.string().default(''),
REFORGER_LOG_FILE_PATTERN: z.string().default(''),
REFORGER_LOG_POLL_INTERVAL_SECONDS: z.coerce.number().int().min(5).max(3600).default(20),
REFORGER_LOG_MAX_DOWNLOAD_BYTES: z.coerce
.number()
.int()
.min(64 * 1024)
.max(64 * 1024 * 1024)
.default(2 * 1024 * 1024),
REFORGER_LOG_STALE_AFTER_SECONDS: z.coerce.number().int().min(30).default(90),
})
.superRefine((env, ctx) => {
if (env.NODE_ENV === 'production' && env.DEV_AUTH_BYPASS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'DEV_AUTH_BYPASS must not be enabled in production',
path: ['DEV_AUTH_BYPASS'],
});
}
if (!env.USE_MOCK_PTERODACTYL) {
for (const key of [
'PTERODACTYL_BASE_URL',
'PTERODACTYL_CLIENT_API_KEY',
'PTERODACTYL_SERVER_ID',
] as const) {
if (!env[key]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${key} is required when USE_MOCK_PTERODACTYL is false`,
path: [key],
});
}
}
}
if (env.NODE_ENV === 'production' && (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Discord OAuth credentials are required in production',
path: ['DISCORD_CLIENT_ID'],
});
}
if (env.NODE_ENV === 'production' && !env.OWNER_DISCORD_ID) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'OWNER_DISCORD_ID is required in production so the owner account is recoverable',
path: ['OWNER_DISCORD_ID'],
});
}
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
const parsed = envSchema.safeParse(source);
if (!parsed.success) {
const details = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
.join('\n');
throw new Error(`Invalid environment configuration:\n${details}`);
}
return parsed.data;
}
/** True when the panel has enough configuration to talk to a game server backend. */
export function isPterodactylConfigured(env: Env): boolean {
return (
env.USE_MOCK_PTERODACTYL ||
Boolean(env.PTERODACTYL_BASE_URL && env.PTERODACTYL_CLIENT_API_KEY && env.PTERODACTYL_SERVER_ID)
);
}
+163
View File
@@ -0,0 +1,163 @@
import { createApp } from './app.js';
import { createDb } from './db/client.js';
import { isPterodactylConfigured, loadEnv } from './env.js';
import { createLogger } from './lib/logger.js';
import { SessionService } from './modules/auth/session-service.js';
import { ConfigFileGateway } from './modules/config/config-file-gateway.js';
import { ConfigSyncService } from './modules/config/config-sync.js';
import { ServerModsService } from './modules/config/mods-service.js';
import { PerformanceSettingsService } from './modules/config/performance-service.js';
import { ResourceHistoryService } from './modules/servers/resource-history.js';
import { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
import { MockGameServerProvider } from './modules/pterodactyl/mock-provider.js';
import { PterodactylProvider } from './modules/pterodactyl/pterodactyl-provider.js';
import type { GameServerProvider } from './modules/pterodactyl/types.js';
import { DrizzleIngestionStore } from './modules/reforger-logs/ingestion/drizzle-store.js';
import { LogIngestionService } from './modules/reforger-logs/ingestion/ingestion-service.js';
import { createLogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js';
import { PterodactylLogSource } from './modules/reforger-logs/ingestion/pterodactyl-log-source.js';
import { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
import { ServerService } from './modules/servers/server-service.js';
import { WorkshopClient } from './modules/workshop/workshop-client.js';
const logger = createLogger();
const env = loadEnv();
const { db, pool } = createDb(env.DATABASE_URL);
const mockLogPath = env.REFORGER_ADMIN_LOG_PATH || '/profile/logs/console.log';
const provider: GameServerProvider = env.USE_MOCK_PTERODACTYL
? new MockGameServerProvider({ logPath: mockLogPath })
: new PterodactylProvider({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
});
const sessions = new SessionService(db, env.OWNER_DISCORD_ID);
const servers = new ServerService(db);
const workshop = new WorkshopClient({ baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL });
const configSync = isPterodactylConfigured(env)
? new ConfigSyncService(provider, servers, logger, env.REFORGER_CONFIG_PATH)
: null;
const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH);
const mods = configSync ? new ServerModsService(gateway, configSync, logger) : null;
const performance = configSync ? new PerformanceSettingsService(gateway, configSync, logger) : null;
const resourceHistory = new ResourceHistoryService(provider, logger);
// Log ingestion runs when a backend is configured and we know where logs live.
const logsConfigured =
isPterodactylConfigured(env) &&
Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL);
const primaryServer = (await servers.listServers())[0] ?? null;
const providerServerId = primaryServer
? (primaryServer.pterodactylServerId ?? primaryServer.slug)
: '';
const resolveLogPath =
logsConfigured && primaryServer
? createLogPathResolver({
provider,
providerServerId,
explicitPath: env.USE_MOCK_PTERODACTYL ? mockLogPath : env.REFORGER_ADMIN_LOG_PATH,
directory: env.REFORGER_LOG_DIRECTORY,
fileName: env.REFORGER_LOG_FILE_PATTERN,
})
: null;
const missions =
resolveLogPath && primaryServer
? new MissionCatalog(provider, resolveLogPath, providerServerId)
: null;
let scheduler: IngestionScheduler | null = null;
if (resolveLogPath) {
const ingestion = new LogIngestionService(
new PterodactylLogSource(provider),
new DrizzleIngestionStore(db),
logger,
{ maxDownloadBytes: env.REFORGER_LOG_MAX_DOWNLOAD_BYTES },
);
scheduler = new IngestionScheduler(
ingestion,
logger,
env.REFORGER_LOG_POLL_INTERVAL_SECONDS * 1000,
);
} else {
logger.info('log ingestion disabled (backend or log location not configured)');
}
const app = createApp({
env,
logger,
db,
sessions,
servers,
provider,
workshop,
scheduler,
resolveLogPath,
configSync,
mods,
performance,
resourceHistory,
missions,
});
const httpServer = app.listen(env.PORT, () => {
logger.info(
{ port: env.PORT, mockPterodactyl: env.USE_MOCK_PTERODACTYL },
'reforger-panel API listening',
);
});
if (scheduler && resolveLogPath && primaryServer) {
scheduler.start([
{
serverId: primaryServer.id,
providerServerId,
resolveLogPath,
},
]);
}
if (primaryServer && isPterodactylConfigured(env)) {
resourceHistory.start([{ serverId: primaryServer.id, providerServerId }]);
}
// Import the real config.json at startup and on an interval so the panel
// always reflects what the server actually runs.
let configSyncTimer: ReturnType<typeof setInterval> | null = null;
if (configSync) {
void configSync.syncAllQuietly();
configSyncTimer = setInterval(
() => void configSync.syncAllQuietly(),
env.REFORGER_CONFIG_SYNC_INTERVAL_SECONDS * 1000,
);
configSyncTimer.unref();
}
// Hourly cleanup of expired sessions.
const sessionCleanup = setInterval(
() => void sessions.deleteExpiredSessions().catch(() => undefined),
60 * 60 * 1000,
);
sessionCleanup.unref();
let shuttingDown = false;
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
logger.info({ signal }, 'shutting down');
httpServer.close();
clearInterval(sessionCleanup);
if (configSyncTimer) clearInterval(configSyncTimer);
resourceHistory.stop();
if (scheduler) await scheduler.stop();
if (provider instanceof MockGameServerProvider) provider.dispose();
await pool.end();
process.exit(0);
}
process.on('SIGINT', () => void shutdown('SIGINT'));
process.on('SIGTERM', () => void shutdown('SIGTERM'));
+31
View File
@@ -0,0 +1,31 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
/** 256-bit URL-safe random token. */
export function generateToken(): string {
return randomBytes(32).toString('base64url');
}
/** Sessions are stored by token hash so a DB leak does not leak usable cookies. */
export function hashSessionToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export function sha256Hex(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
export function signValue(value: string, secret: string): string {
const signature = createHmac('sha256', secret).update(value).digest('base64url');
return `${value}.${signature}`;
}
export function verifySignedValue(signed: string, secret: string): string | null {
const separator = signed.lastIndexOf('.');
if (separator <= 0) return null;
const value = signed.slice(0, separator);
const expected = signValue(value, secret);
const a = Buffer.from(signed);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
return value;
}
+47
View File
@@ -0,0 +1,47 @@
import type { ApiErrorCode } from '@reforger-panel/shared';
const STATUS_BY_CODE: Record<ApiErrorCode, number> = {
UNAUTHENTICATED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
VALIDATION_ERROR: 400,
RATE_LIMITED: 429,
CONFLICT: 409,
UPSTREAM_UNAVAILABLE: 502,
NOT_CONFIGURED: 503,
INTERNAL_ERROR: 500,
};
export class ApiError extends Error {
readonly code: ApiErrorCode;
readonly status: number;
constructor(code: ApiErrorCode, message: string) {
super(message);
this.name = 'ApiError';
this.code = code;
this.status = STATUS_BY_CODE[code];
}
static unauthenticated(message = 'You must be signed in.') {
return new ApiError('UNAUTHENTICATED', message);
}
static forbidden(message = 'You do not have permission to perform this action.') {
return new ApiError('FORBIDDEN', message);
}
static notFound(message = 'Not found.') {
return new ApiError('NOT_FOUND', message);
}
static validation(message: string) {
return new ApiError('VALIDATION_ERROR', message);
}
static rateLimited(message = 'Too many requests. Try again shortly.') {
return new ApiError('RATE_LIMITED', message);
}
static upstream(message = 'An upstream service is unavailable.') {
return new ApiError('UPSTREAM_UNAVAILABLE', message);
}
static notConfigured(message = 'This feature is not configured.') {
return new ApiError('NOT_CONFIGURED', message);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { pino } from 'pino';
const REDACT_PATHS = [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
'*.apiKey',
'*.clientSecret',
'*.sessionToken',
'*.password',
'apiKey',
'clientSecret',
'sessionToken',
];
export function createLogger(level?: string) {
return pino({
level: level ?? (process.env.NODE_ENV === 'test' ? 'silent' : 'info'),
redact: { paths: REDACT_PATHS, censor: '[redacted]' },
transport:
process.env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss' } }
: undefined,
});
}
export type Logger = ReturnType<typeof createLogger>;
const SECRET_HINTS = [/api[_-]?key/i, /secret/i, /token/i, /password/i, /authorization/i];
/**
* Strip anything that looks like a secret, an internal URL, or a stack trace
* from an error before it is persisted or shown to a user.
*/
export function sanitizeErrorMessage(error: unknown, maxLength = 300): string {
let message = error instanceof Error ? error.message : String(error);
message = message.split('\n')[0] ?? '';
// Drop credentials embedded in URLs and query strings.
message = message.replace(/\/\/[^/\s:]+:[^@/\s]+@/g, '//[redacted]@');
message = message.replace(/([?&](?:key|token|secret|password)=)[^&\s]+/gi, '$1[redacted]');
for (const hint of SECRET_HINTS) {
if (hint.test(message)) {
// A secret-ish word appears; keep only a generic description.
return 'Upstream request failed (details withheld — see server logs)';
}
}
return message.slice(0, maxLength);
}
+33
View File
@@ -0,0 +1,33 @@
import type { NextFunction, Request, Response } from 'express';
import { ApiError } from './errors.js';
type Bucket = { count: number; resetAt: number };
/**
* Small in-memory fixed-window rate limiter. Sufficient for a single-process
* private panel; swap for a shared store if the API is ever scaled out.
*/
export function rateLimit(options: { windowMs: number; max: number; keyPrefix: string }) {
const buckets = new Map<string, Bucket>();
return (req: Request, _res: Response, next: NextFunction) => {
const now = Date.now();
const key = `${options.keyPrefix}:${req.ip ?? 'unknown'}`;
let bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
bucket = { count: 0, resetAt: now + options.windowMs };
buckets.set(key, bucket);
}
bucket.count += 1;
if (buckets.size > 10_000) {
for (const [k, b] of buckets) {
if (b.resetAt <= now) buckets.delete(k);
}
}
if (bucket.count > options.max) {
next(ApiError.rateLimited());
return;
}
next();
};
}
@@ -0,0 +1,89 @@
import type { NextFunction, Request, Response } from 'express';
import { parse as parseCookies } from 'cookie';
import type { Capability } from '@reforger-panel/shared';
import { roleHasCapability } from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { SessionUser } from './session-service.js';
export const SESSION_COOKIE = 'rp_session';
declare module 'express-serve-static-core' {
interface Request {
user?: SessionUser;
sessionToken?: string;
}
}
export interface SessionLookup {
getUserBySessionToken(token: string): Promise<SessionUser | null>;
}
export function readSessionToken(req: Request): string | null {
const header = req.headers.cookie;
if (!header) return null;
const cookies = parseCookies(header);
return cookies[SESSION_COOKIE] ?? null;
}
/** Resolves the session cookie into req.user (if valid); never rejects on its own. */
export function sessionResolver(sessions: SessionLookup) {
return async (req: Request, _res: Response, next: NextFunction) => {
try {
const token = readSessionToken(req);
if (token) {
const user = await sessions.getUserBySessionToken(token);
if (user) {
req.user = user;
req.sessionToken = token;
}
}
next();
} catch (error) {
next(error);
}
};
}
export function requireAuth(req: Request, _res: Response, next: NextFunction) {
if (!req.user) {
next(ApiError.unauthenticated());
return;
}
next();
}
/** Backend-enforced capability check. Frontend role checks are UI convenience only. */
export function requireCapability(capability: Capability, message?: string) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user) {
next(ApiError.unauthenticated());
return;
}
if (!roleHasCapability(req.user.role, capability)) {
next(ApiError.forbidden(message));
return;
}
next();
};
}
/**
* CSRF protection for state-changing endpoints: the SPA sends a custom header
* (which browsers only allow same-origin / via CORS we control), and when the
* browser supplies an Origin header it must match an allowed origin.
*/
export function csrfProtection(allowedOrigins: string[]) {
const allowed = new Set(allowedOrigins.map((o) => o.replace(/\/$/, '')));
return (req: Request, _res: Response, next: NextFunction) => {
const origin = req.headers.origin;
if (origin && !allowed.has(origin.replace(/\/$/, ''))) {
next(ApiError.forbidden('Cross-origin request rejected.'));
return;
}
if (req.headers['x-csrf-protection'] !== '1') {
next(ApiError.forbidden('Missing CSRF protection header.'));
return;
}
next();
};
}
+162
View File
@@ -0,0 +1,162 @@
import { Router } from 'express';
import { serialize as serializeCookie } from 'cookie';
import type { CurrentUser } from '@reforger-panel/shared';
import { ROLE_CAPABILITIES } from '@reforger-panel/shared';
import type { Env } from '../../env.js';
import { ApiError } from '../../lib/errors.js';
import { generateToken, signValue, verifySignedValue } from '../../lib/crypto.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { buildAuthorizeUrl, exchangeCodeForProfile } from './discord.js';
import type { SessionService } from './session-service.js';
import { SESSION_COOKIE, requireAuth } from './auth-middleware.js';
const STATE_COOKIE = 'rp_oauth_state';
function sessionCookie(token: string, expiresAt: Date, secure: boolean): string {
return serializeCookie(SESSION_COOKIE, token, {
httpOnly: true,
secure,
sameSite: 'lax',
path: '/',
expires: expiresAt,
});
}
function clearedSessionCookie(secure: boolean): string {
return serializeCookie(SESSION_COOKIE, '', {
httpOnly: true,
secure,
sameSite: 'lax',
path: '/',
maxAge: 0,
});
}
export function createAuthRouter(env: Env, sessions: SessionService): Router {
const router = Router();
const secure = env.NODE_ENV === 'production';
const oauthConfig = {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
redirectUri: env.DISCORD_REDIRECT_URI,
};
const authRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'auth' });
// Which login methods the frontend should offer.
router.get('/options', (_req, res) => {
res.json({
discord: Boolean(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET),
devLogin: env.DEV_AUTH_BYPASS && env.NODE_ENV !== 'production',
});
});
router.get('/discord', authRateLimit, (req, res, next) => {
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
next(
ApiError.notConfigured(
'Discord OAuth is not configured. Set DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET.',
),
);
return;
}
const state = generateToken();
res.setHeader(
'Set-Cookie',
serializeCookie(STATE_COOKIE, signValue(state, env.SESSION_SECRET), {
httpOnly: true,
secure,
sameSite: 'lax',
path: '/api/auth',
maxAge: 10 * 60,
}),
);
res.redirect(buildAuthorizeUrl(oauthConfig, state));
});
router.get('/discord/callback', authRateLimit, async (req, res, next) => {
try {
const code = typeof req.query.code === 'string' ? req.query.code : null;
const state = typeof req.query.state === 'string' ? req.query.state : null;
const stateCookieRaw = req.headers.cookie
?.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith(`${STATE_COOKIE}=`))
?.slice(STATE_COOKIE.length + 1);
if (!code || !state || !stateCookieRaw) {
throw ApiError.validation('Missing OAuth code or state.');
}
const expectedState = verifySignedValue(
decodeURIComponent(stateCookieRaw),
env.SESSION_SECRET,
);
if (!expectedState || expectedState !== state) {
throw ApiError.forbidden('OAuth state mismatch. Please try signing in again.');
}
const profile = await exchangeCodeForProfile(oauthConfig, code);
const user = await sessions.upsertUserFromDiscord(profile);
const session = await sessions.createSession(user.id);
res.setHeader('Set-Cookie', [
sessionCookie(session.token, session.expiresAt, secure),
serializeCookie(STATE_COOKIE, '', { path: '/api/auth', maxAge: 0 }),
]);
res.redirect(env.WEB_ORIGIN);
} catch (error) {
next(error);
}
});
// Local development helper: sign in without Discord. Refuses to exist in
// production (env validation also rejects the flag there).
if (env.DEV_AUTH_BYPASS && env.NODE_ENV !== 'production') {
router.post('/dev-login', authRateLimit, async (_req, res, next) => {
try {
const user = await sessions.upsertUserFromDiscord({
discordId: env.OWNER_DISCORD_ID || '000000000000000000',
username: 'dev-owner',
displayName: 'Dev Owner',
avatarUrl: null,
});
// The dev user is always the owner locally.
if (user.role !== 'owner') {
await sessions.setRole(user.id, 'owner');
}
const session = await sessions.createSession(user.id);
res.setHeader('Set-Cookie', sessionCookie(session.token, session.expiresAt, secure));
res.json({ ok: true });
} catch (error) {
next(error);
}
});
}
router.get('/me', requireAuth, (req, res) => {
const user = req.user!;
const body: CurrentUser = {
id: user.id,
discordId: user.discordId,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
capabilities: [...ROLE_CAPABILITIES[user.role]],
};
res.json(body);
});
router.post('/logout', async (req, res, next) => {
try {
if (req.sessionToken) {
await sessions.revokeSession(req.sessionToken);
}
res.setHeader('Set-Cookie', clearedSessionCookie(secure));
res.json({ ok: true });
} catch (error) {
next(error);
}
});
return router;
}
+82
View File
@@ -0,0 +1,82 @@
import { z } from 'zod';
import { ApiError } from '../../lib/errors.js';
const DISCORD_API = 'https://discord.com/api/v10';
const DISCORD_OAUTH_AUTHORIZE = 'https://discord.com/oauth2/authorize';
const tokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
});
const discordUserSchema = z.object({
id: z.string(),
username: z.string(),
global_name: z.string().nullable().optional(),
avatar: z.string().nullable().optional(),
});
export type DiscordProfile = {
discordId: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
};
export type DiscordOAuthConfig = {
clientId: string;
clientSecret: string;
redirectUri: string;
};
export function buildAuthorizeUrl(config: DiscordOAuthConfig, state: string): string {
const url = new URL(DISCORD_OAUTH_AUTHORIZE);
url.searchParams.set('client_id', config.clientId);
url.searchParams.set('redirect_uri', config.redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'identify');
url.searchParams.set('state', state);
url.searchParams.set('prompt', 'none');
return url.toString();
}
export async function exchangeCodeForProfile(
config: DiscordOAuthConfig,
code: string,
fetchImpl: typeof fetch = fetch,
): Promise<DiscordProfile> {
const tokenResponse = await fetchImpl(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
}),
signal: AbortSignal.timeout(10_000),
});
if (!tokenResponse.ok) {
throw ApiError.upstream('Discord token exchange failed.');
}
const token = tokenResponseSchema.parse(await tokenResponse.json());
const userResponse = await fetchImpl(`${DISCORD_API}/users/@me`, {
headers: { Authorization: `${token.token_type} ${token.access_token}` },
signal: AbortSignal.timeout(10_000),
});
if (!userResponse.ok) {
throw ApiError.upstream('Failed to fetch Discord profile.');
}
const user = discordUserSchema.parse(await userResponse.json());
return {
discordId: user.id,
username: user.username,
displayName: user.global_name ?? null,
avatarUrl: user.avatar
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=128`
: null,
};
}
@@ -0,0 +1,121 @@
import { eq, lt } from 'drizzle-orm';
import type { Role } from '@reforger-panel/shared';
import type { Db } from '../../db/client.js';
import { schema } from '../../db/client.js';
import { generateToken, hashSessionToken } from '../../lib/crypto.js';
import type { DiscordProfile } from './discord.js';
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, revocable server-side
export type SessionUser = {
id: string;
discordId: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
role: Role;
};
/**
* Role assignment at login: the configured owner Discord ID always gets (and
* keeps) `owner`; existing users keep their locally-assigned role; everyone
* new starts as `viewer`.
*/
export function resolveRoleForLogin(
existingRole: Role | null,
discordId: string,
ownerDiscordId: string,
): Role {
if (ownerDiscordId !== '' && discordId === ownerDiscordId) return 'owner';
return existingRole ?? 'viewer';
}
export class SessionService {
constructor(
private readonly db: Db,
private readonly ownerDiscordId: string,
) {}
/** Create or update the local user record for a Discord login. */
async upsertUserFromDiscord(profile: DiscordProfile): Promise<SessionUser> {
const existing = await this.db
.select()
.from(schema.users)
.where(eq(schema.users.discordId, profile.discordId));
if (existing[0]) {
const nextRole = resolveRoleForLogin(
existing[0].role,
profile.discordId,
this.ownerDiscordId,
);
const [updated] = await this.db
.update(schema.users)
.set({
username: profile.username,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
role: nextRole,
})
.where(eq(schema.users.id, existing[0].id))
.returning();
return updated!;
}
const [created] = await this.db
.insert(schema.users)
.values({
discordId: profile.discordId,
username: profile.username,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
role: resolveRoleForLogin(null, profile.discordId, this.ownerDiscordId),
})
.returning();
return created!;
}
async setRole(userId: string, role: Role): Promise<SessionUser | null> {
const [updated] = await this.db
.update(schema.users)
.set({ role })
.where(eq(schema.users.id, userId))
.returning();
return updated ?? null;
}
/** Returns the raw token for the cookie; only its hash is persisted. */
async createSession(userId: string): Promise<{ token: string; expiresAt: Date }> {
const token = generateToken();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await this.db.insert(schema.sessions).values({
id: hashSessionToken(token),
userId,
expiresAt,
});
return { token, expiresAt };
}
async getUserBySessionToken(token: string): Promise<SessionUser | null> {
const rows = await this.db
.select({ session: schema.sessions, user: schema.users })
.from(schema.sessions)
.innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))
.where(eq(schema.sessions.id, hashSessionToken(token)));
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() <= Date.now()) {
await this.db.delete(schema.sessions).where(eq(schema.sessions.id, row.session.id));
return null;
}
return row.user;
}
async revokeSession(token: string): Promise<void> {
await this.db.delete(schema.sessions).where(eq(schema.sessions.id, hashSessionToken(token)));
}
async deleteExpiredSessions(): Promise<void> {
await this.db.delete(schema.sessions).where(lt(schema.sessions.expiresAt, new Date()));
}
}
@@ -0,0 +1,66 @@
import { ApiError } from '../../lib/errors.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
const CONFIG_MAX_BYTES = 256 * 1024;
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Shared read-modify-write access to the server's config.json: size-guarded
* download + parse, and a write path that backs the previous content up to
* `<config>.bak` and verifies the upload by downloading it again. Callers
* mutate only their own keys on the parsed document so everything else in the
* file passes through untouched.
*/
export class ConfigFileGateway {
constructor(
private readonly provider: GameServerProvider,
readonly configPath: string,
) {}
async download(
providerServerId: string,
): Promise<{ raw: string; root: Record<string, unknown> }> {
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
CONFIG_MAX_BYTES,
);
if (file.truncated) {
throw ApiError.upstream('Server config.json is unexpectedly large; refusing to modify it.');
}
let parsed: unknown;
try {
parsed = JSON.parse(file.content.replace(/^\uFEFF/, ''));
} catch {
throw ApiError.upstream('Server config.json is not valid JSON.');
}
const root = asRecord(parsed);
if (!root || !asRecord(root.game)) {
throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.');
}
return { raw: file.content, root };
}
/**
* Backs up `previousRaw`, writes the mutated document, downloads it again
* and hands the verified parsed result to `verify` (throw there to fail).
*/
async write(
providerServerId: string,
root: Record<string, unknown>,
previousRaw: string,
verify: (readBack: Record<string, unknown>) => void,
): Promise<Record<string, unknown>> {
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw);
const serialized = `${JSON.stringify(root, null, 4)}\n`;
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
const readBack = await this.download(providerServerId);
verify(readBack.root);
return readBack.root;
}
}
@@ -0,0 +1,68 @@
import type { ReforgerServerConfig } from '@reforger-panel/shared';
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord, ServerService } from '../servers/server-service.js';
import { parseReforgerConfigJson } from './reforger-config-file.js';
const CONFIG_MAX_BYTES = 256 * 1024;
export type ConfigSyncResult = {
serverName: string;
maxPlayers: number;
config: ReforgerServerConfig;
};
/**
* Reads the server's real config.json (via the provider, read-only) and keeps
* the server row's name/maxPlayers in line with what the server actually
* runs. Configuration is always served live; no revision history is kept.
*/
export class ConfigSyncService {
constructor(
private readonly provider: GameServerProvider,
private readonly servers: ServerService,
private readonly logger: Logger,
private readonly configPath: string,
) {}
async getLiveConfig(server: ServerRecord): Promise<ReforgerServerConfig> {
const providerServerId = server.pterodactylServerId ?? server.slug;
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
CONFIG_MAX_BYTES,
);
return parseReforgerConfigJson(file.content);
}
async sync(server: ServerRecord): Promise<ConfigSyncResult> {
const config = await this.getLiveConfig(server);
const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null;
if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) {
await this.servers.updateServerInfo(server.id, {
name: config.serverName,
maxPlayers,
});
this.logger.info(
{ serverId: server.id, serverName: config.serverName, maxPlayers },
'server info updated from config.json',
);
}
return { serverName: config.serverName, maxPlayers: config.maxPlayers, config };
}
/** Sync all servers, logging failures instead of throwing (for the poll loop). */
async syncAllQuietly(): Promise<void> {
const servers = await this.servers.listServers();
for (const server of servers) {
try {
await this.sync(server);
} catch (error) {
this.logger.warn(
{ serverId: server.id, error: sanitizeErrorMessage(error) },
'config sync failed',
);
}
}
}
}
@@ -0,0 +1,96 @@
import type {
ReforgerConfigMod,
ServerModsResponse,
UpdateModsResult,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
const game = asRecord(root.game);
if (!game || !Array.isArray(game.mods)) return [];
return game.mods
.map((entry): ReforgerConfigMod | null => {
const mod = asRecord(entry);
const modId = typeof mod?.modId === 'string' ? mod.modId : '';
if (!modId) return null;
const name = typeof mod?.name === 'string' && mod.name ? mod.name : undefined;
const version = typeof mod?.version === 'string' && mod.version ? mod.version : undefined;
return {
modId,
...(name ? { name } : {}),
...(version ? { version } : {}),
};
})
.filter((mod): mod is ReforgerConfigMod => mod !== null);
}
/**
* Manages the `game.mods` array of the server's real config.json through the
* shared ConfigFileGateway (backup + read-back verification; all other config
* fields pass through untouched). Changes apply on the next server restart.
*/
export class ServerModsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
async getMods(server: ServerRecord): Promise<ServerModsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { mods: readMods(root), fetchedAt: new Date().toISOString() };
}
async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise<UpdateModsResult> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const previous = readMods(root);
const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase()));
const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const added = [...nextIds].filter((id) => !previousIds.has(id)).length;
const removed = [...previousIds].filter((id) => !nextIds.has(id)).length;
const game = asRecord(root.game)!;
game.mods = mods.map((mod) => ({
modId: mod.modId.toUpperCase(),
...(mod.name ? { name: mod.name } : {}),
...(mod.version ? { version: mod.version } : {}),
}));
const verified = await this.gateway.write(providerId, root, raw, (readBack) => {
const verifyIds = readMods(readBack)
.map((mod) => mod.modId.toUpperCase())
.sort();
if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) {
throw ApiError.upstream(
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
);
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, added, removed }, 'server mods updated');
return {
mods: readMods(verified),
fetchedAt: new Date().toISOString(),
added,
removed,
requiresRestart: true,
};
}
}
@@ -0,0 +1,145 @@
import type {
PerformanceSettings,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
} from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
/** Where each performance field lives inside config.json. */
const FIELD_LOCATIONS: Record<
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string]
> = {
scenarioId: ['game', 'scenarioId'],
maxPlayers: ['game', 'maxPlayers'],
serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'],
networkViewDistance: ['gameProperties', 'networkViewDistance'],
serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'],
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'],
aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'],
};
function sectionFor(
root: Record<string, unknown>,
section: 'game' | 'gameProperties' | 'operating',
createMissing: boolean,
): Record<string, unknown> | null {
const game = asRecord(root.game)!;
if (section === 'game') return game;
if (section === 'gameProperties') {
let props = asRecord(game.gameProperties);
if (!props && createMissing) {
props = {};
game.gameProperties = props;
}
return props;
}
let operating = asRecord(root.operating);
if (!operating && createMissing) {
operating = {};
root.operating = operating;
}
return operating;
}
export function readPerformanceSettings(root: Record<string, unknown>): PerformanceSettings {
const result = {} as Record<keyof PerformanceSettings, number | boolean | string | null>;
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
const container = sectionFor(root, section, false);
const value = container?.[key];
const validType =
field === 'scenarioId'
? typeof value === 'string'
: typeof value === 'number' || typeof value === 'boolean';
result[field] = validType ? (value as number | boolean | string) : null;
}
return result as PerformanceSettings;
}
/**
* Edits the performance-related keys of the live config.json. A `null` value
* removes the key from the file entirely so the game's own default applies
* network/identity fields (bind address, ports, passwords, rcon) are never
* touched by this service.
*/
export class PerformanceSettingsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
async get(server: ServerRecord): Promise<PerformanceSettingsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() };
}
async update(
server: ServerRecord,
patch: PerformanceSettingsPatch,
): Promise<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: true }> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const before = readPerformanceSettings(root);
const changedFields: string[] = [];
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
if (!(field in patch)) continue; // untouched fields stay as-is
const next = patch[field] as number | boolean | string | null;
if (before[field] === next) continue;
changedFields.push(field);
if (next === null) {
const container = sectionFor(root, section, false);
if (container) delete container[key];
} else {
const container = sectionFor(root, section, true)!;
container[key] = next;
}
}
if (changedFields.length > 0) {
await this.gateway.write(providerId, root, raw, (readBack) => {
const after = readPerformanceSettings(readBack);
for (const field of changedFields) {
if (
after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings]
) {
throw new Error('Config write verification failed. Check config.json.bak.');
}
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
}
return {
settings: { ...before, ...patch } as PerformanceSettings,
fetchedAt: new Date().toISOString(),
changedFields,
requiresRestart: true,
};
}
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { parseReforgerConfigJson } from './reforger-config-file.js';
import { ApiError } from '../../lib/errors.js';
// Shape from the Reforger dedicated-server docs / typical Pterodactyl egg output.
const REAL_SHAPE = {
bindAddress: '0.0.0.0',
bindPort: 2001,
publicAddress: '',
publicPort: 2001,
a2s: { address: '0.0.0.0', port: 17777 },
rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' },
game: {
name: 'DazzledCorp Training Grounds',
password: '',
passwordAdmin: 'secret',
admins: ['76561198000000000'],
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
maxPlayers: 16,
visible: true,
crossPlatform: true,
supportedPlatforms: ['PLATFORM_PC', 'PLATFORM_XBL'],
gameProperties: {
serverMaxViewDistance: 2500,
serverMinGrassDistance: 50,
networkViewDistance: 1000,
disableThirdPerson: true,
fastValidation: true,
battlEye: true,
VONDisableUI: false,
},
mods: [
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5' },
],
},
operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 },
};
describe('parseReforgerConfigJson', () => {
it('maps a real-shaped config.json into the panel model', () => {
const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE));
expect(config).toEqual({
serverName: 'DazzledCorp Training Grounds',
maxPlayers: 16,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
aiLimit: -1,
serverMaxViewDistance: 2500,
networkViewDistance: 1000,
crossPlatform: true,
disableThirdPerson: true,
mods: [
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5' },
],
});
});
it('never includes credentials from the config file in the mapped model', () => {
const json = JSON.stringify(parseReforgerConfigJson(JSON.stringify(REAL_SHAPE)));
expect(json).not.toContain('hunter2');
expect(json).not.toContain('secret');
});
it('tolerates missing sections with neutral defaults', () => {
const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}');
expect(config.serverName).toBe('Bare');
expect(config.maxPlayers).toBe(0);
expect(config.aiLimit).toBe(-1);
expect(config.mods).toEqual([]);
});
it('rejects invalid JSON with a sanitized upstream error', () => {
expect(() => parseReforgerConfigJson('not json {')).toThrow(ApiError);
});
});
@@ -0,0 +1,74 @@
import type { ReforgerServerConfig } from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
/**
* Maps a real Reforger server `config.json` (the file the dedicated server
* runs with, documented at
* https://community.bistudio.com/wiki/Arma_Reforger:Server_Config) into the
* panel's internal config model. Mapping is defensive: missing or oddly-typed
* fields fall back to neutral defaults instead of failing the sync.
*/
function record(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function str(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback;
}
function num(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function bool(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
export function mapReforgerConfig(raw: unknown): ReforgerServerConfig {
const root = record(raw);
const game = record(root.game);
const gameProperties = record(game.gameProperties);
const operating = record(root.operating);
const mods = Array.isArray(game.mods)
? game.mods
.map((entry) => {
const mod = record(entry);
const modId = str(mod.modId);
if (!modId) return null;
return {
modId,
name: str(mod.name) || undefined,
version: str(mod.version) || undefined,
};
})
.filter((mod): mod is NonNullable<typeof mod> => mod !== null)
: [];
return {
serverName: str(game.name, 'Unnamed server'),
maxPlayers: num(game.maxPlayers, 0),
scenarioId: str(game.scenarioId),
// -1 means "no limit" in Reforger's operating.aiLimit.
aiLimit: num(operating.aiLimit, -1),
serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0),
networkViewDistance: num(gameProperties.networkViewDistance, 0),
crossPlatform: bool(game.crossPlatform, false),
disableThirdPerson: bool(gameProperties.disableThirdPerson, false),
mods,
};
}
export function parseReforgerConfigJson(content: string): ReforgerServerConfig {
const text = content.replace(/^\uFEFF/, '').trim();
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw ApiError.upstream('Server config.json is not valid JSON.');
}
return mapReforgerConfig(raw);
}
@@ -0,0 +1,152 @@
import { Router } from 'express';
import { z } from 'zod';
import { and, desc, eq, gt, isNull } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
import type { InviteSummary, Role } from '@reforger-panel/shared';
import { ROLES } from '@reforger-panel/shared';
import type { Db } from '../../db/client.js';
import { schema } from '../../db/client.js';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth, requireCapability } from '../auth/auth-middleware.js';
const createBodySchema = z.object({
// Owner invites are deliberately not creatable; there is one owner.
role: z.enum(['server_admin', 'mission_lead', 'viewer']),
expiresInHours: z.number().int().min(1).max(8760).nullable().default(168),
});
const NEVER_EXPIRES_HOURS = 24 * 365 * 100;
const redeemBodySchema = z.object({
code: z.string().trim().min(4).max(64),
});
function inviteCode(): string {
// Readable, unambiguous, ~50 bits.
return randomBytes(10).toString('base64url').replace(/[-_]/g, 'x').slice(0, 12).toUpperCase();
}
export function createInviteRouter(db: Db): Router {
const router = Router();
const redeemRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'invite-redeem' });
router.use(requireAuth);
/**
* Redeem an invite: upgrades the calling user to the invite's role and
* consumes the code. Available to any signed-in user (rate limited).
*/
router.post('/redeem', redeemRateLimit, async (req, res, next) => {
try {
const body = redeemBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid invite code.');
const user = req.user!;
const rows = await db
.select()
.from(schema.invites)
.where(
and(
eq(schema.invites.code, body.data.code.toUpperCase()),
isNull(schema.invites.usedAt),
gt(schema.invites.expiresAt, new Date()),
),
);
const invite = rows[0];
if (!invite) {
throw ApiError.notFound('This invite code is invalid, used, or expired.');
}
if (user.role === 'owner') {
// Owners never downgrade themselves by redeeming a code.
res.json({ ok: true, role: user.role, changed: false });
return;
}
await db
.update(schema.invites)
.set({ usedByUserId: user.id, usedAt: new Date() })
.where(eq(schema.invites.id, invite.id));
await db
.update(schema.users)
.set({ role: invite.role as Role })
.where(eq(schema.users.id, user.id));
res.json({ ok: true, role: invite.role, changed: invite.role !== user.role });
} catch (error) {
next(error);
}
});
router.use(requireCapability('users.manage', 'Only the owner can manage invites.'));
router.get('/', async (_req, res, next) => {
try {
const rows = await db
.select({ invite: schema.invites, createdBy: schema.users })
.from(schema.invites)
.leftJoin(schema.users, eq(schema.users.id, schema.invites.createdByUserId))
.orderBy(desc(schema.invites.createdAt))
.limit(50);
const usedByIds = rows
.map((r) => r.invite.usedByUserId)
.filter((id): id is string => id !== null);
const usedByUsers = usedByIds.length > 0 ? await db.select().from(schema.users) : [];
const usedByName = new Map(usedByUsers.map((u) => [u.id, u.displayName ?? u.username]));
const invites: InviteSummary[] = rows.map(({ invite, createdBy }) => ({
id: invite.id,
code: invite.code,
role: invite.role,
createdBy: createdBy ? (createdBy.displayName ?? createdBy.username) : null,
expiresAt: invite.expiresAt?.toISOString() ?? null,
usedBy: invite.usedByUserId ? (usedByName.get(invite.usedByUserId) ?? 'unknown') : null,
usedAt: invite.usedAt?.toISOString() ?? null,
createdAt: invite.createdAt.toISOString(),
}));
res.json({ invites });
} catch (error) {
next(error);
}
});
router.post('/', async (req, res, next) => {
try {
const body = createBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid invite request.');
if (!ROLES.includes(body.data.role)) throw ApiError.validation('Invalid role.');
const [invite] = await db
.insert(schema.invites)
.values({
code: inviteCode(),
role: body.data.role,
createdByUserId: req.user!.id,
expiresAt: new Date(
Date.now() + (body.data.expiresInHours ?? NEVER_EXPIRES_HOURS) * 60 * 60 * 1000,
),
})
.returning();
res.json({
id: invite!.id,
code: invite!.code,
role: invite!.role,
expiresAt: invite!.expiresAt?.toISOString() ?? null,
});
} catch (error) {
next(error);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const id = z.string().uuid().safeParse(req.params.id);
if (!id.success) throw ApiError.validation('Invalid invite id.');
await db.delete(schema.invites).where(eq(schema.invites.id, id.data));
res.json({ ok: true });
} catch (error) {
next(error);
}
});
return router;
}
@@ -0,0 +1,342 @@
import type {
RestartScheduleInput,
ServerScheduleSummary,
ServerStatus,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
const START_DELAY_MS = 4_000;
const STOP_DELAY_MS = 2_500;
function pad(n: number, width = 2): string {
return String(n).padStart(width, '0');
}
function timeOfDay(date: Date): string {
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(
date.getUTCMilliseconds(),
3,
)}`;
}
function dateStamp(date: Date): string {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
}
/**
* Builds a plausible Reforger console.log covering the last ~50 minutes:
* server start, four connects, one disconnect. Line shapes mirror the real
* Enfusion/BattlEye output the parser targets (see parser/patterns.ts).
*/
export function buildMockConsoleLog(now: Date = new Date()): string {
const at = (minutesAgo: number, driftSeconds = 0) =>
new Date(now.getTime() - minutesAgo * 60_000 + driftSeconds * 1000);
const started = at(50);
const lines = [
`------------------------------------------------------------------------------------------------`,
`Log started ${dateStamp(started)} ${timeOfDay(started).slice(0, 8)}`,
`${timeOfDay(started)} ENGINE : Enfusion engine build: 1.3.0.42 (mock)`,
`${timeOfDay(at(50, 4))} DEFAULT : Loading world.`,
`${timeOfDay(at(49))} DEFAULT : Game successfully created.`,
`${timeOfDay(at(48))} NETWORK : Server is ready to accept connections`,
`${timeOfDay(at(44))} DEFAULT : BattlEye Server: 'Player #1 Braeden (10.66.4.21:50241) connected'`,
`${timeOfDay(at(44, 2))} DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'`,
`${timeOfDay(at(38))} DEFAULT : BattlEye Server: 'Player #2 Sable (10.66.4.30:61022) connected'`,
`${timeOfDay(at(38, 1))} DEFAULT : BattlEye Server: 'Player #2 Sable - GUID: 41c7de9a5b02f311'`,
`${timeOfDay(at(31))} DEFAULT : BattlEye Server: 'Player #3 Kestrel (10.66.4.87:49155) connected'`,
`${timeOfDay(at(27))} SCRIPT : SCR_BaseGameMode: match state changed`,
`${timeOfDay(at(22))} DEFAULT : BattlEye Server: 'Player #4 Moss (10.66.4.44:51811) connected'`,
`${timeOfDay(at(22, 1))} DEFAULT : BattlEye Server: 'Player #4 Moss - GUID: c31009e2ab77d514'`,
`${timeOfDay(at(9))} DEFAULT : BattlEye Server: 'Player #3 Kestrel disconnected'`,
`${timeOfDay(at(2))} NETWORK : ### Connection stats`,
'',
];
return lines.join('\n');
}
/**
* In-process stand-in for Pterodactyl so the whole panel runs without
* credentials. Power actions transition through starting/stopping states, and
* the mock file system serves a generated console.log fixture.
*/
export class MockGameServerProvider implements GameServerProvider {
private status: ServerStatus = 'online';
private startedAt = Date.now() - 50 * 60_000;
private transitionTimer: ReturnType<typeof setTimeout> | null = null;
private readonly logContent: string;
private readonly logPath: string;
private readonly configPath: string;
private configContent: string;
private nextScheduleId = 2;
private schedules: ServerScheduleSummary[] = [
{
id: '1',
name: 'Daily restart',
isActive: true,
onlyWhenOnline: true,
minute: '0',
hour: '9',
dayOfMonth: '*',
month: '*',
dayOfWeek: '*',
nextRunAt: null,
lastRunAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tasks: [
{
id: '1',
action: 'power',
payload: 'restart',
timeOffsetSeconds: 0,
continueOnFailure: false,
},
],
},
];
/** Files written via writeTextFile (e.g. config.json backups). */
readonly writtenFiles = new Map<string, string>();
constructor(options: { logPath?: string; configPath?: string; now?: Date } = {}) {
this.logPath = options.logPath ?? '/profile/logs/console.log';
this.logContent = buildMockConsoleLog(options.now ?? new Date());
this.configPath = options.configPath ?? '/config.json';
// Shape mirrors a real Reforger dedicated-server config.json.
this.configContent = JSON.stringify(
{
bindAddress: '0.0.0.0',
bindPort: 2001,
game: {
name: 'Mock Reforger Server',
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
maxPlayers: 16,
crossPlatform: true,
gameProperties: {
serverMaxViewDistance: 2500,
networkViewDistance: 1500,
disableThirdPerson: false,
},
mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }],
},
operating: { aiLimit: 40 },
},
null,
2,
);
}
dispose() {
if (this.transitionTimer) clearTimeout(this.transitionTimer);
}
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
this.status = to;
if (this.transitionTimer) clearTimeout(this.transitionTimer);
this.transitionTimer = setTimeout(() => {
this.status = thenTo;
if (thenTo === 'online') this.startedAt = Date.now();
this.transitionTimer = null;
}, after);
this.transitionTimer.unref?.();
}
async getServerStatus(): Promise<ServerStatus> {
return this.status;
}
async getServerResources(): Promise<ProviderServerResources> {
const online = this.status === 'online';
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
return {
status: this.status,
cpuPercent: online ? Math.max(2, wobble(38, 14)) : 0,
cpuLimitPercent: 400,
memoryBytes: online ? Math.round(wobble(5.1, 0.6) * 1024 ** 3) : 0,
memoryLimitBytes: 8 * 1024 ** 3,
diskBytes: Math.round(22.4 * 1024 ** 3),
diskLimitBytes: 40 * 1024 ** 3,
networkRxBytes: online ? Math.round(wobble(9.2, 1.5) * 1024 ** 2) : 0,
networkTxBytes: online ? Math.round(wobble(26.8, 4) * 1024 ** 2) : 0,
uptimeMs: online ? Date.now() - this.startedAt : 0,
};
}
async startServer(): Promise<void> {
if (this.status === 'online') return;
this.transition('starting', START_DELAY_MS, 'online');
}
async stopServer(): Promise<void> {
if (this.status === 'offline') return;
this.transition('stopping', STOP_DELAY_MS, 'offline');
}
async restartServer(): Promise<void> {
this.transition('stopping', STOP_DELAY_MS, 'starting');
setTimeout(() => {
if (this.status === 'starting') {
this.status = 'online';
this.startedAt = Date.now();
}
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
}
async listFiles(_serverId: string, directory: string): Promise<ServerFileEntry[]> {
const dir = directory.replace(/\/$/, '') || '/';
const logDir = this.logPath.slice(0, this.logPath.lastIndexOf('/')) || '/';
if (dir !== logDir) return [];
return [
{
name: this.logPath.slice(this.logPath.lastIndexOf('/') + 1),
isFile: true,
sizeBytes: Buffer.byteLength(this.logContent),
modifiedAt: new Date(),
},
];
}
async getFileDownloadUrl(): Promise<string> {
throw ApiError.notConfigured('Direct downloads are not available in mock mode.');
}
async writeTextFile(_serverId: string, path: string, content: string): Promise<void> {
this.writtenFiles.set(path, content);
if (path === this.configPath) {
this.configContent = content;
}
}
private startupVariables = [
{
name: 'Server Password',
description: 'Password required to join the server.',
envVariable: 'SERVER_PASSWORD',
serverValue: '',
defaultValue: '',
isEditable: true,
},
{
name: 'Admin Password',
description: 'Password for in-game admin access.',
envVariable: 'ADMIN_PASSWORD',
serverValue: 'mock-admin-pass',
defaultValue: '',
isEditable: true,
},
{
name: 'App ID',
description: 'Steam application id (managed by the egg).',
envVariable: 'SRCDS_APPID',
serverValue: '1874900',
defaultValue: '1874900',
isEditable: false,
},
];
async listStartupVariables() {
return this.startupVariables.map((v) => ({ ...v }));
}
async updateStartupVariable(_serverId: string, envVariable: string, value: string) {
const variable = this.startupVariables.find((v) => v.envVariable === envVariable);
if (!variable || !variable.isEditable) {
throw ApiError.validation('This startup variable cannot be edited.');
}
variable.serverValue = value;
}
async listSchedules(): Promise<ServerScheduleSummary[]> {
return this.schedules.map((schedule) => ({
...schedule,
tasks: schedule.tasks.map((task) => ({ ...task })),
}));
}
async createRestartSchedule(
_serverId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const now = new Date().toISOString();
const schedule: ServerScheduleSummary = {
id: String(this.nextScheduleId++),
name: input.name,
isActive: input.isActive,
onlyWhenOnline: input.onlyWhenOnline,
minute: String(input.minute),
hour: String(input.hour),
dayOfMonth: '*',
month: '*',
dayOfWeek: input.dayOfWeek,
nextRunAt: null,
lastRunAt: null,
createdAt: now,
updatedAt: now,
tasks: [
{
id: String(this.nextScheduleId++),
action: 'power',
payload: 'restart',
timeOffsetSeconds: 0,
continueOnFailure: false,
},
],
};
this.schedules.unshift(schedule);
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
}
async updateRestartSchedule(
_serverId: string,
scheduleId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const schedule = this.schedules.find((s) => s.id === scheduleId);
if (!schedule) throw ApiError.notFound('Schedule not found.');
schedule.name = input.name;
schedule.isActive = input.isActive;
schedule.onlyWhenOnline = input.onlyWhenOnline;
schedule.minute = String(input.minute);
schedule.hour = String(input.hour);
schedule.dayOfWeek = input.dayOfWeek;
schedule.updatedAt = new Date().toISOString();
return { ...schedule, tasks: schedule.tasks.map((task) => ({ ...task })) };
}
async deleteSchedule(_serverId: string, scheduleId: string): Promise<void> {
this.schedules = this.schedules.filter((schedule) => schedule.id !== scheduleId);
}
async downloadTextFile(
_serverId: string,
path: string,
maxBytes = 2 * 1024 * 1024,
): Promise<DownloadableFile> {
const content =
path === this.logPath
? this.logContent
: path === this.configPath
? this.configContent
: null;
if (content === null) {
throw ApiError.notFound(`Mock file not found: ${path}`);
}
const buffer = Buffer.from(content, 'utf8');
const trimmed =
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
return {
path,
content: trimmed.toString('utf8'),
totalSizeBytes: buffer.byteLength,
contentStartOffset: buffer.byteLength - trimmed.byteLength,
truncated: trimmed.byteLength < buffer.byteLength,
};
}
}
@@ -0,0 +1,483 @@
import type {
RestartScheduleInput,
ServerScheduleSummary,
ServerScheduleTask,
ServerStatus,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
const DEFAULT_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
type PterodactylOptions = {
baseUrl: string;
apiKey: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
type PterodactylScheduleResponse = {
data?: {
attributes?: PterodactylScheduleAttributes;
};
};
type PterodactylScheduleAttributes = {
id?: number | string;
name?: string;
cron?: {
minute?: string;
hour?: string;
day_of_month?: string;
month?: string;
day_of_week?: string;
};
is_active?: boolean;
only_when_online?: boolean;
last_run_at?: string | null;
next_run_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
relationships?: {
tasks?: {
data?: {
attributes?: {
id?: number | string;
action?: string;
payload?: string;
time_offset?: number;
continue_on_failure?: boolean;
};
}[];
};
};
};
function mapState(state: string): ServerStatus {
switch (state) {
case 'running':
return 'online';
case 'offline':
return 'offline';
case 'starting':
return 'starting';
case 'stopping':
return 'stopping';
default:
return 'unknown';
}
}
/**
* Pterodactyl Client API provider. Uses only client-scoped endpoints (status,
* resources, power, read-only file access). Errors are sanitized: they carry
* the endpoint category and HTTP status, never the API key or full URL.
*/
export class PterodactylProvider implements GameServerProvider {
private readonly baseUrl: string;
private readonly apiKey: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
private limitsCache = new Map<
string,
{
cpuLimitPercent: number | null;
memoryLimitBytes: number | null;
diskLimitBytes: number | null;
fetchedAt: number;
}
>();
constructor(options: PterodactylOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
private async request<T = unknown>(
label: string,
path: string,
init: { method?: string; body?: unknown; timeoutMs?: number; raw?: boolean } = {},
): Promise<T> {
const url = `${this.baseUrl}/api/client${path}`;
let response: Response;
try {
response = await this.fetchImpl(url, {
method: init.method ?? 'GET',
headers: {
Authorization: `Bearer ${this.apiKey}`,
Accept: 'application/json',
...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
signal: AbortSignal.timeout(init.timeoutMs ?? this.timeoutMs),
});
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Pterodactyl request (${label}) ${reason}.`);
}
if (!response.ok) {
throw ApiError.upstream(`Pterodactyl request (${label}) returned HTTP ${response.status}.`);
}
if (init.raw) {
return (await response.text()) as T;
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
throw ApiError.upstream(`Pterodactyl request (${label}) returned invalid JSON.`);
}
}
private async getLimits(serverId: string) {
const cached = this.limitsCache.get(serverId);
if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached;
const data = await this.request<{
attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } };
}>('server details', `/servers/${encodeURIComponent(serverId)}`);
const limits = data.attributes?.limits;
const entry = {
cpuLimitPercent: limits?.cpu && limits.cpu > 0 ? limits.cpu : null,
memoryLimitBytes: limits?.memory ? limits.memory * 1024 * 1024 : null,
diskLimitBytes: limits?.disk ? limits.disk * 1024 * 1024 : null,
fetchedAt: Date.now(),
};
this.limitsCache.set(serverId, entry);
return entry;
}
async getServerStatus(serverId: string): Promise<ServerStatus> {
const resources = await this.getServerResources(serverId);
return resources.status;
}
async getServerResources(serverId: string): Promise<ProviderServerResources> {
const data = await this.request<{
attributes?: {
current_state?: string;
resources?: {
memory_bytes?: number;
cpu_absolute?: number;
disk_bytes?: number;
network_rx_bytes?: number;
network_tx_bytes?: number;
uptime?: number;
};
};
}>('resources', `/servers/${encodeURIComponent(serverId)}/resources`);
const attrs = data.attributes ?? {};
const res = attrs.resources ?? {};
const limits = await this.getLimits(serverId).catch(() => ({
cpuLimitPercent: null,
memoryLimitBytes: null,
diskLimitBytes: null,
}));
return {
status: mapState(attrs.current_state ?? 'unknown'),
cpuPercent: res.cpu_absolute ?? 0,
cpuLimitPercent: limits.cpuLimitPercent,
memoryBytes: res.memory_bytes ?? 0,
memoryLimitBytes: limits.memoryLimitBytes,
diskBytes: res.disk_bytes ?? 0,
diskLimitBytes: limits.diskLimitBytes,
networkRxBytes: res.network_rx_bytes ?? 0,
networkTxBytes: res.network_tx_bytes ?? 0,
uptimeMs: res.uptime ?? 0,
};
}
private async sendPowerSignal(serverId: string, signal: 'start' | 'stop' | 'restart') {
await this.request(`power ${signal}`, `/servers/${encodeURIComponent(serverId)}/power`, {
method: 'POST',
body: { signal },
});
}
async startServer(serverId: string): Promise<void> {
await this.sendPowerSignal(serverId, 'start');
}
async stopServer(serverId: string): Promise<void> {
await this.sendPowerSignal(serverId, 'stop');
}
async restartServer(serverId: string): Promise<void> {
await this.sendPowerSignal(serverId, 'restart');
}
async listFiles(serverId: string, directory: string): Promise<ServerFileEntry[]> {
const data = await this.request<{
data?: {
attributes?: {
name?: string;
is_file?: boolean;
size?: number;
modified_at?: string;
};
}[];
}>(
'file list',
`/servers/${encodeURIComponent(serverId)}/files/list?directory=${encodeURIComponent(directory)}`,
);
return (data.data ?? []).map((entry) => ({
name: entry.attributes?.name ?? '',
isFile: entry.attributes?.is_file ?? false,
sizeBytes: entry.attributes?.size ?? 0,
modifiedAt: entry.attributes?.modified_at ? new Date(entry.attributes.modified_at) : null,
}));
}
async getFileDownloadUrl(serverId: string, path: string): Promise<string> {
const data = await this.request<{ attributes?: { url?: string } }>(
'file download url',
`/servers/${encodeURIComponent(serverId)}/files/download?file=${encodeURIComponent(path)}`,
);
const url = data.attributes?.url;
if (!url) {
throw ApiError.upstream('Pterodactyl did not return a download URL.');
}
return url;
}
/**
* Downloads a text file via the signed one-time download URL (streams and
* caps size, unlike files/contents which buffers whole files). When the file
* exceeds maxBytes the TAIL is kept this method exists for log retrieval.
*/
async downloadTextFile(
serverId: string,
path: string,
maxBytes: number = DEFAULT_MAX_DOWNLOAD_BYTES,
): Promise<DownloadableFile> {
const stat = await this.statFile(serverId, path);
const url = await this.getFileDownloadUrl(serverId, path);
let response: Response;
try {
response = await this.fetchImpl(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Pterodactyl log download ${reason}.`);
}
if (!response.ok || !response.body) {
throw ApiError.upstream(`Pterodactyl log download returned HTTP ${response.status}.`);
}
// Stream and keep a rolling tail of at most maxBytes.
const chunks: Uint8Array[] = [];
let buffered = 0;
let discarded = 0;
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
buffered += value.byteLength;
while (buffered - (chunks[0]?.byteLength ?? 0) >= maxBytes && chunks.length > 1) {
const dropped = chunks.shift()!;
buffered -= dropped.byteLength;
discarded += dropped.byteLength;
}
}
let combined = Buffer.concat(chunks);
if (combined.byteLength > maxBytes) {
const trim = combined.byteLength - maxBytes;
combined = combined.subarray(trim);
discarded += trim;
}
return {
path,
content: combined.toString('utf8'),
totalSizeBytes: stat?.sizeBytes ?? discarded + combined.byteLength,
contentStartOffset: discarded,
truncated: discarded > 0,
};
}
async writeTextFile(serverId: string, path: string, content: string): Promise<void> {
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(serverId)}/files/write?file=${encodeURIComponent(path)}`;
let response: Response;
try {
response = await this.fetchImpl(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
},
body: content,
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Pterodactyl request (file write) ${reason}.`);
}
if (!response.ok) {
throw ApiError.upstream(`Pterodactyl request (file write) returned HTTP ${response.status}.`);
}
}
async listStartupVariables(serverId: string) {
const data = await this.request<{
data?: {
attributes?: {
name?: string;
description?: string;
env_variable?: string;
server_value?: string | null;
default_value?: string | null;
is_editable?: boolean;
};
}[];
}>('startup variables', `/servers/${encodeURIComponent(serverId)}/startup`);
return (data.data ?? []).map((entry) => ({
name: entry.attributes?.name ?? '',
description: entry.attributes?.description ?? '',
envVariable: entry.attributes?.env_variable ?? '',
serverValue: entry.attributes?.server_value ?? '',
defaultValue: entry.attributes?.default_value ?? '',
isEditable: entry.attributes?.is_editable ?? false,
}));
}
async updateStartupVariable(serverId: string, envVariable: string, value: string): Promise<void> {
await this.request(
'startup variable update',
`/servers/${encodeURIComponent(serverId)}/startup/variable`,
{ method: 'PUT', body: { key: envVariable, value } },
);
}
private mapSchedule(attributes: PterodactylScheduleAttributes): ServerScheduleSummary {
const cron = attributes.cron ?? {};
const tasks: ServerScheduleTask[] = (attributes.relationships?.tasks?.data ?? []).map(
(task) => ({
id: String(task.attributes?.id ?? ''),
action: task.attributes?.action ?? '',
payload: task.attributes?.payload ?? '',
timeOffsetSeconds: task.attributes?.time_offset ?? 0,
continueOnFailure: task.attributes?.continue_on_failure ?? false,
}),
);
return {
id: String(attributes.id ?? ''),
name: attributes.name ?? 'Untitled schedule',
isActive: attributes.is_active ?? false,
onlyWhenOnline: attributes.only_when_online ?? false,
minute: cron.minute ?? '*',
hour: cron.hour ?? '*',
dayOfMonth: cron.day_of_month ?? '*',
month: cron.month ?? '*',
dayOfWeek: cron.day_of_week ?? '*',
nextRunAt: attributes.next_run_at ?? null,
lastRunAt: attributes.last_run_at ?? null,
createdAt: attributes.created_at ?? null,
updatedAt: attributes.updated_at ?? null,
tasks,
};
}
private scheduleBody(input: RestartScheduleInput) {
return {
name: input.name,
is_active: input.isActive,
minute: String(input.minute),
hour: String(input.hour),
day_of_month: '*',
month: '*',
day_of_week: input.dayOfWeek,
only_when_online: input.onlyWhenOnline,
};
}
async listSchedules(serverId: string): Promise<ServerScheduleSummary[]> {
const data = await this.request<{
data?: { attributes?: PterodactylScheduleAttributes }[];
}>('schedules', `/servers/${encodeURIComponent(serverId)}/schedules?include=tasks`);
return (data.data ?? []).map((entry) => this.mapSchedule(entry.attributes ?? {}));
}
async createRestartSchedule(
serverId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const created = await this.request<PterodactylScheduleResponse>(
'schedule create',
`/servers/${encodeURIComponent(serverId)}/schedules`,
{ method: 'POST', body: this.scheduleBody(input) },
);
const schedule = this.mapSchedule(created.data?.attributes ?? {});
if (!schedule.id) {
throw ApiError.upstream('Pterodactyl did not return the created schedule id.');
}
await this.request(
'schedule task create',
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(schedule.id)}/tasks`,
{
method: 'POST',
body: {
action: 'power',
payload: 'restart',
time_offset: 0,
continue_on_failure: false,
},
},
);
const [withTasks] = (await this.listSchedules(serverId)).filter((s) => s.id === schedule.id);
return withTasks ?? schedule;
}
async updateRestartSchedule(
serverId: string,
scheduleId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary> {
const updated = await this.request<PterodactylScheduleResponse>(
'schedule update',
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`,
{ method: 'PATCH', body: this.scheduleBody(input) },
);
return this.mapSchedule(updated.data?.attributes ?? {});
}
async deleteSchedule(serverId: string, scheduleId: string): Promise<void> {
await this.request(
'schedule delete',
`/servers/${encodeURIComponent(serverId)}/schedules/${encodeURIComponent(scheduleId)}`,
{ method: 'DELETE' },
);
}
private async statFile(
serverId: string,
path: string,
): Promise<{ sizeBytes: number; modifiedAt: Date | null } | null> {
const directory = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/';
const fileName = path.slice(path.lastIndexOf('/') + 1);
try {
const entries = await this.listFiles(serverId, directory);
const match = entries.find((entry) => entry.isFile && entry.name === fileName);
return match ? { sizeBytes: match.sizeBytes, modifiedAt: match.modifiedAt } : null;
} catch {
return null;
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import type {
RestartScheduleInput,
ServerScheduleSummary,
ServerStatus,
} from '@reforger-panel/shared';
export type ProviderServerResources = {
status: ServerStatus;
cpuPercent: number;
cpuLimitPercent: number | null;
memoryBytes: number;
memoryLimitBytes: number | null;
diskBytes: number;
diskLimitBytes: number | null;
networkRxBytes: number;
networkTxBytes: number;
uptimeMs: number;
};
export type ServerFileEntry = {
name: string;
isFile: boolean;
sizeBytes: number;
modifiedAt: Date | null;
};
export type DownloadableFile = {
path: string;
content: string;
/** Size of the file on the remote, if known (may exceed content length when capped). */
totalSizeBytes: number | null;
/** Byte offset of content[0] within the remote file. Non-zero when the head was trimmed. */
contentStartOffset: number;
truncated: boolean;
};
/**
* Abstraction over the game-server backend (Pterodactyl Client API in
* production, an in-process mock for local development). Deliberately narrow:
* no arbitrary writes, no console execution.
*/
export interface GameServerProvider {
getServerStatus(serverId: string): Promise<ServerStatus>;
getServerResources(serverId: string): Promise<ProviderServerResources>;
startServer(serverId: string): Promise<void>;
stopServer(serverId: string): Promise<void>;
restartServer(serverId: string): Promise<void>;
listFiles(serverId: string, directory: string): Promise<ServerFileEntry[]>;
getFileDownloadUrl(serverId: string, path: string): Promise<string>;
downloadTextFile(serverId: string, path: string, maxBytes?: number): Promise<DownloadableFile>;
/**
* Writes a text file. NOT exposed as a generic panel endpoint: the only
* callers write server-generated content to paths from server configuration
* (config.json updates and their backups), never user-supplied paths.
*/
writeTextFile(serverId: string, path: string, content: string): Promise<void>;
/** Egg startup variables (Pterodactyl "Startup" tab). May contain secrets. */
listStartupVariables(serverId: string): Promise<StartupVariableEntry[]>;
updateStartupVariable(serverId: string, envVariable: string, value: string): Promise<void>;
/** Native Pterodactyl schedules, scoped here to restart schedule management. */
listSchedules(serverId: string): Promise<ServerScheduleSummary[]>;
createRestartSchedule(
serverId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary>;
updateRestartSchedule(
serverId: string,
scheduleId: string,
input: RestartScheduleInput,
): Promise<ServerScheduleSummary>;
deleteSchedule(serverId: string, scheduleId: string): Promise<void>;
}
export type StartupVariableEntry = {
name: string;
description: string;
envVariable: string;
serverValue: string;
defaultValue: string;
isEditable: boolean;
};
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { BOUNDED_TAIL_BYTES, hashLine, planIngestion, type CursorState } from './cursor-service.js';
function snapshot(content: string, opts: { startOffset?: number; totalSize?: number } = {}) {
const buffer = Buffer.from(content, 'utf8');
return {
buffer,
contentStartOffset: opts.startOffset ?? 0,
totalSizeBytes: opts.totalSize ?? (opts.startOffset ?? 0) + buffer.byteLength,
};
}
function cursorFor(consumed: string, extra: Partial<CursorState> = {}): CursorState {
const lines = consumed.endsWith('\n') ? consumed.slice(0, -1).split('\n') : consumed.split('\n');
return {
fileFingerprint: null,
lastByteOffset: Buffer.byteLength(consumed, 'utf8'),
lastLineHash: hashLine(lines.at(-1) ?? ''),
partialTrailingLine: null,
...extra,
};
}
describe('planIngestion', () => {
it('first sync processes the whole (small) file', () => {
const plan = planIngestion(null, snapshot('line1\nline2\n'));
expect(plan.reason).toBe('first_sync');
expect(plan.cursorReset).toBe(false);
expect(plan.chunk).toBe('line1\nline2\n');
expect(plan.nextByteOffset).toBe(12);
});
it('first sync bounds a huge file to a tail starting at a line boundary', () => {
const bigLine = 'x'.repeat(1000) + '\n';
const content = bigLine.repeat(600); // ~600 KB > BOUNDED_TAIL_BYTES
const plan = planIngestion(null, snapshot(content));
expect(Buffer.byteLength(plan.chunk)).toBeLessThanOrEqual(BOUNDED_TAIL_BYTES);
expect(plan.chunk.startsWith('x')).toBe(true);
expect(plan.chunk.endsWith('\n')).toBe(true);
expect(plan.nextByteOffset).toBe(Buffer.byteLength(content));
});
it('normal append processes only new content', () => {
const consumed = 'line1\nline2\n';
const appended = 'line3\nline4\n';
const plan = planIngestion(cursorFor(consumed), snapshot(consumed + appended));
expect(plan.reason).toBe('append');
expect(plan.chunk).toBe(appended);
expect(plan.cursorReset).toBe(false);
});
it('prepends a stored partial trailing line to new content', () => {
const consumed = 'line1\npart';
const cursor = cursorFor(consumed, {
partialTrailingLine: 'part',
lastLineHash: hashLine('line1'),
});
const plan = planIngestion(cursor, snapshot('line1\npartial-done\nline3\n'));
expect(plan.reason).toBe('append');
expect(plan.chunk).toBe('partial-done\nline3\n');
});
it('reports no new data when the file has not grown', () => {
const consumed = 'line1\nline2\n';
const plan = planIngestion(cursorFor(consumed), snapshot(consumed));
expect(plan.reason).toBe('no_new_data');
expect(plan.chunk).toBe('');
expect(plan.cursorReset).toBe(false);
});
it('resets on rotation (file shrank)', () => {
const cursor = cursorFor('a'.repeat(5000) + '\n');
const plan = planIngestion(cursor, snapshot('fresh1\nfresh2\n'));
expect(plan.reason).toBe('rotation');
expect(plan.cursorReset).toBe(true);
expect(plan.chunk).toBe('fresh1\nfresh2\n');
});
it('resets when the fingerprint (first line) changed despite a larger file', () => {
const oldContent = 'old-header\nold-line\n';
const cursor = cursorFor(oldContent, { fileFingerprint: hashLine('old-header') });
const newContent = 'new-header-longer\nnew-line-1\nnew-line-2\n';
const plan = planIngestion(cursor, snapshot(newContent));
expect(plan.reason).toBe('rotation');
expect(plan.cursorReset).toBe(true);
});
it('resets on continuity mismatch (replaced file, same-or-larger size, no visible head)', () => {
const consumed = 'line1\nline2\n';
const cursor = cursorFor(consumed);
// Same length as consumed but different content before the cut.
const replaced = 'lineX\nlineZ\nline3\n';
const plan = planIngestion(cursor, snapshot(replaced, { startOffset: 0, totalSize: 100 }));
// fingerprint check triggers first only if cursor had one; here continuity check fires
expect(['continuity_mismatch', 'rotation']).toContain(plan.reason);
expect(plan.cursorReset).toBe(true);
});
it('processes a bounded tail when the download window skipped past the cursor', () => {
const cursor = cursorFor('early\n'); // offset 6
const plan = planIngestion(
cursor,
snapshot('tail-line-1\ntail-line-2\n', { startOffset: 10_000, totalSize: 10_024 }),
);
expect(plan.reason).toBe('gap');
expect(plan.cursorReset).toBe(true);
// Head-cut downloads drop the first partial line.
expect(plan.chunk).toBe('tail-line-2\n');
});
it('advances the cursor across consecutive appends', () => {
let content = 'l1\n';
let cursor: CursorState | null = null;
const offsets: number[] = [];
for (const next of ['l2\n', 'l3\n', 'l4\n']) {
const plan = planIngestion(cursor, snapshot(content));
offsets.push(plan.nextByteOffset);
const lines = content.slice(0, plan.nextByteOffset);
cursor = cursorFor(lines, { fileFingerprint: null });
content += next;
}
expect(offsets).toEqual([3, 6, 9]);
});
});
@@ -0,0 +1,145 @@
import { sha256Hex } from '../../../lib/crypto.js';
/** How much history to import when seeing a file for the first time (or after rotation). */
export const BOUNDED_TAIL_BYTES = 512 * 1024;
export type CursorState = {
fileFingerprint: string | null;
lastByteOffset: number;
lastLineHash: string | null;
partialTrailingLine: string | null;
};
export type FileSnapshot = {
/** Raw downloaded bytes (possibly only the tail of the remote file). */
buffer: Buffer;
/** Byte offset of buffer[0] within the remote file. */
contentStartOffset: number;
/** Total remote file size if known. */
totalSizeBytes: number | null;
};
export type IngestionPlan = {
/** Text to parse this sync, starting at a line boundary. */
chunk: string;
/** Cursor byte offset to record after a successful parse. */
nextByteOffset: number;
/** True when the cursor was reset (first sync, rotation, truncation, or mismatch). */
cursorReset: boolean;
reason: 'first_sync' | 'append' | 'no_new_data' | 'rotation' | 'continuity_mismatch' | 'gap';
};
export function hashLine(line: string): string {
return sha256Hex(line);
}
/** Extract the final complete line of a buffer region (for continuity checks). */
function lastCompleteLineBefore(buffer: Buffer, end: number): string | null {
if (end <= 0) return null;
const region = buffer.subarray(0, end);
const text = region.toString('utf8');
const withoutTrailing = text.endsWith('\n') ? text.slice(0, -1) : text;
const lastNewline = withoutTrailing.lastIndexOf('\n');
const line = lastNewline >= 0 ? withoutTrailing.slice(lastNewline + 1) : withoutTrailing;
return line.replace(/\r$/, '');
}
/** Skip a leading partial line after an arbitrary byte cut. */
function alignToNextLine(buffer: Buffer): Buffer {
const newlineIndex = buffer.indexOf(0x0a);
if (newlineIndex === -1) return Buffer.alloc(0);
return buffer.subarray(newlineIndex + 1);
}
function boundedTail(snapshot: FileSnapshot, reason: IngestionPlan['reason']): IngestionPlan {
let region = snapshot.buffer;
let cutInsideLine = snapshot.contentStartOffset > 0;
if (region.byteLength > BOUNDED_TAIL_BYTES) {
region = region.subarray(region.byteLength - BOUNDED_TAIL_BYTES);
cutInsideLine = true;
}
if (cutInsideLine) {
region = alignToNextLine(region);
}
return {
chunk: region.toString('utf8'),
nextByteOffset: snapshot.contentStartOffset + snapshot.buffer.byteLength,
cursorReset: reason !== 'first_sync',
reason,
};
}
/**
* Decide what portion of the downloaded file to parse, handling first sync,
* normal append, rotation/truncation/replacement, and download gaps.
*
* The fingerprint is the hash of the file's first line when the download
* includes the start of the file; it changes when the file is replaced even
* if the new file is larger than the old offset.
*/
export function planIngestion(cursor: CursorState | null, snapshot: FileSnapshot): IngestionPlan {
const fileEnd = snapshot.contentStartOffset + snapshot.buffer.byteLength;
if (!cursor) {
return boundedTail(snapshot, 'first_sync');
}
const totalSize = snapshot.totalSizeBytes ?? fileEnd;
// Rotation / truncation: the file shrank below what we already consumed.
if (totalSize < cursor.lastByteOffset) {
return boundedTail(snapshot, 'rotation');
}
// Replacement detection via fingerprint (only when we can see the file head).
const fingerprint = computeFingerprint(snapshot);
if (fingerprint && cursor.fileFingerprint && fingerprint !== cursor.fileFingerprint) {
return boundedTail(snapshot, 'rotation');
}
// The download window no longer reaches back to our cursor (file grew more
// than maxBytes between syncs). Process what we have; some lines were lost.
if (cursor.lastByteOffset < snapshot.contentStartOffset) {
return boundedTail(snapshot, 'gap');
}
const cutIndex = cursor.lastByteOffset - snapshot.contentStartOffset;
if (cutIndex >= snapshot.buffer.byteLength) {
return {
chunk: '',
nextByteOffset: cursor.lastByteOffset,
cursorReset: false,
reason: 'no_new_data',
};
}
// Continuity check: the content just before the cut must be what we last
// saw; otherwise the file was replaced by a same-size-or-larger one.
if (cursor.partialTrailingLine !== null && cursor.partialTrailingLine !== '') {
const fragment = lastCompleteLineBefore(snapshot.buffer, cutIndex);
if (fragment !== null && cutIndex > 0 && !cursor.partialTrailingLine.endsWith(fragment)) {
return boundedTail(snapshot, 'continuity_mismatch');
}
} else if (cursor.lastLineHash) {
const previousLine = lastCompleteLineBefore(snapshot.buffer, cutIndex);
if (previousLine !== null && hashLine(previousLine) !== cursor.lastLineHash) {
return boundedTail(snapshot, 'continuity_mismatch');
}
}
const newRegion = snapshot.buffer.subarray(cutIndex);
const chunk = (cursor.partialTrailingLine ?? '') + newRegion.toString('utf8');
return {
chunk,
nextByteOffset: fileEnd,
cursorReset: false,
reason: 'append',
};
}
export function computeFingerprint(snapshot: FileSnapshot): string | null {
if (snapshot.contentStartOffset !== 0) return null;
const firstNewline = snapshot.buffer.indexOf(0x0a);
if (firstNewline === -1) return null;
return sha256Hex(snapshot.buffer.subarray(0, firstNewline).toString('utf8'));
}
@@ -0,0 +1,196 @@
import { and, eq, isNull } from 'drizzle-orm';
import type { Db } from '../../../db/client.js';
import { schema } from '../../../db/client.js';
import type {
CursorRecord,
IngestionStore,
NewServerEvent,
OpenSessionRecord,
PlayerRecord,
} from './types.js';
export class DrizzleIngestionStore implements IngestionStore {
constructor(private readonly db: Db) {}
async getCursor(serverId: string, logPath: string): Promise<CursorRecord | null> {
const rows = await this.db
.select()
.from(schema.logCursors)
.where(and(eq(schema.logCursors.serverId, serverId), eq(schema.logCursors.logPath, logPath)));
const row = rows[0];
if (!row) return null;
return {
serverId: row.serverId,
logPath: row.logPath,
fileFingerprint: row.fileFingerprint,
lastByteOffset: row.lastByteOffset,
lastLineHash: row.lastLineHash,
partialTrailingLine: row.partialTrailingLine,
lastEventTimestamp: row.lastEventTimestamp,
lastSuccessfulSyncAt: row.lastSuccessfulSyncAt,
lastErrorAt: row.lastErrorAt,
lastErrorMessage: row.lastErrorMessage,
};
}
async saveCursor(cursor: CursorRecord): Promise<void> {
await this.db
.insert(schema.logCursors)
.values(cursor)
.onConflictDoUpdate({
target: [schema.logCursors.serverId, schema.logCursors.logPath],
set: {
fileFingerprint: cursor.fileFingerprint,
lastByteOffset: cursor.lastByteOffset,
lastLineHash: cursor.lastLineHash,
partialTrailingLine: cursor.partialTrailingLine,
lastEventTimestamp: cursor.lastEventTimestamp,
lastSuccessfulSyncAt: cursor.lastSuccessfulSyncAt,
lastErrorAt: cursor.lastErrorAt,
lastErrorMessage: cursor.lastErrorMessage,
},
});
}
async insertEventIfNew(
event: NewServerEvent,
): Promise<{ created: boolean; eventId: string | null }> {
const rows = await this.db
.insert(schema.serverEvents)
.values({
serverId: event.serverId,
eventType: event.eventType,
occurredAt: event.occurredAt,
playerId: event.playerId ?? null,
playerSessionId: event.playerSessionId ?? null,
summary: event.summary,
payload: event.payload,
sourceLogPath: event.sourceLogPath,
sourceLineHash: event.sourceLineHash,
})
.onConflictDoNothing({
target: [
schema.serverEvents.serverId,
schema.serverEvents.sourceLogPath,
schema.serverEvents.sourceLineHash,
],
})
.returning({ id: schema.serverEvents.id });
return { created: rows.length > 0, eventId: rows[0]?.id ?? null };
}
async findPlayerByExternalId(
serverId: string,
externalPlayerId: string,
): Promise<PlayerRecord | null> {
const rows = await this.db
.select()
.from(schema.players)
.where(
and(
eq(schema.players.serverId, serverId),
eq(schema.players.externalPlayerId, externalPlayerId),
),
);
return rows[0] ?? null;
}
async findPlayerByName(serverId: string, displayName: string): Promise<PlayerRecord | null> {
const rows = await this.db
.select()
.from(schema.players)
.where(
and(eq(schema.players.serverId, serverId), eq(schema.players.displayName, displayName)),
)
.limit(1);
return rows[0] ?? null;
}
async createPlayer(input: {
serverId: string;
displayName: string;
externalPlayerId: string | null;
seenAt: Date;
}): Promise<PlayerRecord> {
const [row] = await this.db
.insert(schema.players)
.values({
serverId: input.serverId,
displayName: input.displayName,
externalPlayerId: input.externalPlayerId,
firstSeenAt: input.seenAt,
lastSeenAt: input.seenAt,
})
.returning();
return row!;
}
async updatePlayer(
playerId: string,
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
): Promise<void> {
await this.db.update(schema.players).set(patch).where(eq(schema.players.id, playerId));
}
async getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null> {
const rows = await this.db
.select()
.from(schema.playerSessions)
.where(
and(
eq(schema.playerSessions.serverId, serverId),
eq(schema.playerSessions.playerId, playerId),
isNull(schema.playerSessions.disconnectedAt),
),
);
const row = rows[0];
return row ? { id: row.id, playerId: row.playerId, connectedAt: row.connectedAt } : null;
}
async openSession(input: {
serverId: string;
playerId: string;
connectedAt: Date;
sourceLogPath: string;
}): Promise<OpenSessionRecord> {
const [row] = await this.db.insert(schema.playerSessions).values(input).returning();
return { id: row!.id, playerId: row!.playerId, connectedAt: row!.connectedAt };
}
async closeSession(
sessionId: string,
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
): Promise<void> {
await this.db
.update(schema.playerSessions)
.set(input)
.where(eq(schema.playerSessions.id, sessionId));
}
async closeAllOpenSessions(
serverId: string,
disconnectedAt: Date,
reason: string,
): Promise<{ closed: number }> {
const open = await this.db
.select()
.from(schema.playerSessions)
.where(
and(
eq(schema.playerSessions.serverId, serverId),
isNull(schema.playerSessions.disconnectedAt),
),
);
for (const session of open) {
await this.closeSession(session.id, {
disconnectedAt,
durationSeconds: Math.max(
0,
Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000),
),
disconnectReason: reason,
});
}
return { closed: open.length };
}
}
@@ -0,0 +1,371 @@
import type { LogSyncResult } from '@reforger-panel/shared';
import { sanitizeErrorMessage, type Logger } from '../../../lib/logger.js';
import { parseLogChunk } from '../parser/parser.js';
import type { ParsedLogEvent } from '../parser/types.js';
import { computeFingerprint, hashLine, planIngestion } from './cursor-service.js';
import { dateFromLogPath } from './log-path-resolver.js';
import type { CursorRecord, IngestionStore, LogSource, PlayerRecord } from './types.js';
export type IngestionOptions = {
maxDownloadBytes: number;
};
export type SyncStats = LogSyncResult & {
ignoredLines: number;
invalidTimestamps: number;
reason: string;
};
/**
* Turns raw Reforger log content into player/session/event records.
* Orchestrates: fetch (LogSource) plan (cursor-service) parse (parser)
* persist (IngestionStore). Holds no state between runs beyond the cursor.
*/
export class LogIngestionService {
constructor(
private readonly source: LogSource,
private readonly store: IngestionStore,
private readonly logger: Logger,
private readonly options: IngestionOptions,
) {}
async sync(serverId: string, providerServerId: string, logPath: string): Promise<SyncStats> {
const startedAt = new Date();
try {
const stats = await this.runSync(serverId, providerServerId, logPath, startedAt);
this.logger.debug({ ...stats }, 'log sync completed');
return stats;
} catch (error) {
const message = sanitizeErrorMessage(error);
await this.recordFailure(serverId, logPath, message).catch(() => undefined);
this.logger.warn({ serverId, logPath, error: message }, 'log sync failed');
throw error;
}
}
private async runSync(
serverId: string,
providerServerId: string,
logPath: string,
startedAt: Date,
): Promise<SyncStats> {
const file = await this.source.fetchLog(
providerServerId,
logPath,
this.options.maxDownloadBytes,
);
const buffer = Buffer.from(file.content, 'utf8');
const snapshot = {
buffer,
contentStartOffset: file.contentStartOffset,
totalSizeBytes: file.totalSizeBytes,
};
const cursor = await this.store.getCursor(serverId, logPath);
const plan = planIngestion(cursor, snapshot);
// Continuation chunks have no "Log started" header, so carry the calendar
// date forward from the last ingested event — or, failing that, from the
// dated per-boot folder name in the log path. A header in the chunk
// (fresh file after rotation) still overrides this.
const previousTimestamp =
(!plan.cursorReset ? (cursor?.lastEventTimestamp ?? null) : null) ?? dateFromLogPath(logPath);
const parsed = parseLogChunk(plan.chunk, {
fallbackDate: new Date(),
context: previousTimestamp
? {
baseDate: new Date(
Date.UTC(
previousTimestamp.getUTCFullYear(),
previousTimestamp.getUTCMonth(),
previousTimestamp.getUTCDate(),
),
),
lastTimestamp: previousTimestamp,
}
: undefined,
});
let createdEvents = 0;
let updatedSessions = 0;
for (const event of parsed.events) {
const result = await this.applyEvent(serverId, logPath, event);
createdEvents += result.createdEvents;
updatedSessions += result.updatedSessions;
}
const lastEvent = parsed.events.at(-1);
const fingerprint =
computeFingerprint(snapshot) ?? (plan.cursorReset ? null : (cursor?.fileFingerprint ?? null));
const nextCursor: CursorRecord = {
serverId,
logPath,
fileFingerprint: fingerprint,
lastByteOffset: plan.nextByteOffset,
lastLineHash: parsed.lastCompleteLine
? hashLine(parsed.lastCompleteLine)
: plan.cursorReset || plan.reason === 'first_sync'
? null
: (cursor?.lastLineHash ?? null),
partialTrailingLine:
parsed.partialTrailingLine ??
(plan.reason === 'no_new_data' ? (cursor?.partialTrailingLine ?? null) : null),
lastEventTimestamp: lastEvent?.occurredAt ?? cursor?.lastEventTimestamp ?? null,
lastSuccessfulSyncAt: new Date(),
lastErrorAt: null,
lastErrorMessage: null,
};
await this.store.saveCursor(nextCursor);
return {
serverId,
logPath,
fetchedBytes: buffer.byteLength,
processedLines: parsed.completeLineCount,
createdEvents,
updatedSessions,
cursorReset: plan.cursorReset,
startedAt: startedAt.toISOString(),
finishedAt: new Date().toISOString(),
ignoredLines: parsed.ignoredLineCount,
invalidTimestamps: parsed.invalidTimestampCount,
reason: plan.reason,
};
}
private async recordFailure(serverId: string, logPath: string, message: string): Promise<void> {
const cursor = await this.store.getCursor(serverId, logPath);
await this.store.saveCursor({
serverId,
logPath,
fileFingerprint: cursor?.fileFingerprint ?? null,
lastByteOffset: cursor?.lastByteOffset ?? 0,
lastLineHash: cursor?.lastLineHash ?? null,
partialTrailingLine: cursor?.partialTrailingLine ?? null,
lastEventTimestamp: cursor?.lastEventTimestamp ?? null,
lastSuccessfulSyncAt: cursor?.lastSuccessfulSyncAt ?? null,
lastErrorAt: new Date(),
lastErrorMessage: message,
});
}
private async resolvePlayer(
serverId: string,
event: Extract<
ParsedLogEvent,
{ type: 'player_connected' | 'player_disconnected' | 'player_identity' }
>,
): Promise<PlayerRecord> {
// Prefer the stable log-provided identity; fall back to display name.
// Names are NOT globally unique — see README for the limitations.
if (event.type === 'player_identity' || event.externalPlayerId) {
const externalId = event.externalPlayerId!;
const byExternal = await this.store.findPlayerByExternalId(serverId, externalId);
if (byExternal) {
if (byExternal.displayName !== event.playerName) {
await this.store.updatePlayer(byExternal.id, {
displayName: event.playerName,
lastSeenAt: event.occurredAt,
});
}
return byExternal;
}
const byName = await this.store.findPlayerByName(serverId, event.playerName);
if (byName && byName.externalPlayerId === null) {
await this.store.updatePlayer(byName.id, {
externalPlayerId: externalId,
lastSeenAt: event.occurredAt,
});
return { ...byName, externalPlayerId: externalId };
}
if (byName) {
// The player already carries a different identity (e.g. engine
// identityId vs BattlEye GUID — the logs emit both). Keep the first
// one rather than splitting the player into duplicates.
await this.store.updatePlayer(byName.id, { lastSeenAt: event.occurredAt });
return byName;
}
return this.store.createPlayer({
serverId,
displayName: event.playerName,
externalPlayerId: externalId,
seenAt: event.occurredAt,
});
}
const byName = await this.store.findPlayerByName(serverId, event.playerName);
if (byName) {
await this.store.updatePlayer(byName.id, { lastSeenAt: event.occurredAt });
return byName;
}
return this.store.createPlayer({
serverId,
displayName: event.playerName,
externalPlayerId: null,
seenAt: event.occurredAt,
});
}
private async resolvePlayerByName(
serverId: string,
playerName: string,
occurredAt: Date,
): Promise<PlayerRecord> {
const byName = await this.store.findPlayerByName(serverId, playerName);
if (byName) {
await this.store.updatePlayer(byName.id, { lastSeenAt: occurredAt });
return byName;
}
return this.store.createPlayer({
serverId,
displayName: playerName,
externalPlayerId: null,
seenAt: occurredAt,
});
}
private async applyEvent(
serverId: string,
logPath: string,
event: ParsedLogEvent,
): Promise<{ createdEvents: number; updatedSessions: number }> {
const lineHash = hashLine(event.rawLine);
switch (event.type) {
case 'player_connected': {
const player = await this.resolvePlayer(serverId, event);
const inserted = await this.store.insertEventIfNew({
serverId,
eventType: 'player_connected',
occurredAt: event.occurredAt,
playerId: player.id,
summary: `${event.playerName} connected`,
payload: { playerName: event.playerName, playerNumber: event.playerNumber ?? null },
sourceLogPath: logPath,
sourceLineHash: lineHash,
});
if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 };
// A connect while a session is open means we missed the disconnect.
const existing = await this.store.getOpenSession(serverId, player.id);
let updatedSessions = 0;
if (existing) {
await this.store.closeSession(existing.id, {
disconnectedAt: event.occurredAt,
durationSeconds: Math.max(
0,
Math.round((event.occurredAt.getTime() - existing.connectedAt.getTime()) / 1000),
),
disconnectReason: 'missed_disconnect',
});
updatedSessions += 1;
}
await this.store.openSession({
serverId,
playerId: player.id,
connectedAt: event.occurredAt,
sourceLogPath: logPath,
});
return { createdEvents: 1, updatedSessions: updatedSessions + 1 };
}
case 'player_identity': {
// Identity lines only enrich the player record; they are not events.
await this.resolvePlayer(serverId, event);
return { createdEvents: 0, updatedSessions: 0 };
}
case 'player_disconnected': {
const player = await this.resolvePlayer(serverId, event);
const inserted = await this.store.insertEventIfNew({
serverId,
eventType: 'player_disconnected',
occurredAt: event.occurredAt,
playerId: player.id,
summary: event.reason
? `${event.playerName} disconnected (${event.reason})`
: `${event.playerName} disconnected`,
payload: { playerName: event.playerName, reason: event.reason ?? null },
sourceLogPath: logPath,
sourceLineHash: lineHash,
});
if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 };
const open = await this.store.getOpenSession(serverId, player.id);
if (!open) return { createdEvents: 1, updatedSessions: 0 };
await this.store.closeSession(open.id, {
disconnectedAt: event.occurredAt,
durationSeconds: Math.max(
0,
Math.round((event.occurredAt.getTime() - open.connectedAt.getTime()) / 1000),
),
disconnectReason: event.reason ?? null,
});
return { createdEvents: 1, updatedSessions: 1 };
}
case 'player_killed': {
const killer = await this.resolvePlayerByName(serverId, event.killerName, event.occurredAt);
const victim = await this.resolvePlayerByName(serverId, event.victimName, event.occurredAt);
const inserted = await this.store.insertEventIfNew({
serverId,
eventType: 'player_killed',
occurredAt: event.occurredAt,
playerId: victim.id,
summary: `${event.killerName} killed ${event.victimName}`,
payload: {
killerPlayerId: killer.id,
killerName: event.killerName,
victimPlayerId: victim.id,
victimName: event.victimName,
friendly: event.friendly,
killerTeam: null,
victimTeam: null,
killerPosition: null,
victimPosition: null,
distanceMeters: null,
weapon: null,
},
sourceLogPath: logPath,
sourceLineHash: lineHash,
});
return { createdEvents: inserted.created ? 1 : 0, updatedSessions: 0 };
}
case 'server_started': {
const inserted = await this.store.insertEventIfNew({
serverId,
eventType: 'server_started',
occurredAt: event.occurredAt,
summary: 'Server started',
payload: {},
sourceLogPath: logPath,
sourceLineHash: lineHash,
});
if (!inserted.created) return { createdEvents: 0, updatedSessions: 0 };
// Sessions can't survive a server start; anything still open was
// orphaned by a crash/restart we didn't see a disconnect for.
const { closed } = await this.store.closeAllOpenSessions(
serverId,
event.occurredAt,
'server_restart',
);
let createdEvents = 1;
if (closed > 0) {
const restartInserted = await this.store.insertEventIfNew({
serverId,
eventType: 'server_restart_detected',
occurredAt: event.occurredAt,
summary: `Server restart detected (${closed} session${closed === 1 ? '' : 's'} closed)`,
payload: { closedSessions: closed },
sourceLogPath: logPath,
sourceLineHash: `${lineHash}:restart`,
});
if (restartInserted.created) createdEvents += 1;
}
return { createdEvents, updatedSessions: closed };
}
}
}
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { createLogPathResolver, dateFromLogPath } from './log-path-resolver.js';
import type { GameServerProvider, ServerFileEntry } from '../../pterodactyl/types.js';
function providerWithListing(entries: ServerFileEntry[]): GameServerProvider {
return {
listFiles: async () => entries,
} as unknown as GameServerProvider;
}
function dir(name: string, modifiedAt: Date | null): ServerFileEntry {
return { name, isFile: false, sizeBytes: 0, modifiedAt };
}
describe('dateFromLogPath', () => {
it('extracts the session start time from dated folder names', () => {
expect(
dateFromLogPath('/profile/logs/logs_2026-07-04_12-54-04/console.log')?.toISOString(),
).toBe('2026-07-04T12:54:04.000Z');
});
it('returns null for paths without a dated folder', () => {
expect(dateFromLogPath('/profile/logs/console.log')).toBeNull();
});
});
describe('createLogPathResolver', () => {
it('uses the explicit path when configured, without listing files', async () => {
const resolve = createLogPathResolver({
provider: providerWithListing([]),
providerServerId: 'x',
explicitPath: '/profile/logs/pinned.log',
directory: '/profile/logs',
fileName: 'console.log',
});
expect(await resolve()).toBe('/profile/logs/pinned.log');
});
it('picks the newest dated logs_* folder', async () => {
const resolve = createLogPathResolver({
provider: providerWithListing([
dir('logs_2026-07-04_12-54-04', new Date('2026-07-04T12:54:04Z')),
dir('logs_2026-07-05_08-10-00', new Date('2026-07-05T08:10:00Z')),
dir('backups', new Date('2026-07-05T09:00:00Z')),
]),
providerServerId: 'x',
explicitPath: '',
directory: '/profile/logs',
fileName: 'console.log',
});
expect(await resolve()).toBe('/profile/logs/logs_2026-07-05_08-10-00/console.log');
});
it('falls back to name ordering when modified times are missing', async () => {
const resolve = createLogPathResolver({
provider: providerWithListing([
dir('logs_2026-07-03_23-00-00', null),
dir('logs_2026-07-05_01-00-00', null),
]),
providerServerId: 'x',
explicitPath: '',
directory: '/profile/logs',
fileName: 'console.log',
});
expect(await resolve()).toBe('/profile/logs/logs_2026-07-05_01-00-00/console.log');
});
it('prefers a stable file directly in the directory', async () => {
const resolve = createLogPathResolver({
provider: providerWithListing([
{ name: 'console.log', isFile: true, sizeBytes: 10, modifiedAt: new Date() },
dir('logs_2026-07-05_01-00-00', new Date()),
]),
providerServerId: 'x',
explicitPath: '',
directory: '/profile/logs/',
fileName: 'console.log',
});
expect(await resolve()).toBe('/profile/logs/console.log');
});
it('returns null when nothing matches', async () => {
const resolve = createLogPathResolver({
provider: providerWithListing([dir('backups', new Date())]),
providerServerId: 'x',
explicitPath: '',
directory: '/profile/logs',
fileName: 'console.log',
});
expect(await resolve()).toBeNull();
});
});
@@ -0,0 +1,73 @@
import type { GameServerProvider } from '../../pterodactyl/types.js';
export type LogPathResolver = () => Promise<string | null>;
/** Directories the Reforger server creates per boot, e.g. logs_2026-07-04_12-54-04. */
const DATED_LOG_DIR_PATTERN = /^logs[_-]/i;
const LOG_PATH_DATE_PATTERN = /logs[_-](\d{4})-(\d{2})-(\d{2})[_-](\d{2})-(\d{2})-(\d{2})/i;
/**
* Reforger's per-boot folder names encode the session start time; use it to
* anchor line timestamps when the parsed chunk has no "Log started" header
* and no prior cursor context.
*/
export function dateFromLogPath(logPath: string): Date | null {
const match = LOG_PATH_DATE_PATTERN.exec(logPath);
if (!match) return null;
const [, year, month, day, hours, minutes, seconds] = match;
const date = new Date(
Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds),
),
);
return Number.isNaN(date.getTime()) ? null : date;
}
/**
* Resolves the current Reforger log file path.
*
* - `REFORGER_ADMIN_LOG_PATH` (explicit file) wins when set.
* - Otherwise `REFORGER_LOG_DIRECTORY` is listed on every sync and the newest
* dated `logs_*` subdirectory is used, so per-boot log folders are picked up
* automatically after restarts. `REFORGER_LOG_FILE_PATTERN` is the file name
* inside that directory (default `console.log`).
*/
export function createLogPathResolver(options: {
provider: GameServerProvider;
providerServerId: string;
explicitPath: string;
directory: string;
fileName: string;
}): LogPathResolver {
const fileName = options.fileName || 'console.log';
return async () => {
if (options.explicitPath) return options.explicitPath;
if (!options.directory) return null;
const directory = options.directory.replace(/\/$/, '');
const entries = await options.provider.listFiles(options.providerServerId, directory);
// A stable file directly in the directory takes priority.
if (entries.some((entry) => entry.isFile && entry.name === fileName)) {
return `${directory}/${fileName}`;
}
const datedDirs = entries.filter(
(entry) => !entry.isFile && DATED_LOG_DIR_PATTERN.test(entry.name),
);
if (datedDirs.length === 0) return null;
datedDirs.sort((a, b) => {
const byTime = (b.modifiedAt?.getTime() ?? 0) - (a.modifiedAt?.getTime() ?? 0);
// Names embed sortable timestamps (logs_YYYY-MM-DD_HH-MM-SS); use them
// as a tiebreaker when mtimes are missing or equal.
return byTime !== 0 ? byTime : b.name.localeCompare(a.name);
});
return `${directory}/${datedDirs[0]!.name}/${fileName}`;
};
}
@@ -0,0 +1,15 @@
import type { GameServerProvider } from '../../pterodactyl/types.js';
import type { LogSource } from './types.js';
/**
* LogSource backed by the game server provider (Pterodactyl Client API or the
* mock). Retrieval is size-capped tail download; if the panel ever needs
* range/tail requests, only this adapter changes.
*/
export class PterodactylLogSource implements LogSource {
constructor(private readonly provider: GameServerProvider) {}
fetchLog(serverId: string, logPath: string, maxBytes: number) {
return this.provider.downloadTextFile(serverId, logPath, maxBytes);
}
}
@@ -0,0 +1,120 @@
import { ApiError } from '../../../lib/errors.js';
import type { Logger } from '../../../lib/logger.js';
import type { LogIngestionService, SyncStats } from './ingestion-service.js';
import type { LogPathResolver } from './log-path-resolver.js';
export type ScheduledServer = {
serverId: string;
providerServerId: string;
/** Resolved on every sync so per-boot dated log folders are followed. */
resolveLogPath: LogPathResolver;
};
const MAX_BACKOFF_MULTIPLIER = 8;
/**
* Background polling loop. One timer per server, a per-server lock so syncs
* never overlap, exponential backoff after consecutive failures (to avoid
* hammering a broken Pterodactyl), and graceful shutdown that waits for
* in-flight syncs.
*/
export class IngestionScheduler {
private timers = new Map<string, ReturnType<typeof setTimeout>>();
private inFlight = new Map<string, Promise<void>>();
private failureCounts = new Map<string, number>();
private stopped = false;
private lastResults = new Map<string, SyncStats>();
constructor(
private readonly service: LogIngestionService,
private readonly logger: Logger,
private readonly intervalMs: number,
) {}
start(servers: ScheduledServer[]): void {
for (const server of servers) {
this.schedule(server, 1_000 + Math.floor(Math.random() * 2_000));
}
this.logger.info(
{ servers: servers.length, intervalSeconds: this.intervalMs / 1000 },
'log ingestion scheduler started',
);
}
/** Manually trigger a sync; shares the per-server lock with the poller. */
async syncNow(server: ScheduledServer): Promise<SyncStats> {
const existing = this.inFlight.get(server.serverId);
if (existing) {
await existing.catch(() => undefined);
}
let stats!: SyncStats;
const run = (async () => {
const logPath = await server.resolveLogPath();
if (!logPath) {
throw ApiError.notConfigured(
'Could not locate the current Reforger log file. Check REFORGER_LOG_DIRECTORY / REFORGER_ADMIN_LOG_PATH.',
);
}
stats = await this.service.sync(server.serverId, server.providerServerId, logPath);
})();
this.inFlight.set(server.serverId, run.catch(() => undefined) as Promise<void>);
try {
await run;
} finally {
this.inFlight.delete(server.serverId);
}
this.lastResults.set(server.serverId, stats);
return stats;
}
getLastResult(serverId: string): SyncStats | null {
return this.lastResults.get(serverId) ?? null;
}
private schedule(server: ScheduledServer, delayMs: number): void {
if (this.stopped) return;
const timer = setTimeout(() => void this.tick(server), delayMs);
timer.unref?.();
this.timers.set(server.serverId, timer);
}
private async tick(server: ScheduledServer): Promise<void> {
if (this.stopped) return;
if (this.inFlight.has(server.serverId)) {
this.schedule(server, this.intervalMs);
return;
}
const run = server
.resolveLogPath()
.then((logPath) => {
if (!logPath) {
throw new Error('no log path resolved');
}
return this.service.sync(server.serverId, server.providerServerId, logPath);
})
.then((stats) => {
this.lastResults.set(server.serverId, stats);
this.failureCounts.set(server.serverId, 0);
})
.catch(() => {
const failures = (this.failureCounts.get(server.serverId) ?? 0) + 1;
this.failureCounts.set(server.serverId, failures);
});
this.inFlight.set(server.serverId, run);
await run;
this.inFlight.delete(server.serverId);
const failures = this.failureCounts.get(server.serverId) ?? 0;
const multiplier = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
this.schedule(server, this.intervalMs * multiplier);
}
/** Stop scheduling and wait for any in-flight sync to finish. */
async stop(): Promise<void> {
this.stopped = true;
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
await Promise.allSettled(this.inFlight.values());
this.logger.info('log ingestion scheduler stopped');
}
}
@@ -0,0 +1,88 @@
import type { ServerEventType } from '@reforger-panel/shared';
import type { DownloadableFile } from '../../pterodactyl/types.js';
export type CursorRecord = {
serverId: string;
logPath: string;
fileFingerprint: string | null;
lastByteOffset: number;
lastLineHash: string | null;
partialTrailingLine: string | null;
lastEventTimestamp: Date | null;
lastSuccessfulSyncAt: Date | null;
lastErrorAt: Date | null;
lastErrorMessage: string | null;
};
export type PlayerRecord = {
id: string;
serverId: string;
externalPlayerId: string | null;
displayName: string;
};
export type OpenSessionRecord = {
id: string;
playerId: string;
connectedAt: Date;
};
export type NewServerEvent = {
serverId: string;
eventType: ServerEventType;
occurredAt: Date;
playerId?: string | null;
playerSessionId?: string | null;
summary: string;
payload: Record<string, unknown>;
sourceLogPath: string;
sourceLineHash: string;
};
/**
* Persistence boundary for log ingestion. Production uses Drizzle/Postgres;
* tests use an in-memory implementation.
*/
export interface IngestionStore {
getCursor(serverId: string, logPath: string): Promise<CursorRecord | null>;
saveCursor(cursor: CursorRecord): Promise<void>;
/** Returns created=false when the dedupe key already exists. */
insertEventIfNew(event: NewServerEvent): Promise<{ created: boolean; eventId: string | null }>;
findPlayerByExternalId(serverId: string, externalPlayerId: string): Promise<PlayerRecord | null>;
findPlayerByName(serverId: string, displayName: string): Promise<PlayerRecord | null>;
createPlayer(input: {
serverId: string;
displayName: string;
externalPlayerId: string | null;
seenAt: Date;
}): Promise<PlayerRecord>;
updatePlayer(
playerId: string,
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
): Promise<void>;
getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null>;
openSession(input: {
serverId: string;
playerId: string;
connectedAt: Date;
sourceLogPath: string;
}): Promise<OpenSessionRecord>;
closeSession(
sessionId: string,
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
): Promise<void>;
/** Close every open session on the server (used when a fresh server start is seen). */
closeAllOpenSessions(
serverId: string,
disconnectedAt: Date,
reason: string,
): Promise<{ closed: number }>;
}
/** Source of log file bytes; production wraps the Pterodactyl provider. */
export interface LogSource {
fetchLog(serverId: string, logPath: string, maxBytes: number): Promise<DownloadableFile>;
}
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { mergeMissions, parseMissionList, scenariosFromWorkshopMod } from './missions-catalog.js';
// Verbatim shape from a real console.log (server runs with -listScenarios).
const LOG = [
'12:54:28.215 SCRIPT : --------------------------------------------------',
'12:54:28.215 SCRIPT : Official scenarios (3 entries)',
'12:54:28.216 SCRIPT : --------------------------------------------------',
'12:54:28.216 SCRIPT : {ECC61978EDCC2B5A}Missions/23_Campaign.conf (Conflict - Everon)',
'12:54:28.216 SCRIPT : {002AF7323E0129AF}Missions/Tutorial.conf (Training)',
'12:54:28.217 SCRIPT : {59AD59368755F41A}Missions/21_GM_Eden.conf (Game Master - Everon)',
'12:54:29.000 SCRIPT : Workshop scenarios (1 entries)',
'12:54:29.001 SCRIPT : {ABCDEF0123456789}Missions/CustomOps.conf (Custom Ops)',
'12:54:30.000 DEFAULT : something unrelated',
].join('\n');
describe('parseMissionList', () => {
it('parses scenario ids, display names, and section sources', () => {
const missions = parseMissionList(LOG);
expect(missions).toHaveLength(4);
expect(missions[0]).toEqual({
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon',
source: 'official',
});
expect(missions[3]).toEqual({
scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf',
name: 'Custom Ops',
source: 'workshop',
});
});
it('deduplicates repeated listings (multiple boots in one file)', () => {
const missions = parseMissionList(`${LOG}\n${LOG}`);
expect(missions).toHaveLength(4);
});
it('returns an empty list when no listing is present', () => {
expect(parseMissionList('12:00:00.000 DEFAULT : nothing here')).toEqual([]);
});
});
describe('workshop scenario helpers', () => {
it('converts mod scenarios into mission entries', () => {
const missions = scenariosFromWorkshopMod({
id: 'ABC',
name: 'Scenario Pack',
author: 'Author',
imageUrl: null,
size: null,
rating: null,
workshopUrl: null,
version: null,
gameVersion: null,
subscribers: null,
downloads: null,
createdAtText: null,
lastModifiedText: null,
summary: null,
description: null,
license: null,
tags: [],
dependencies: [],
scenarios: [
{
name: 'Raid Night',
description: null,
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
gamemode: 'Coop',
playerCount: 32,
imageUrl: null,
},
],
});
expect(missions).toEqual([
{
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
name: 'Raid Night',
source: 'mod: Scenario Pack',
},
]);
});
it('deduplicates mission groups while preserving first source', () => {
const merged = mergeMissions(
[{ scenarioId: 'same', name: 'From Log', source: 'workshop' }],
[{ scenarioId: 'same', name: 'From Mod', source: 'mod: Pack' }],
[{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' }],
);
expect(merged).toEqual([
{ scenarioId: 'same', name: 'From Log', source: 'workshop' },
{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' },
]);
});
});
@@ -0,0 +1,102 @@
import type { MissionInfo, MissionsResponse, WorkshopModDetail } from '@reforger-panel/shared';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from './ingestion/log-path-resolver.js';
const CATALOG_TTL_MS = 10 * 60 * 1000;
const CATALOG_MAX_BYTES = 2 * 1024 * 1024;
/**
* Scenario listing printed at boot when the server runs with -listScenarios
* (verified against real logs):
* 12:54:28.215 SCRIPT : Official scenarios (31 entries)
* 12:54:28.216 SCRIPT : {ECC61978EDCC2B5A}Missions/23_Campaign.conf (Conflict - Everon)
*/
const SECTION_PATTERN = /SCRIPT\s*:\s*(.+ scenarios) \(\d+ entr/i;
const MISSION_PATTERN = /SCRIPT\s*:\s*(\{[0-9A-Fa-f]{16}\}\S+\.conf)(?:\s+\((.+)\))?\s*$/;
export function parseMissionList(logContent: string): MissionInfo[] {
const missions: MissionInfo[] = [];
const seen = new Set<string>();
let currentSource = 'official';
for (const line of logContent.split('\n')) {
const section = SECTION_PATTERN.exec(line);
if (section) {
currentSource = section[1]!.toLowerCase().replace(/ scenarios$/, '');
continue;
}
const mission = MISSION_PATTERN.exec(line);
if (mission && !seen.has(mission[1]!)) {
seen.add(mission[1]!);
missions.push({
scenarioId: mission[1]!,
name: mission[2] ?? mission[1]!.slice(mission[1]!.lastIndexOf('/') + 1),
source: currentSource,
});
}
}
return missions;
}
export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[] {
return mod.scenarios.map((scenario) => ({
scenarioId: scenario.scenarioId,
name: scenario.name,
source: `mod: ${mod.name}`,
}));
}
export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] {
const merged: MissionInfo[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const mission of group) {
if (seen.has(mission.scenarioId)) continue;
seen.add(mission.scenarioId);
merged.push(mission);
}
}
return merged;
}
/**
* Extracts the available-missions dropdown data from the server's current
* console.log. Cached briefly; a fresh boot log always carries the listing
* near the top, so the head of the file is enough.
*/
export class MissionCatalog {
private cache: { missions: MissionInfo[]; fetchedAt: string; expiresAt: number } | null = null;
constructor(
private readonly provider: GameServerProvider,
private readonly resolveLogPath: LogPathResolver,
private readonly providerServerId: string,
) {}
async list(force = false): Promise<MissionsResponse> {
if (!force && this.cache && this.cache.expiresAt > Date.now()) {
return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt };
}
const logPath = await this.resolveLogPath();
if (!logPath) return { missions: [], fetchedAt: null };
const file = await this.provider.downloadTextFile(
this.providerServerId,
logPath,
CATALOG_MAX_BYTES,
);
const missions = parseMissionList(file.content);
if (missions.length > 0) {
this.cache = {
missions,
fetchedAt: new Date().toISOString(),
expiresAt: Date.now() + CATALOG_TTL_MS,
};
return { missions, fetchedAt: this.cache.fetchedAt };
}
// Long-running servers may have rotated past the listing; keep the last
// known catalog rather than returning nothing.
if (this.cache) {
return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt };
}
return { missions: [], fetchedAt: null };
}
}
@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest';
import { parseLogChunk } from './parser.js';
const HEADER = 'Log started 2026-07-04 10:00:00';
function line(time: string, category: string, message: string): string {
return `${time} ${category.padEnd(12)} : ${message}`;
}
const CONNECT_LINE = line(
'10:05:01.123',
'DEFAULT',
"BattlEye Server: 'Player #1 Braeden (10.0.0.2:50241) connected'",
);
const GUID_LINE = line(
'10:05:02.500',
'DEFAULT',
"BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'",
);
const DISCONNECT_LINE = line(
'10:45:09.001',
'DEFAULT',
"BattlEye Server: 'Player #1 Braeden disconnected'",
);
describe('parseLogChunk', () => {
it('parses a player connect event with timestamp from the header date', () => {
const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n`);
expect(result.events).toHaveLength(1);
const event = result.events[0]!;
expect(event.type).toBe('player_connected');
if (event.type === 'player_connected') {
expect(event.playerName).toBe('Braeden');
expect(event.playerNumber).toBe(1);
expect(event.occurredAt.toISOString()).toBe('2026-07-04T10:05:01.123Z');
}
});
it('parses disconnect events and identity (GUID) lines', () => {
const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n${GUID_LINE}\n${DISCONNECT_LINE}\n`);
expect(result.events.map((e) => e.type)).toEqual([
'player_connected',
'player_identity',
'player_disconnected',
]);
const identity = result.events[1]!;
if (identity.type === 'player_identity') {
expect(identity.externalPlayerId).toBe('9f2ab04c11d9e0aa');
}
});
it('parses player names containing spaces and parentheses-free IPs', () => {
const weird = line(
'10:06:00.000',
'DEFAULT',
"BattlEye Server: 'Player #7 Sgt. Moss Jr (192.168.1.44:61022) connected'",
);
const result = parseLogChunk(`${HEADER}\n${weird}\n`);
expect(result.events).toHaveLength(1);
if (result.events[0]!.type === 'player_connected') {
expect(result.events[0]!.playerName).toBe('Sgt. Moss Jr');
}
});
it('parses engine-level authenticated-player identity lines (real log format)', () => {
const backend = line(
'12:57:48.941',
'BACKEND',
'Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=mcdazzzled',
);
const result = parseLogChunk(`${HEADER}\n${backend}\n`);
expect(result.events).toHaveLength(1);
const event = result.events[0]!;
expect(event.type).toBe('player_identity');
if (event.type === 'player_identity') {
expect(event.playerName).toBe('mcdazzzled');
expect(event.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756');
}
});
it('detects server start lines', () => {
const content = `${HEADER}\n${line('10:00:05.000', 'DEFAULT', 'Game successfully created.')}\n`;
const result = parseLogChunk(content);
expect(result.events).toHaveLength(1);
expect(result.events[0]!.type).toBe('server_started');
});
it('parses ServerAdminTools killfeed lines', () => {
const kill = line(
'10:12:30.000',
'SCRIPT',
'ServerAdminTools | Event serveradmintools_player_killed | player: Victim, instigator: Killer, friendly: false',
);
const result = parseLogChunk(`${HEADER}\n${kill}\n`);
expect(result.events).toHaveLength(1);
const event = result.events[0]!;
expect(event.type).toBe('player_killed');
if (event.type === 'player_killed') {
expect(event.victimName).toBe('Victim');
expect(event.killerName).toBe('Killer');
expect(event.friendly).toBe(false);
}
});
it('handles multiple simultaneous players', () => {
const content = [
HEADER,
line('10:01:00.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha (10.0.0.1:1) connected'"),
line('10:01:01.000', 'DEFAULT', "BattlEye Server: 'Player #2 Bravo (10.0.0.2:2) connected'"),
line(
'10:01:02.000',
'DEFAULT',
"BattlEye Server: 'Player #3 Charlie (10.0.0.3:3) connected'",
),
line('10:30:00.000', 'DEFAULT', "BattlEye Server: 'Player #2 Bravo disconnected'"),
'',
].join('\n');
const result = parseLogChunk(content);
expect(result.events).toHaveLength(4);
expect(result.events.filter((e) => e.type === 'player_connected')).toHaveLength(3);
});
it('ignores unknown lines safely and counts them', () => {
const content = [
HEADER,
line('10:02:00.000', 'SCRIPT', 'SCR_BaseGameMode: match state changed'),
line('10:02:01.000', 'NETWORK', '### Connection stats'),
'complete garbage that matches nothing',
CONNECT_LINE,
'',
].join('\n');
const result = parseLogChunk(content);
expect(result.events).toHaveLength(1);
expect(result.ignoredLineCount).toBe(3);
});
it('rejects invalid timestamps without crashing', () => {
const content = `${HEADER}\n${line('25:99:99.000', 'DEFAULT', 'Game successfully created.')}\n${CONNECT_LINE}\n`;
const result = parseLogChunk(content);
expect(result.invalidTimestampCount).toBe(1);
expect(result.events).toHaveLength(1);
});
it('returns the partial trailing line unparsed', () => {
const partial = "10:50:00.100 DEFAULT : BattlEye Server: 'Player #2 Sab";
const result = parseLogChunk(`${HEADER}\n${CONNECT_LINE}\n${partial}`);
expect(result.events).toHaveLength(1);
expect(result.partialTrailingLine).toBe(partial);
});
it('rolls the date over at midnight', () => {
const content = [
'Log started 2026-07-04 23:58:00',
line('23:59:30.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha (10.0.0.1:1) connected'"),
line('00:01:10.000', 'DEFAULT', "BattlEye Server: 'Player #1 Alpha disconnected'"),
'',
].join('\n');
const result = parseLogChunk(content);
expect(result.events).toHaveLength(2);
expect(result.events[0]!.occurredAt.toISOString()).toBe('2026-07-04T23:59:30.000Z');
expect(result.events[1]!.occurredAt.toISOString()).toBe('2026-07-05T00:01:10.000Z');
});
it('uses the fallback date when no header is present, without producing future timestamps', () => {
const fallback = new Date('2026-07-05T00:10:00.000Z');
const result = parseLogChunk(
`${line('23:55:00.000', 'DEFAULT', 'Game successfully created.')}\n`,
{
fallbackDate: fallback,
},
);
expect(result.events).toHaveLength(1);
expect(result.events[0]!.occurredAt.toISOString()).toBe('2026-07-04T23:55:00.000Z');
});
it('parses an empty chunk without events', () => {
const result = parseLogChunk('');
expect(result.events).toHaveLength(0);
expect(result.completeLineCount).toBe(0);
expect(result.partialTrailingLine).toBeNull();
});
});
@@ -0,0 +1,217 @@
import {
AUTHENTICATED_PLAYER_PATTERN,
BATTLEYE_WRAPPER_PATTERN,
LINE_PREFIX_PATTERN,
LOG_HEADER_PATTERN,
PLAYER_CONNECTED_PATTERN,
PLAYER_DISCONNECTED_PATTERN,
PLAYER_GUID_PATTERN,
SERVER_ADMIN_TOOLS_KILL_PATTERN,
SERVER_STARTED_PATTERNS,
} from './patterns.js';
import type { ParseChunkResult, ParsedLogEvent, ParserContext } from './types.js';
const MIDNIGHT_ROLLOVER_TOLERANCE_MS = 60_000;
export function emptyContext(): ParserContext {
return { baseDate: null, lastTimestamp: null };
}
function startOfDayUtc(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
/**
* Combine a time-of-day stamp with the tracked calendar date. Reforger's
* console.log lines carry no date, so the date comes from the log header when
* present, otherwise from the fallback (file time / now). Rollover past
* midnight is detected by the clock going backwards.
*/
function resolveTimestamp(
hours: number,
minutes: number,
seconds: number,
millis: number,
context: ParserContext,
fallbackDate: Date,
): Date | null {
if (hours > 23 || minutes > 59 || seconds > 59) return null;
const base = context.baseDate ?? startOfDayUtc(fallbackDate);
if (!context.baseDate) context.baseDate = base;
let timestamp = new Date(
base.getTime() + ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis,
);
// No header date + fallback of "today" can push pre-midnight lines into the
// future when syncing just after midnight; pull them back a day.
if (
!context.lastTimestamp &&
timestamp.getTime() > fallbackDate.getTime() + MIDNIGHT_ROLLOVER_TOLERANCE_MS
) {
context.baseDate = new Date(base.getTime() - 24 * 60 * 60 * 1000);
timestamp = new Date(timestamp.getTime() - 24 * 60 * 60 * 1000);
}
if (
context.lastTimestamp &&
timestamp.getTime() < context.lastTimestamp.getTime() - MIDNIGHT_ROLLOVER_TOLERANCE_MS
) {
context.baseDate = new Date(base.getTime() + 24 * 60 * 60 * 1000);
timestamp = new Date(timestamp.getTime() + 24 * 60 * 60 * 1000);
}
context.lastTimestamp = timestamp;
return timestamp;
}
function parseMessage(message: string, occurredAt: Date, rawLine: string): ParsedLogEvent | null {
const battleye = BATTLEYE_WRAPPER_PATTERN.exec(message);
const body = battleye ? battleye[1]! : message;
const connected = PLAYER_CONNECTED_PATTERN.exec(body);
if (connected) {
return {
type: 'player_connected',
occurredAt,
playerNumber: Number(connected[1]),
playerName: connected[2]!,
rawLine,
};
}
const authenticated = AUTHENTICATED_PLAYER_PATTERN.exec(body);
if (authenticated) {
return {
type: 'player_identity',
occurredAt,
playerName: authenticated[2]!,
externalPlayerId: authenticated[1]!.toLowerCase(),
rawLine,
};
}
const guid = PLAYER_GUID_PATTERN.exec(body);
if (guid) {
return {
type: 'player_identity',
occurredAt,
playerNumber: Number(guid[1]),
playerName: guid[2]!,
externalPlayerId: guid[3]!.toLowerCase(),
rawLine,
};
}
const disconnected = PLAYER_DISCONNECTED_PATTERN.exec(body);
if (disconnected) {
return {
type: 'player_disconnected',
occurredAt,
playerNumber: Number(disconnected[1]),
playerName: disconnected[2]!,
reason: disconnected[3] || undefined,
rawLine,
};
}
if (SERVER_STARTED_PATTERNS.some((pattern) => pattern.test(body))) {
return { type: 'server_started', occurredAt, rawLine };
}
const kill = SERVER_ADMIN_TOOLS_KILL_PATTERN.exec(body);
if (kill) {
return {
type: 'player_killed',
occurredAt,
victimName: kill[1]!,
killerName: kill[2]!,
friendly: kill[3]!.toLowerCase() === 'true',
rawLine,
};
}
return null;
}
/**
* Parse a chunk of log content. The chunk must start at a line boundary
* (callers prepend any stored partial trailing line). Pure and side-effect
* free apart from the returned, updated context.
*/
export function parseLogChunk(
content: string,
options: { context?: ParserContext; fallbackDate?: Date } = {},
): ParseChunkResult {
const context: ParserContext = options.context ? { ...options.context } : emptyContext();
const fallbackDate = options.fallbackDate ?? new Date();
const endsWithNewline = content.endsWith('\n');
const segments = content.split('\n');
const partialTrailingLine = endsWithNewline ? null : (segments.pop() ?? null);
if (endsWithNewline) segments.pop(); // drop the empty segment after the final newline
const events: ParsedLogEvent[] = [];
let ignoredLineCount = 0;
let invalidTimestampCount = 0;
let lastCompleteLine: string | null = null;
for (const rawSegment of segments) {
const line = rawSegment.replace(/\r$/, '');
lastCompleteLine = line;
if (line.trim() === '') continue;
const header = LOG_HEADER_PATTERN.exec(line);
if (header) {
const [, year, month, day, hours, minutes, seconds] = header;
const headerDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), 0, 0, 0));
if (!Number.isNaN(headerDate.getTime())) {
context.baseDate = headerDate;
context.lastTimestamp = new Date(
Date.UTC(
Number(year),
Number(month) - 1,
Number(day),
Number(hours),
Number(minutes),
Number(seconds),
),
);
}
continue;
}
const prefix = LINE_PREFIX_PATTERN.exec(line);
if (!prefix) {
ignoredLineCount += 1;
continue;
}
const [, h, m, s, ms, , message] = prefix;
const occurredAt = resolveTimestamp(
Number(h),
Number(m),
Number(s),
Number(ms),
context,
fallbackDate,
);
if (!occurredAt) {
invalidTimestampCount += 1;
continue;
}
const event = parseMessage(message!, occurredAt, line);
if (event) {
events.push(event);
} else {
ignoredLineCount += 1;
}
}
return {
events,
completeLineCount: segments.length,
ignoredLineCount,
invalidTimestampCount,
partialTrailingLine: partialTrailingLine === '' ? null : partialTrailingLine,
lastCompleteLine,
context,
};
}
@@ -0,0 +1,57 @@
/**
* Line patterns for Arma Reforger (Enfusion) server logs.
*
* These are based on observed community-documented output and the bundled
* fixtures, NOT an official spec Bohemia can change them between game
* versions. All pattern knowledge lives in this file so new formats only
* require touching the regexes below and adding a fixture. Unknown lines are
* ignored safely and only counted in diagnostics.
*
* Canonical shapes targeted:
* Log started 2026-07-04 11:22:33
* 11:24:01.001 DEFAULT : BattlEye Server: 'Player #1 Braeden (10.0.0.2:50241) connected'
* 11:24:03.500 DEFAULT : BattlEye Server: 'Player #1 Braeden - GUID: 9f2ab04c11d9e0aa'
* 11:52:09.114 DEFAULT : BattlEye Server: 'Player #1 Braeden disconnected'
* 11:22:35.123 DEFAULT : Game successfully created.
*/
/** Header written at the top of console.log; provides the calendar date. */
export const LOG_HEADER_PATTERN =
/^Log started\s+(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/;
/** Enfusion line prefix: time-of-day, category, colon, message. */
export const LINE_PREFIX_PATTERN = /^(\d{1,2}):(\d{2}):(\d{2})\.(\d{3})\s+([A-Z]+)\s*:\s*(.*)$/;
/** BattlEye messages are quoted inside a wrapper on the DEFAULT channel. */
export const BATTLEYE_WRAPPER_PATTERN = /^BattlEye Server: '(.*)'$/;
/** Player #1 Name (1.2.3.4:56789) connected */
export const PLAYER_CONNECTED_PATTERN =
/^Player #(\d+) (.+) \((?:\d{1,3}\.){3}\d{1,3}:\d+\) connected$/;
/** Player #1 Name disconnected (optionally with a trailing reason in parentheses) */
export const PLAYER_DISCONNECTED_PATTERN = /^Player #(\d+) (.+?) disconnected(?: \((.+)\))?$/;
/** Player #1 Name - GUID: abcdef0123456789 (also matches "- BE GUID:") */
export const PLAYER_GUID_PATTERN = /^Player #(\d+) (.+) - (?:BE )?GUID: ([0-9a-fA-F]{8,64})$/;
/**
* Engine-level identity on the BACKEND channel (verified against real logs):
* Authenticated player: rplIdentity=0x00000000 identityId=<uuid> name=<name>
* Available even when BattlEye is disabled.
*/
export const AUTHENTICATED_PLAYER_PATTERN =
/^Authenticated player: .*identityId=([0-9a-fA-F-]{8,64}) name=(.+)$/;
/** Messages that indicate the server process finished starting a session. */
export const SERVER_STARTED_PATTERNS: RegExp[] = [
/^Game successfully created\.?$/,
/^Server is ready to accept connections/,
];
/**
* ServerAdminTools killfeed line observed in reforger-stats:
* ServerAdminTools | Event serveradmintools_player_killed | player: Victim, instigator: Killer, friendly: false
*/
export const SERVER_ADMIN_TOOLS_KILL_PATTERN =
/^ServerAdminTools \| Event serveradmintools_player_killed \| player: (.+), instigator: (.+), friendly: (true|false)$/i;
@@ -0,0 +1,63 @@
export type ParsedLogEvent =
| {
type: 'player_connected';
occurredAt: Date;
playerName: string;
playerNumber?: number;
externalPlayerId?: string;
rawLine: string;
}
| {
type: 'player_disconnected';
occurredAt: Date;
playerName: string;
playerNumber?: number;
externalPlayerId?: string;
reason?: string;
rawLine: string;
}
| {
/**
* Identity lines (e.g. BattlEye GUID) arrive separately from connects;
* ingestion merges them into the matching player record.
*/
type: 'player_identity';
occurredAt: Date;
playerName: string;
playerNumber?: number;
externalPlayerId: string;
rawLine: string;
}
| {
type: 'server_started';
occurredAt: Date;
rawLine: string;
}
| {
type: 'player_killed';
occurredAt: Date;
victimName: string;
killerName: string;
friendly: boolean;
rawLine: string;
};
export type ParserContext = {
/** Calendar date the time-of-day stamps are relative to (from the log header). */
baseDate: Date | null;
/** Last timestamp emitted; used to detect midnight rollover. */
lastTimestamp: Date | null;
};
export type ParseChunkResult = {
events: ParsedLogEvent[];
completeLineCount: number;
/** Lines that matched no pattern. Safe to ignore; counted for diagnostics only. */
ignoredLineCount: number;
invalidTimestampCount: number;
/** Content after the final newline — not parsed, carried to the next sync. */
partialTrailingLine: string | null;
/** Raw text of the last complete line, for cursor continuity checks. */
lastCompleteLine: string | null;
context: ParserContext;
};
@@ -0,0 +1,103 @@
import type { ResourceHistoryResponse, ResourceSample } from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
export const SAMPLE_INTERVAL_SECONDS = 15;
const MAX_SAMPLES = 240; // ~1 hour window
type RawSample = ResourceSample & { rxTotal: number; txTotal: number };
/**
* In-memory rolling window of resource usage for the dashboard graphs.
* Network rates are derived from the provider's cumulative rx/tx counters;
* history is intentionally not persisted (it is telemetry, not records).
*/
export class ResourceHistoryService {
private samples = new Map<string, RawSample[]>();
private timer: ReturnType<typeof setInterval> | null = null;
private servers: { serverId: string; providerServerId: string }[] = [];
constructor(
private readonly provider: GameServerProvider,
private readonly logger: Logger,
private readonly intervalSeconds: number = SAMPLE_INTERVAL_SECONDS,
) {}
start(servers: { serverId: string; providerServerId: string }[]): void {
this.servers = servers;
void this.sampleAll();
this.timer = setInterval(() => void this.sampleAll(), this.intervalSeconds * 1000);
this.timer.unref?.();
this.logger.info({ servers: servers.length }, 'resource history sampler started');
}
stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
}
private async sampleAll(): Promise<void> {
for (const server of this.servers) {
try {
await this.sampleOne(server.serverId, server.providerServerId);
} catch {
// Provider unreachable: record an offline-ish gap sample so graphs
// show the outage instead of freezing on the last good value.
this.push(server.serverId, {
t: Date.now(),
status: 'unknown',
cpuPercent: 0,
cpuLimitPercent: null,
memoryBytes: 0,
memoryLimitBytes: null,
networkRxRate: 0,
networkTxRate: 0,
rxTotal: -1,
txTotal: -1,
});
}
}
}
private async sampleOne(serverId: string, providerServerId: string): Promise<void> {
const resources = await this.provider.getServerResources(providerServerId);
const previous = this.samples.get(serverId)?.at(-1);
const now = Date.now();
let networkRxRate = 0;
let networkTxRate = 0;
if (previous && previous.rxTotal >= 0 && now > previous.t) {
const dtSeconds = (now - previous.t) / 1000;
// Counters reset on server restart; clamp negative deltas to zero.
networkRxRate = Math.max(0, (resources.networkRxBytes - previous.rxTotal) / dtSeconds);
networkTxRate = Math.max(0, (resources.networkTxBytes - previous.txTotal) / dtSeconds);
}
this.push(serverId, {
t: now,
status: resources.status,
cpuPercent: Math.round(resources.cpuPercent * 10) / 10,
cpuLimitPercent: resources.cpuLimitPercent,
memoryBytes: resources.memoryBytes,
memoryLimitBytes: resources.memoryLimitBytes,
networkRxRate: Math.round(networkRxRate),
networkTxRate: Math.round(networkTxRate),
rxTotal: resources.networkRxBytes,
txTotal: resources.networkTxBytes,
});
}
private push(serverId: string, sample: RawSample): void {
const list = this.samples.get(serverId) ?? [];
list.push(sample);
if (list.length > MAX_SAMPLES) list.splice(0, list.length - MAX_SAMPLES);
this.samples.set(serverId, list);
}
history(serverId: string): ResourceHistoryResponse {
const samples = (this.samples.get(serverId) ?? []).map(
({ rxTotal: _rx, txTotal: _tx, ...sample }) => sample,
);
return { samples, intervalSeconds: this.intervalSeconds };
}
}
@@ -0,0 +1,687 @@
import { Router } from 'express';
import { z } from 'zod';
import type {
LogIngestionHealth,
ServerResources,
ServerStatus,
ServerSummary,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth, requireCapability } from '../auth/auth-middleware.js';
import type { ConfigSyncService } from '../config/config-sync.js';
import type { ServerModsService } from '../config/mods-service.js';
import type { PerformanceSettingsService } from '../config/performance-service.js';
import type { ResourceHistoryService } from './resource-history.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js';
import type { MissionCatalog } from '../reforger-logs/missions-catalog.js';
import { mergeMissions, scenariosFromWorkshopMod } from '../reforger-logs/missions-catalog.js';
import type { ServerRecord, ServerService } from './server-service.js';
import type { WorkshopClient } from '../workshop/workshop-client.js';
const slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'Invalid server slug.');
export type ServerRouterDeps = {
service: ServerService;
provider: GameServerProvider;
scheduler: IngestionScheduler | null;
resolveLogPath: LogPathResolver | null;
configSync: ConfigSyncService | null;
mods: ServerModsService | null;
performance: PerformanceSettingsService | null;
resourceHistory: ResourceHistoryService | null;
missions: MissionCatalog | null;
workshop: WorkshopClient;
staleAfterSeconds: number;
mockMode: boolean;
};
// Validation ranges follow the Bohemia server-config reference. Only provided
// keys are touched; `null` removes the key (the game default applies).
const performanceBodySchema = z
.object({
scenarioId: z
.string()
.trim()
.max(200)
.regex(/^\{[0-9A-Fa-f]{16}\}\S+\.conf$/, 'Invalid scenario id.')
.nullable(),
maxPlayers: z.number().int().min(1).max(128).nullable(),
serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(),
networkViewDistance: z.number().int().min(500).max(5000).nullable(),
serverMinGrassDistance: z.number().int().min(0).max(150).nullable(),
disableThirdPerson: z.boolean().nullable(),
fastValidation: z.boolean().nullable(),
battlEye: z.boolean().nullable(),
aiLimit: z.number().int().min(-1).max(1000).nullable(),
playerSaveTime: z.number().int().min(1).max(3600).nullable(),
slotReservationTimeout: z.number().int().min(5).max(300).nullable(),
lobbyPlayerSynchronise: z.boolean().nullable(),
})
.partial()
.strict();
const startupVariableBodySchema = z.object({
key: z.string().regex(/^[A-Z0-9_]{1,64}$/, 'Invalid variable name.'),
value: z.string().max(500),
});
const restartScheduleBodySchema = z.object({
name: z.string().trim().min(1).max(100),
isActive: z.boolean(),
minute: z.number().int().min(0).max(59),
hour: z.number().int().min(0).max(23),
dayOfWeek: z.enum(['*', '0', '1', '2', '3', '4', '5', '6']),
onlyWhenOnline: z.boolean(),
});
const scheduleIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid schedule id.');
// Reforger Workshop mod IDs are 16 hex characters (see the Bohemia server
// config reference); name/version are free-ish text with sane caps.
const modsBodySchema = z.object({
mods: z
.array(
z.object({
modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'),
name: z.string().trim().max(200).optional(),
version: z
.string()
.trim()
.max(32)
.regex(/^[\w.+-]*$/, 'Invalid version.')
.optional(),
}),
)
.max(200),
});
function providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
export function createServerRouter(deps: ServerRouterDeps): Router {
const router = Router();
const { service, provider } = deps;
const powerRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'power' });
const syncRateLimit = rateLimit({ windowMs: 60_000, max: 6, keyPrefix: 'logsync' });
router.use(requireAuth);
async function loadServer(slugRaw: unknown): Promise<ServerRecord> {
const slug = slugSchema.safeParse(slugRaw);
if (!slug.success) throw ApiError.validation('Invalid server slug.');
const server = await service.getServerBySlug(slug.data);
if (!server) throw ApiError.notFound('Server not found.');
return server;
}
async function toSummary(server: ServerRecord): Promise<ServerSummary> {
let status = server.status as ServerStatus;
try {
status = await provider.getServerStatus(providerId(server));
if (status !== server.status) {
await service.updateStatus(server.id, status);
}
} catch {
// Provider unreachable: fall back to the last stored status.
}
return {
id: server.id,
slug: server.slug,
name: server.name,
providerType: server.providerType,
status,
maxPlayers: server.maxPlayers,
onlinePlayerCount: await service.countOnlinePlayers(server.id),
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
};
}
router.get('/', async (_req, res, next) => {
try {
const servers = await service.listServers();
res.json({ servers: await Promise.all(servers.map((s) => toSummary(s))) });
} catch (error) {
next(error);
}
});
router.get('/:slug', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await toSummary(server));
} catch (error) {
next(error);
}
});
router.get('/:slug/resources', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const resources = await provider.getServerResources(providerId(server));
const body: ServerResources = { ...resources, fetchedAt: new Date().toISOString() };
res.json(body);
} catch (error) {
next(error);
}
});
router.get('/:slug/resources/history', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.resourceHistory) {
throw ApiError.notConfigured('Resource history requires a configured game server backend.');
}
res.json(deps.resourceHistory.history(server.id));
} catch (error) {
next(error);
}
});
router.get('/:slug/config/performance', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.performance) {
throw ApiError.notConfigured('Config editing requires a configured game server backend.');
}
res.json(await deps.performance.get(server));
} catch (error) {
next(error);
}
});
router.put(
'/:slug/config/performance',
syncRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.performance) {
throw ApiError.notConfigured('Config editing requires a configured game server backend.');
}
const body = performanceBodySchema.safeParse(req.body);
if (!body.success) {
const issue = body.error.issues[0];
throw ApiError.validation(
issue ? `${issue.path.join('.')}: ${issue.message}` : 'Invalid settings.',
);
}
const result = await deps.performance.update(server, body.data);
// Many Reforger eggs template config.json from startup variables at
// boot; mirror the mission there too so switching sticks either way.
if (result.changedFields.includes('scenarioId') && body.data.scenarioId) {
await provider
.updateStartupVariable(providerId(server), 'SCENARIO_ID', body.data.scenarioId)
.catch(() => undefined); // variable may not exist on this egg
}
if (result.changedFields.length > 0) {
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'config.performance.updated',
summary: `Performance settings updated by ${user.displayName ?? user.username}: ${result.changedFields.join(', ')} (applies on restart)`,
metadata: { changedFields: result.changedFields },
});
}
res.json(result);
} catch (error) {
next(error);
}
},
);
router.get('/:slug/players', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await service.getOnlinePlayers(server, deps.staleAfterSeconds));
} catch (error) {
next(error);
}
});
router.get('/:slug/players/known', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json({ players: await service.getKnownPlayers(server.id) });
} catch (error) {
next(error);
}
});
router.get('/:slug/activity', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const limit = z.coerce.number().int().min(1).max(200).default(50).parse(req.query.limit);
res.json({ activity: await service.getActivity(server.id, limit) });
} catch (error) {
next(error);
}
});
router.get('/:slug/killfeed', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const limit = z.coerce.number().int().min(1).max(500).default(100).parse(req.query.limit);
res.json({ events: await service.getKillfeed(server.id, limit) });
} catch (error) {
next(error);
}
});
router.get('/:slug/configuration', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Configuration requires a configured game server backend.');
}
const config = await deps.configSync.getLiveConfig(server);
res.json({ config, fetchedAt: new Date().toISOString() });
} catch (error) {
next(error);
}
});
router.get('/:slug/missions', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const logMissions = deps.missions ? (await deps.missions.list()).missions : [];
const modMissions = [];
if (deps.mods) {
const installed = await deps.mods.getMods(server);
const details = await Promise.allSettled(
installed.mods.map((mod) => deps.workshop.getMod(mod.modId)),
);
for (const result of details) {
if (result.status === 'fulfilled') {
modMissions.push(...scenariosFromWorkshopMod(result.value));
}
}
}
if (!deps.missions && !deps.mods) {
throw ApiError.notConfigured('Missions require logs or config/mod access.');
}
res.json({
missions: mergeMissions(logMissions, modMissions),
fetchedAt: new Date().toISOString(),
});
} catch (error) {
next(error);
}
});
router.get(
'/:slug/logs/raw',
requireCapability('ops.health.view', 'Raw logs are restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.resolveLogPath) {
throw ApiError.notConfigured('Log access requires a configured game server backend.');
}
const lineCount = z.coerce
.number()
.int()
.min(10)
.max(1000)
.default(300)
.parse(req.query.lines);
const logPath = await deps.resolveLogPath();
if (!logPath) throw ApiError.notConfigured('Could not locate the current log file.');
const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024);
const allLines = file.content.split('\n');
res.json({
path: logPath,
lines: allLines.slice(-lineCount),
truncated: file.truncated || allLines.length > lineCount,
fetchedAt: new Date().toISOString(),
});
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/startup',
requireCapability('config.edit', 'Startup variables are restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const variables = await provider.listStartupVariables(providerId(server));
res.json({
variables: variables.map((v) => ({
name: v.name,
description: v.description,
envVariable: v.envVariable,
value: v.serverValue,
defaultValue: v.defaultValue,
isEditable: v.isEditable,
})),
fetchedAt: new Date().toISOString(),
});
} catch (error) {
next(error);
}
},
);
router.put(
'/:slug/startup/variable',
syncRateLimit,
requireCapability('config.edit', 'Startup variables are restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const body = startupVariableBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid startup variable update.');
await provider.updateStartupVariable(providerId(server), body.data.key, body.data.value);
const user = req.user!;
// Never put the value in the activity feed — these can be passwords.
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'startup.variable.updated',
summary: `Startup variable ${body.data.key} updated by ${user.displayName ?? user.username} (applies on restart)`,
metadata: { key: body.data.key },
});
res.json({ ok: true, requiresRestart: true });
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/schedules',
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json({
schedules: await provider.listSchedules(providerId(server)),
fetchedAt: new Date().toISOString(),
});
} catch (error) {
next(error);
}
},
);
router.post(
'/:slug/schedules/restarts',
syncRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const body = restartScheduleBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid restart schedule.');
const schedule = await provider.createRestartSchedule(providerId(server), body.data);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'schedule.restart.created',
summary: `Restart schedule "${schedule.name}" created by ${user.displayName ?? user.username}`,
metadata: { scheduleId: schedule.id },
});
res.json({ schedule });
} catch (error) {
next(error);
}
},
);
router.put(
'/:slug/schedules/:scheduleId/restart',
syncRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const scheduleId = scheduleIdSchema.safeParse(req.params.scheduleId);
if (!scheduleId.success) throw ApiError.validation('Invalid schedule id.');
const body = restartScheduleBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid restart schedule.');
const schedule = await provider.updateRestartSchedule(
providerId(server),
scheduleId.data,
body.data,
);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'schedule.restart.updated',
summary: `Restart schedule "${schedule.name}" updated by ${user.displayName ?? user.username}`,
metadata: { scheduleId: schedule.id },
});
res.json({ schedule });
} catch (error) {
next(error);
}
},
);
router.delete(
'/:slug/schedules/:scheduleId',
syncRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const scheduleId = scheduleIdSchema.safeParse(req.params.scheduleId);
if (!scheduleId.success) throw ApiError.validation('Invalid schedule id.');
await provider.deleteSchedule(providerId(server), scheduleId.data);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'schedule.deleted',
summary: `Schedule deleted by ${user.displayName ?? user.username}`,
metadata: { scheduleId: scheduleId.data },
});
res.json({ ok: true });
} catch (error) {
next(error);
}
},
);
router.get('/:slug/mods', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
res.json(await deps.mods.getMods(server));
} catch (error) {
next(error);
}
});
router.put(
'/:slug/mods',
syncRateLimit,
requireCapability('mods.manage', 'You do not have permission to manage mods.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
const body = modsBodySchema.safeParse(req.body);
if (!body.success) {
throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.');
}
// Reject duplicate mod ids up front instead of silently collapsing.
const ids = body.data.mods.map((mod) => mod.modId.toUpperCase());
if (new Set(ids).size !== ids.length) {
throw ApiError.validation('Duplicate mod ids in the list.');
}
const result = await deps.mods.setMods(server, body.data.mods);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'mods.updated',
summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed (${result.mods.length} total, applies on restart)`,
metadata: { added: result.added, removed: result.removed, total: result.mods.length },
});
res.json(result);
} catch (error) {
next(error);
}
},
);
router.get('/:slug/mod-packs', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json({ modPacks: await service.getModPacks(server.id) });
} catch (error) {
next(error);
}
});
const powerActions = [
{
action: 'start' as const,
capability: 'server.power.start' as const,
message: 'You do not have permission to start this server.',
run: (id: string) => provider.startServer(id),
},
{
action: 'stop' as const,
capability: 'server.power.stop' as const,
message: 'You do not have permission to stop this server.',
run: (id: string) => provider.stopServer(id),
},
{
action: 'restart' as const,
capability: 'server.power.restart' as const,
message: 'You do not have permission to restart this server.',
run: (id: string) => provider.restartServer(id),
},
];
for (const { action, capability, message, run } of powerActions) {
router.post(
`/:slug/power/${action}`,
powerRateLimit,
requireCapability(capability, message),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
await run(providerId(server));
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: `server.power.${action}`,
summary: `Server ${action} requested by ${user.displayName ?? user.username}${
deps.mockMode ? ' (mock mode)' : ''
}`,
metadata: { action, mock: deps.mockMode },
});
res.json({ ok: true, action, simulated: deps.mockMode });
} catch (error) {
next(error);
}
},
);
}
router.post(
'/:slug/logs/sync',
syncRateLimit,
requireCapability('logs.sync', 'Only the owner can trigger a manual log sync.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.scheduler || !deps.resolveLogPath) {
throw ApiError.notConfigured(
'Log ingestion is not configured. Set REFORGER_LOG_DIRECTORY (or REFORGER_ADMIN_LOG_PATH) and the Pterodactyl variables.',
);
}
const target: ScheduledServer = {
serverId: server.id,
providerServerId: providerId(server),
resolveLogPath: deps.resolveLogPath,
};
const result = await deps.scheduler.syncNow(target);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'logs.sync.manual',
summary: `Manual log sync by ${user.displayName ?? user.username} (${result.createdEvents} new events)`,
metadata: { createdEvents: result.createdEvents, processedLines: result.processedLines },
});
res.json(result);
} catch (error) {
next(error);
}
},
);
router.post(
'/:slug/config/sync',
syncRateLimit,
requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Config import requires a configured game server backend.');
}
// Config is served live; this just refreshes the stored name/capacity.
const result = await deps.configSync.sync(server);
res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers });
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/logs/health',
requireCapability('ops.health.view', 'Operational diagnostics are restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const cursor = await service.getLogCursor(server.id);
const lastResult = deps.scheduler?.getLastResult(server.id) ?? null;
const lastSyncAt = cursor?.lastSuccessfulSyncAt ?? null;
const body: LogIngestionHealth = {
configured: Boolean(deps.scheduler && deps.resolveLogPath),
running: Boolean(deps.scheduler),
logPath: cursor?.logPath ?? null,
lastSuccessfulSyncAt: lastSyncAt?.toISOString() ?? null,
lastErrorAt: cursor?.lastErrorAt?.toISOString() ?? null,
lastErrorMessage: cursor?.lastErrorMessage ?? null,
lastSync: lastResult
? {
processedLines: lastResult.processedLines,
createdEvents: lastResult.createdEvents,
updatedSessions: lastResult.updatedSessions,
}
: null,
stale: !lastSyncAt || Date.now() - lastSyncAt.getTime() > deps.staleAfterSeconds * 1000,
};
res.json(body);
} catch (error) {
next(error);
}
},
);
return router;
}
@@ -0,0 +1,259 @@
import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
import type {
ActivityItem,
KillfeedEvent,
KnownPlayer,
ModPackSummary,
OnlinePlayer,
PlayersResponse,
} from '@reforger-panel/shared';
import type { Db } from '../../db/client.js';
import { schema } from '../../db/client.js';
export type ServerRecord = typeof schema.servers.$inferSelect;
export class ServerService {
constructor(private readonly db: Db) {}
async listServers(): Promise<ServerRecord[]> {
return this.db.select().from(schema.servers).orderBy(schema.servers.name);
}
async getServerBySlug(slug: string): Promise<ServerRecord | null> {
const rows = await this.db.select().from(schema.servers).where(eq(schema.servers.slug, slug));
return rows[0] ?? null;
}
async updateStatus(serverId: string, status: string): Promise<void> {
await this.db.update(schema.servers).set({ status }).where(eq(schema.servers.id, serverId));
}
async updateServerInfo(
serverId: string,
patch: { name?: string; maxPlayers?: number | null },
): Promise<void> {
await this.db.update(schema.servers).set(patch).where(eq(schema.servers.id, serverId));
}
async countOnlinePlayers(serverId: string): Promise<number> {
const rows = await this.db
.select({ value: count() })
.from(schema.playerSessions)
.where(
and(
eq(schema.playerSessions.serverId, serverId),
isNull(schema.playerSessions.disconnectedAt),
),
);
return rows[0]?.value ?? 0;
}
async getOnlinePlayers(
server: ServerRecord,
staleAfterSeconds: number,
): Promise<PlayersResponse> {
const rows = await this.db
.select({ session: schema.playerSessions, player: schema.players })
.from(schema.playerSessions)
.innerJoin(schema.players, eq(schema.players.id, schema.playerSessions.playerId))
.where(
and(
eq(schema.playerSessions.serverId, server.id),
isNull(schema.playerSessions.disconnectedAt),
),
)
.orderBy(schema.playerSessions.connectedAt);
const now = Date.now();
const players: OnlinePlayer[] = rows.map(({ session, player }) => ({
playerId: player.id,
displayName: player.displayName,
externalPlayerId: player.externalPlayerId,
connectedAt: session.connectedAt.toISOString(),
sessionDurationSeconds: Math.max(0, Math.round((now - session.connectedAt.getTime()) / 1000)),
}));
const cursors = await this.db
.select()
.from(schema.logCursors)
.where(eq(schema.logCursors.serverId, server.id));
const lastSyncedAt = cursors
.map((c) => c.lastSuccessfulSyncAt)
.filter((d): d is Date => d !== null)
.sort((a, b) => b.getTime() - a.getTime())[0];
return {
players,
onlineCount: players.length,
maxPlayers: server.maxPlayers,
lastSyncedAt: lastSyncedAt?.toISOString() ?? null,
stale: !lastSyncedAt || now - lastSyncedAt.getTime() > staleAfterSeconds * 1000,
};
}
async getKnownPlayers(serverId: string, limit = 100): Promise<KnownPlayer[]> {
const rows = await this.db
.select({
player: schema.players,
totalSessions: count(schema.playerSessions.id),
totalPlaytimeSeconds: sql<number>`coalesce(sum(${schema.playerSessions.durationSeconds}), 0)`,
openSessions: sql<number>`count(*) filter (where ${schema.playerSessions.disconnectedAt} is null)`,
})
.from(schema.players)
.leftJoin(schema.playerSessions, eq(schema.playerSessions.playerId, schema.players.id))
.where(eq(schema.players.serverId, serverId))
.groupBy(schema.players.id)
.orderBy(desc(schema.players.lastSeenAt))
.limit(limit);
return rows.map(({ player, totalSessions, totalPlaytimeSeconds, openSessions }) => ({
id: player.id,
displayName: player.displayName,
externalPlayerId: player.externalPlayerId,
firstSeenAt: player.firstSeenAt.toISOString(),
lastSeenAt: player.lastSeenAt.toISOString(),
totalSessions,
totalPlaytimeSeconds: Number(totalPlaytimeSeconds),
online: Number(openSessions) > 0,
}));
}
/** Merged feed of panel actions (server_activity) and log-derived server events. */
async getActivity(serverId: string, limit = 50): Promise<ActivityItem[]> {
const actions = await this.db
.select({ activity: schema.serverActivity, actor: schema.users })
.from(schema.serverActivity)
.leftJoin(schema.users, eq(schema.users.id, schema.serverActivity.actorUserId))
.where(eq(schema.serverActivity.serverId, serverId))
.orderBy(desc(schema.serverActivity.createdAt))
.limit(limit);
const events = await this.db
.select()
.from(schema.serverEvents)
.where(eq(schema.serverEvents.serverId, serverId))
.orderBy(desc(schema.serverEvents.occurredAt))
.limit(limit);
const items: ActivityItem[] = [
...actions.map(({ activity, actor }) => ({
id: `activity:${activity.id}`,
kind: 'panel_action' as const,
action: activity.action,
summary: activity.summary,
actor: actor
? { id: actor.id, username: actor.username, displayName: actor.displayName }
: null,
occurredAt: activity.createdAt.toISOString(),
})),
...events.map((event) => ({
id: `event:${event.id}`,
kind: 'server_event' as const,
action: event.eventType,
summary: event.summary,
actor: null,
occurredAt: event.occurredAt.toISOString(),
})),
];
items.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
return items.slice(0, limit);
}
async getKillfeed(serverId: string, limit = 100): Promise<KillfeedEvent[]> {
const events = await this.db
.select()
.from(schema.serverEvents)
.where(
and(
eq(schema.serverEvents.serverId, serverId),
eq(schema.serverEvents.eventType, 'player_killed'),
),
)
.orderBy(desc(schema.serverEvents.occurredAt))
.limit(limit);
return events.map((event) => {
const payload = event.payload as Record<string, unknown>;
const position = (value: unknown) => {
if (!value || typeof value !== 'object') return null;
const record = value as Record<string, unknown>;
return typeof record.x === 'number' && typeof record.y === 'number'
? {
x: record.x,
y: record.y,
z: typeof record.z === 'number' ? record.z : null,
}
: null;
};
return {
id: event.id,
occurredAt: event.occurredAt.toISOString(),
killerName: typeof payload.killerName === 'string' ? payload.killerName : 'unknown',
victimName: typeof payload.victimName === 'string' ? payload.victimName : 'unknown',
friendly: payload.friendly === true,
killerTeam: typeof payload.killerTeam === 'string' ? payload.killerTeam : null,
victimTeam: typeof payload.victimTeam === 'string' ? payload.victimTeam : null,
killerPosition: position(payload.killerPosition),
victimPosition: position(payload.victimPosition),
distanceMeters: typeof payload.distanceMeters === 'number' ? payload.distanceMeters : null,
weapon: typeof payload.weapon === 'string' ? payload.weapon : null,
};
});
}
async recordActivity(input: {
serverId: string;
actorUserId: string | null;
action: string;
summary: string;
metadata?: Record<string, unknown>;
}): Promise<void> {
await this.db.insert(schema.serverActivity).values({
serverId: input.serverId,
actorUserId: input.actorUserId,
action: input.action,
summary: input.summary,
metadata: input.metadata ?? {},
});
}
async getModPacks(serverId: string): Promise<ModPackSummary[]> {
const packs = await this.db
.select()
.from(schema.modPacks)
.where(eq(schema.modPacks.serverId, serverId))
.orderBy(desc(schema.modPacks.updatedAt));
const result: ModPackSummary[] = [];
for (const pack of packs) {
const revisions = await this.db
.select()
.from(schema.modPackRevisions)
.where(eq(schema.modPackRevisions.modPackId, pack.id))
.orderBy(desc(schema.modPackRevisions.version))
.limit(1);
const latest = revisions[0];
const mods = (latest?.mods ?? []) as unknown[];
result.push({
id: pack.id,
name: pack.name,
description: pack.description,
status: pack.status,
modCount: Array.isArray(mods) ? mods.length : 0,
latestVersion: latest?.version ?? null,
updatedAt: pack.updatedAt.toISOString(),
});
}
return result;
}
async getLogCursor(serverId: string) {
const rows = await this.db
.select()
.from(schema.logCursors)
.where(eq(schema.logCursors.serverId, serverId))
.orderBy(desc(schema.logCursors.updatedAt))
.limit(1);
return rows[0] ?? null;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express';
import { z } from 'zod';
import { desc } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import type { PanelUser } from '@reforger-panel/shared';
import { ROLES } from '@reforger-panel/shared';
import type { Db } from '../../db/client.js';
import { schema } from '../../db/client.js';
import { ApiError } from '../../lib/errors.js';
import { requireCapability } from '../auth/auth-middleware.js';
const roleBodySchema = z.object({ role: z.enum(ROLES as [string, ...string[]]) });
export function createUserRouter(db: Db): Router {
const router = Router();
router.use(requireCapability('users.manage', 'Only the owner can manage users.'));
router.get('/', async (_req, res, next) => {
try {
const rows = await db.select().from(schema.users).orderBy(desc(schema.users.createdAt));
const users: PanelUser[] = rows.map((user) => ({
id: user.id,
discordId: user.discordId,
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
role: user.role,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
}));
res.json({ users });
} catch (error) {
next(error);
}
});
router.patch('/:id/role', async (req, res, next) => {
try {
const id = z.string().uuid().safeParse(req.params.id);
if (!id.success) throw ApiError.validation('Invalid user id.');
const body = roleBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid role.');
if (req.user!.id === id.data) {
throw ApiError.validation('You cannot change your own role.');
}
const [updated] = await db
.update(schema.users)
.set({ role: body.data.role as (typeof ROLES)[number] })
.where(eq(schema.users.id, id.data))
.returning();
if (!updated) throw ApiError.notFound('User not found.');
res.json({ ok: true });
} catch (error) {
next(error);
}
});
return router;
}
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { WorkshopClient, normalizeImageUrl } from './workshop-client.js';
const REAL_IMAGE = 'https://ar-gcp-cdn.bistudio.com/image/abcd/1234';
function listResponse() {
return {
status: 'success',
meta: { totalPages: 1, currentPage: 1, totalMods: 2, shownMods: 2 },
data: [
{
name: 'Mod A',
author: 'Author',
imageURL: 'https://via.placeholder.com/640x360',
originalModURL: 'https://reforger.armaplatform.com/workshop/AAAAAAAAAAAAAAA1',
apiModURL: 'https://api.reforgermods.net/v1/mod/AAAAAAAAAAAAAAA1',
size: '1 MB',
rating: '99%',
ID: 'AAAAAAAAAAAAAAA1',
},
],
};
}
function detailResponse(id: string) {
return {
status: 'success',
mod: {
name: 'Mod A',
author: 'Author',
originalModURL: `https://reforger.armaplatform.com/workshop/${id}`,
apiModURL: `https://api.reforgermods.net/v1/mod/${id}`,
// Upstream bug: two URLs concatenated.
imageURL: `https://reforger.armaplatform.com${REAL_IMAGE}`,
rating: '99%',
version: '1.2.0',
size: '1 MB',
id,
tags: [],
dependencies: [],
scenarios: [],
},
};
}
describe('normalizeImageUrl', () => {
it('drops dead placeholder URLs', () => {
expect(normalizeImageUrl('https://via.placeholder.com/640x360')).toBeNull();
});
it('repairs concatenated double URLs', () => {
expect(normalizeImageUrl(`https://reforger.armaplatform.com${REAL_IMAGE}`)).toBe(REAL_IMAGE);
});
it('passes through well-formed URLs and rejects junk', () => {
expect(normalizeImageUrl(REAL_IMAGE)).toBe(REAL_IMAGE);
expect(normalizeImageUrl('')).toBeNull();
expect(normalizeImageUrl('not a url')).toBeNull();
});
});
describe('WorkshopClient image enrichment', () => {
it('warms list images from the detail endpoint in the background and caches them', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url);
if (path.includes('/v1/mod/')) {
const id = path.slice(path.lastIndexOf('/') + 1);
return new Response(JSON.stringify(detailResponse(id)), { status: 200 });
}
return new Response(JSON.stringify(listResponse()), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const first = await client.search('', 1);
expect(first.mods[0]!.imageUrl).toBeNull();
await vi.waitFor(() => {
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCalls).toHaveLength(1);
});
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCalls).toHaveLength(1);
// Second search hits the cache — no extra detail request.
const second = await client.search('', 1);
expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE);
const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCallsAfter).toHaveLength(1);
});
it('leaves the image empty when the detail fetch fails', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url);
if (path.includes('/v1/mod/')) {
return new Response('nope', { status: 500 });
}
return new Response(JSON.stringify(listResponse()), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const result = await client.search('', 1);
expect(result.mods[0]!.imageUrl).toBeNull();
});
});
@@ -0,0 +1,282 @@
import { z } from 'zod';
import type {
WorkshopHealth,
WorkshopModDetail,
WorkshopModPreview,
WorkshopSearchResponse,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { sanitizeErrorMessage } from '../../lib/logger.js';
/**
* Client for the public reforgermods.net Workshop metadata API.
* Endpoint shapes follow https://reforgermods.net/?page=documentation/api:
* GET /v1/health
* GET /v1/mods/{page}?search={q}&sort={sort}
* GET /v1/mod/{mod_id}
* Backend-only the browser never talks to this host directly.
*/
const modPreviewSchema = z.object({
name: z.string(),
author: z.string().catch('Unknown'),
imageURL: z.string().catch(''),
originalModURL: z.string().catch(''),
size: z.string().catch(''),
rating: z.string().catch(''),
ID: z.string(),
});
const searchResponseSchema = z.object({
status: z.string(),
meta: z.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalMods: z.number().catch(0),
}),
data: z.array(modPreviewSchema).catch([]),
});
const modDetailSchema = z.object({
name: z.string(),
author: z.string().catch('Unknown'),
originalModURL: z.string().catch(''),
imageURL: z.string().catch(''),
rating: z.string().catch(''),
version: z.string().nullish(),
gameVersion: z.string().nullish(),
size: z.string().catch(''),
subscribers: z.number().nullish(),
downloads: z.number().nullish(),
created: z.string().nullish(),
lastModified: z.string().nullish(),
id: z.string(),
summary: z.string().nullish(),
description: z.string().nullish(),
license: z.string().nullish(),
tags: z.array(z.string()).catch([]),
dependencies: z.array(z.object({ name: z.string(), apiModURL: z.string().catch('') })).catch([]),
scenarios: z
.array(
z.object({
name: z.string(),
description: z.string().catch(''),
scenarioID: z.string(),
gamemode: z.string().catch(''),
playerCount: z.number().catch(0),
imageURL: z.string().catch(''),
}),
)
.catch([]),
});
const modDetailEnvelopeSchema = z.object({ status: z.string(), mod: modDetailSchema });
export type WorkshopSort = 'popularity' | 'newest' | 'subscribers' | 'version_size';
function extractModId(apiModUrl: string): string | null {
const match = /\/v1\/mod\/([^/?#]+)/.exec(apiModUrl);
return match?.[1] ?? null;
}
/**
* Upstream image URLs need repair: list endpoints return dead
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two
* URLs ("https://reforger.armaplatform.comhttps://ar-gcp-cdn...").
*/
export function normalizeImageUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
if (raw.includes('via.placeholder.com')) return null;
const lastScheme = raw.lastIndexOf('https://');
const candidate = lastScheme > 0 ? raw.slice(lastScheme) : raw;
return candidate.startsWith('http') ? candidate : null;
}
function toPreview(mod: z.infer<typeof modPreviewSchema>): WorkshopModPreview {
return {
id: mod.ID,
name: mod.name,
author: mod.author,
imageUrl: normalizeImageUrl(mod.imageURL),
size: mod.size || null,
rating: mod.rating || null,
workshopUrl: mod.originalModURL || null,
};
}
const IMAGE_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
const IMAGE_FETCH_CONCURRENCY = 5;
export class WorkshopClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
/** modId → real image URL (or null when the mod has none). */
private imageCache = new Map<string, { url: string | null; expiresAt: number }>();
constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? 10_000;
}
private async get(path: string): Promise<unknown> {
let response: Response;
try {
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (error) {
const reason =
error instanceof Error && error.name === 'TimeoutError' ? 'timed out' : 'failed';
throw ApiError.upstream(`Workshop API request ${reason}.`);
}
if (response.status === 404) {
throw ApiError.notFound('Workshop mod not found.');
}
if (response.status === 429) {
throw ApiError.rateLimited('Workshop API rate limit reached. Try again shortly.');
}
if (!response.ok) {
throw ApiError.upstream(`Workshop API returned HTTP ${response.status}.`);
}
return response.json();
}
async health(): Promise<WorkshopHealth> {
const startedAt = Date.now();
try {
await this.get('/v1/health');
return {
ok: true,
latencyMs: Date.now() - startedAt,
checkedAt: new Date().toISOString(),
message: null,
};
} catch (error) {
return {
ok: false,
latencyMs: null,
checkedAt: new Date().toISOString(),
message: sanitizeErrorMessage(error),
};
}
}
async search(query: string, page = 1, sort?: WorkshopSort): Promise<WorkshopSearchResponse> {
const params = new URLSearchParams();
if (query) params.set('search', query);
if (sort) params.set('sort', sort);
const qs = params.size > 0 ? `?${params.toString()}` : '';
const raw = await this.get(`/v1/mods/${Math.max(1, page)}${qs}`);
const parsed = searchResponseSchema.safeParse(raw);
if (!parsed.success) {
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
}
const mods = parsed.data.data.map(toPreview);
this.applyCachedImages(mods);
void this.enrichImages(mods).catch(() => undefined);
return {
mods,
meta: parsed.data.meta,
};
}
private applyCachedImages(mods: WorkshopModPreview[]): void {
const now = Date.now();
for (const mod of mods) {
if (mod.imageUrl) continue;
const cached = this.imageCache.get(mod.id);
if (cached && cached.expiresAt > now) {
mod.imageUrl = cached.url;
}
}
}
/**
* List responses carry no usable images, so fill them in from the detail
* endpoint (which does). This runs as a background cache warmer from search:
* first-load results are fast, later visits pick up cached images.
*/
private async enrichImages(mods: WorkshopModPreview[]): Promise<void> {
const now = Date.now();
const pending: WorkshopModPreview[] = [];
for (const mod of mods) {
if (mod.imageUrl) continue;
const cached = this.imageCache.get(mod.id);
if (cached && cached.expiresAt > now) {
mod.imageUrl = cached.url;
} else {
pending.push(mod);
}
}
if (pending.length === 0) return;
const queue = [...pending];
const worker = async () => {
for (;;) {
const mod = queue.shift();
if (!mod) return;
try {
const detail = await this.getMod(mod.id);
mod.imageUrl = detail.imageUrl;
} catch {
mod.imageUrl = null;
}
this.imageCache.set(mod.id, {
url: mod.imageUrl,
expiresAt: Date.now() + IMAGE_CACHE_TTL_MS,
});
}
};
await Promise.all(
Array.from({ length: Math.min(IMAGE_FETCH_CONCURRENCY, queue.length) }, () => worker()),
);
if (this.imageCache.size > 5_000) {
for (const [key, value] of this.imageCache) {
if (value.expiresAt <= now) this.imageCache.delete(key);
}
}
}
async getMod(modId: string): Promise<WorkshopModDetail> {
const raw = await this.get(`/v1/mod/${encodeURIComponent(modId)}`);
const parsed = modDetailEnvelopeSchema.safeParse(raw);
if (!parsed.success) {
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
}
const mod = parsed.data.mod;
return {
id: mod.id,
name: mod.name,
author: mod.author,
imageUrl: normalizeImageUrl(mod.imageURL),
size: mod.size || null,
rating: mod.rating || null,
workshopUrl: mod.originalModURL || null,
version: mod.version ?? null,
gameVersion: mod.gameVersion ?? null,
subscribers: mod.subscribers ?? null,
downloads: mod.downloads ?? null,
createdAtText: mod.created ?? null,
lastModifiedText: mod.lastModified ?? null,
summary: mod.summary ?? null,
description: mod.description ?? null,
license: mod.license ?? null,
tags: mod.tags,
dependencies: mod.dependencies.map((dep) => ({
name: dep.name,
id: extractModId(dep.apiModURL),
})),
scenarios: mod.scenarios.map((scenario) => ({
name: scenario.name,
description: scenario.description || null,
scenarioId: scenario.scenarioID,
gamemode: scenario.gamemode || null,
playerCount: scenario.playerCount || null,
imageUrl: normalizeImageUrl(scenario.imageURL),
})),
};
}
}
@@ -0,0 +1,57 @@
import { Router } from 'express';
import { z } from 'zod';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth } from '../auth/auth-middleware.js';
import type { WorkshopClient } from './workshop-client.js';
const searchQuerySchema = z.object({
q: z.string().trim().max(100).default(''),
page: z.coerce.number().int().min(1).max(10_000).default(1),
sort: z.enum(['popularity', 'newest', 'subscribers', 'version_size']).optional(),
});
const modIdSchema = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'Invalid mod id.');
export function createWorkshopRouter(client: WorkshopClient): Router {
const router = Router();
// The upstream allows 60 req/min per IP; stay well under it.
const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 30, keyPrefix: 'workshop' });
router.use(requireAuth, workshopRateLimit);
router.get('/health', async (_req, res, next) => {
try {
res.json(await client.health());
} catch (error) {
next(error);
}
});
router.get('/search', async (req, res, next) => {
try {
const parsed = searchQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw ApiError.validation('Invalid search parameters.');
}
const { q, page, sort } = parsed.data;
res.json(await client.search(q, page, sort));
} catch (error) {
next(error);
}
});
router.get('/mods/:id', async (req, res, next) => {
try {
const parsed = modIdSchema.safeParse(req.params.id);
if (!parsed.success) {
throw ApiError.validation('Invalid mod id.');
}
res.json(await client.getMod(parsed.data));
} catch (error) {
next(error);
}
});
return router;
}
+426
View File
@@ -0,0 +1,426 @@
import { describe, expect, it } from 'vitest';
import request from 'supertest';
import type { Role } from '@reforger-panel/shared';
import { createApp } from '../src/app.js';
import { loadEnv } from '../src/env.js';
import { createLogger } from '../src/lib/logger.js';
import type { Db } from '../src/db/client.js';
import {
resolveRoleForLogin,
type SessionService,
type SessionUser,
} from '../src/modules/auth/session-service.js';
import type { ServerModsService } from '../src/modules/config/mods-service.js';
import type { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
import type { ResourceHistoryService } from '../src/modules/servers/resource-history.js';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js';
import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js';
import { WorkshopClient } from '../src/modules/workshop/workshop-client.js';
const OWNER_ID = '111111111111111111';
const TEST_ENV = {
NODE_ENV: 'test',
DATABASE_URL: 'postgresql://unused',
SESSION_SECRET: 'a'.repeat(40),
OWNER_DISCORD_ID: OWNER_ID,
USE_MOCK_PTERODACTYL: 'true',
};
function makeUser(role: Role): SessionUser {
return {
id: `user-${role}`,
discordId: `discord-${role}`,
username: role,
displayName: role,
avatarUrl: null,
role,
};
}
const TOKENS: Record<string, SessionUser> = {
'owner-token': makeUser('owner'),
'admin-token': makeUser('server_admin'),
'lead-token': makeUser('mission_lead'),
'viewer-token': makeUser('viewer'),
};
const trainingServer: ServerRecord = {
id: 'srv-1',
slug: 'training-server',
name: 'Training Server',
providerType: 'pterodactyl',
pterodactylServerId: null,
status: 'online',
maxPlayers: 20,
createdAt: new Date(),
updatedAt: new Date(),
};
function buildApp() {
const env = loadEnv(TEST_ENV as NodeJS.ProcessEnv);
const provider = new MockGameServerProvider();
const activity: { action: string; actorUserId: string | null }[] = [];
const sessions = {
getUserBySessionToken: async (token: string) => TOKENS[token] ?? null,
revokeSession: async () => undefined,
} as unknown as SessionService;
const servers = {
getServerBySlug: async (slug: string) => (slug === 'training-server' ? trainingServer : null),
listServers: async () => [trainingServer],
countOnlinePlayers: async () => 0,
updateStatus: async () => undefined,
recordActivity: async (input: { action: string; actorUserId: string | null }) => {
activity.push(input);
},
getOnlinePlayers: async () => ({
players: [],
onlineCount: 0,
maxPlayers: 20,
lastSyncedAt: null,
stale: true,
}),
getActivity: async () => [],
getConfiguration: async () => ({ current: null, history: [] }),
getModPacks: async () => [],
getKnownPlayers: async () => [],
getLogCursor: async () => null,
} as unknown as ServerService;
const scheduler = {
syncNow: async () => ({
serverId: 'srv-1',
logPath: '/profile/logs/console.log',
fetchedBytes: 0,
processedLines: 0,
createdEvents: 0,
updatedSessions: 0,
cursorReset: false,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
ignoredLines: 0,
invalidTimestamps: 0,
reason: 'no_new_data',
}),
getLastResult: () => null,
} as unknown as IngestionScheduler;
const app = createApp({
env,
logger: createLogger('silent'),
db: {} as Db,
sessions,
servers,
provider,
workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }),
scheduler,
resolveLogPath: async () => '/profile/logs/console.log',
configSync: null,
mods: {
getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }),
setMods: async () => ({
mods: [],
fetchedAt: new Date().toISOString(),
added: 0,
removed: 0,
requiresRestart: true as const,
}),
} as unknown as ServerModsService,
performance: {
get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }),
update: async (_server: unknown, settings: unknown) => ({
settings,
fetchedAt: new Date().toISOString(),
changedFields: [],
requiresRestart: true as const,
}),
} as unknown as PerformanceSettingsService,
resourceHistory: {
history: () => ({ samples: [], intervalSeconds: 15 }),
} as unknown as ResourceHistoryService,
missions: null,
});
return { app, provider, activity };
}
function asUser(token: string) {
return { Cookie: `rp_session=${token}`, 'X-CSRF-Protection': '1' };
}
describe('authentication and roles', () => {
it('rejects unauthenticated requests to /api/auth/me', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/auth/me');
expect(response.status).toBe(401);
expect(response.body.error.code).toBe('UNAUTHENTICATED');
});
it('returns the current user with capabilities', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/auth/me').set(asUser('viewer-token'));
expect(response.status).toBe(200);
expect(response.body.role).toBe('viewer');
expect(response.body.capabilities).toEqual(['server.view']);
});
it('bootstraps the owner role from OWNER_DISCORD_ID and defaults others to viewer', () => {
expect(resolveRoleForLogin(null, OWNER_ID, OWNER_ID)).toBe('owner');
expect(resolveRoleForLogin('viewer', OWNER_ID, OWNER_ID)).toBe('owner');
expect(resolveRoleForLogin(null, '222', OWNER_ID)).toBe('viewer');
expect(resolveRoleForLogin('server_admin', '222', OWNER_ID)).toBe('server_admin');
// No owner configured: nobody is silently promoted.
expect(resolveRoleForLogin(null, '', '')).toBe('viewer');
});
it('requires authentication on server routes', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/servers');
expect(response.status).toBe(401);
});
});
describe('power controls by role', () => {
const cases: { token: string; action: string; expected: number }[] = [
{ token: 'owner-token', action: 'start', expected: 200 },
{ token: 'owner-token', action: 'stop', expected: 200 },
{ token: 'owner-token', action: 'restart', expected: 200 },
{ token: 'admin-token', action: 'start', expected: 200 },
{ token: 'admin-token', action: 'stop', expected: 200 },
{ token: 'admin-token', action: 'restart', expected: 200 },
{ token: 'lead-token', action: 'restart', expected: 200 },
{ token: 'lead-token', action: 'start', expected: 403 },
{ token: 'lead-token', action: 'stop', expected: 403 },
{ token: 'viewer-token', action: 'start', expected: 403 },
{ token: 'viewer-token', action: 'stop', expected: 403 },
{ token: 'viewer-token', action: 'restart', expected: 403 },
];
for (const { token, action, expected } of cases) {
it(`${token.replace('-token', '')} ${action}${expected}`, async () => {
const { app } = buildApp();
const response = await request(app)
.post(`/api/servers/training-server/power/${action}`)
.set(asUser(token));
expect(response.status).toBe(expected);
if (expected === 403) {
expect(response.body.error.code).toBe('FORBIDDEN');
}
});
}
it('simulated power actions still create activity records', async () => {
const { app, activity } = buildApp();
await request(app)
.post('/api/servers/training-server/power/restart')
.set(asUser('lead-token'))
.expect(200);
expect(activity).toHaveLength(1);
expect(activity[0]!.action).toBe('server.power.restart');
});
});
describe('manual log sync and diagnostics', () => {
it('allows only the owner to trigger a manual sync', async () => {
const { app } = buildApp();
await request(app)
.post('/api/servers/training-server/logs/sync')
.set(asUser('owner-token'))
.expect(200);
for (const token of ['admin-token', 'lead-token', 'viewer-token']) {
const response = await request(app)
.post('/api/servers/training-server/logs/sync')
.set(asUser(token));
expect(response.status).toBe(403);
}
});
it('hides log ingestion health from mission leads and viewers', async () => {
const { app } = buildApp();
await request(app)
.get('/api/servers/training-server/logs/health')
.set(asUser('admin-token'))
.expect(200);
await request(app)
.get('/api/servers/training-server/logs/health')
.set(asUser('lead-token'))
.expect(403);
});
it('restricts user management to the owner', async () => {
const { app } = buildApp();
const response = await request(app).get('/api/users').set(asUser('viewer-token'));
expect(response.status).toBe(403);
});
});
describe('mod management by role', () => {
it('allows owner and server_admin to update mods', async () => {
const { app } = buildApp();
for (const token of ['owner-token', 'admin-token']) {
const response = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser(token))
.send({ mods: [{ modId: '591AF5BDA9F7CE8B', name: 'X' }] });
expect(response.status).toBe(200);
}
});
it('forbids mission leads and viewers from updating mods', async () => {
const { app } = buildApp();
for (const token of ['lead-token', 'viewer-token']) {
const response = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser(token))
.send({ mods: [] });
expect(response.status).toBe(403);
}
});
it('rejects invalid mod ids and duplicates', async () => {
const { app } = buildApp();
const bad = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser('owner-token'))
.send({ mods: [{ modId: 'not-a-mod-id' }] });
expect(bad.status).toBe(400);
const dup = await request(app)
.put('/api/servers/training-server/mods')
.set(asUser('owner-token'))
.send({
mods: [{ modId: '591AF5BDA9F7CE8B' }, { modId: '591af5bda9f7ce8b' }],
});
expect(dup.status).toBe(400);
});
});
describe('schedule management by role', () => {
it('allows owner and server_admin to view schedules', async () => {
const { app } = buildApp();
await request(app)
.get('/api/servers/training-server/schedules')
.set(asUser('owner-token'))
.expect(200);
await request(app)
.get('/api/servers/training-server/schedules')
.set(asUser('admin-token'))
.expect(200);
await request(app)
.get('/api/servers/training-server/schedules')
.set(asUser('lead-token'))
.expect(403);
});
it('creates restart schedules through the provider and records activity', async () => {
const { app, activity } = buildApp();
const response = await request(app)
.post('/api/servers/training-server/schedules/restarts')
.set(asUser('admin-token'))
.send({
name: 'Morning restart',
isActive: true,
minute: 30,
hour: 8,
dayOfWeek: '*',
onlyWhenOnline: true,
});
expect(response.status).toBe(200);
expect(response.body.schedule.name).toBe('Morning restart');
expect(response.body.schedule.tasks[0].payload).toBe('restart');
expect(activity.at(-1)?.action).toBe('schedule.restart.created');
});
});
describe('performance config by role', () => {
const validBody = {
maxPlayers: 32,
serverMaxViewDistance: null,
networkViewDistance: null,
serverMinGrassDistance: null,
disableThirdPerson: null,
fastValidation: null,
battlEye: null,
aiLimit: null,
playerSaveTime: null,
slotReservationTimeout: null,
lobbyPlayerSynchronise: null,
};
it('allows owner and server_admin, forbids mission_lead and viewer', async () => {
const { app } = buildApp();
for (const token of ['owner-token', 'admin-token']) {
await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser(token))
.send(validBody)
.expect(200);
}
for (const token of ['lead-token', 'viewer-token']) {
await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser(token))
.send(validBody)
.expect(403);
}
});
it('rejects out-of-range values with the offending field named', async () => {
const { app } = buildApp();
const response = await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser('owner-token'))
.send({ ...validBody, serverMaxViewDistance: 99999 });
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('serverMaxViewDistance');
});
it('serves resource history to any authenticated user', async () => {
const { app } = buildApp();
const response = await request(app)
.get('/api/servers/training-server/resources/history')
.set(asUser('viewer-token'));
expect(response.status).toBe(200);
expect(response.body.intervalSeconds).toBe(15);
});
});
describe('invites', () => {
it('restricts invite management to the owner', async () => {
const { app } = buildApp();
for (const token of ['admin-token', 'lead-token', 'viewer-token']) {
const response = await request(app).get('/api/invites').set(asUser(token));
expect(response.status).toBe(403);
}
});
it('rejects malformed redeem codes before touching the database', async () => {
const { app } = buildApp();
const response = await request(app)
.post('/api/invites/redeem')
.set(asUser('viewer-token'))
.send({ code: '' });
expect(response.status).toBe(400);
});
});
describe('CSRF protection', () => {
it('rejects state-changing requests without the CSRF header', async () => {
const { app } = buildApp();
const response = await request(app)
.post('/api/servers/training-server/power/restart')
.set('Cookie', 'rp_session=owner-token');
expect(response.status).toBe(403);
});
it('rejects cross-origin state-changing requests', async () => {
const { app } = buildApp();
const response = await request(app)
.post('/api/servers/training-server/power/restart')
.set(asUser('owner-token'))
.set('Origin', 'https://evil.example.com');
expect(response.status).toBe(403);
});
});
@@ -0,0 +1,165 @@
import { randomUUID } from 'node:crypto';
import type {
CursorRecord,
IngestionStore,
LogSource,
NewServerEvent,
OpenSessionRecord,
PlayerRecord,
} from '../../src/modules/reforger-logs/ingestion/types.js';
import type { DownloadableFile } from '../../src/modules/pterodactyl/types.js';
export type StoredSession = {
id: string;
serverId: string;
playerId: string;
connectedAt: Date;
disconnectedAt: Date | null;
durationSeconds: number | null;
disconnectReason: string | null;
sourceLogPath: string;
};
export type StoredEvent = NewServerEvent & { id: string };
export class InMemoryIngestionStore implements IngestionStore {
cursors = new Map<string, CursorRecord>();
events: StoredEvent[] = [];
players: (PlayerRecord & { firstSeenAt: Date; lastSeenAt: Date })[] = [];
sessions: StoredSession[] = [];
async getCursor(serverId: string, logPath: string) {
return this.cursors.get(`${serverId}:${logPath}`) ?? null;
}
async saveCursor(cursor: CursorRecord) {
this.cursors.set(`${cursor.serverId}:${cursor.logPath}`, { ...cursor });
}
async insertEventIfNew(event: NewServerEvent) {
const duplicate = this.events.find(
(e) =>
e.serverId === event.serverId &&
e.sourceLogPath === event.sourceLogPath &&
e.sourceLineHash === event.sourceLineHash,
);
if (duplicate) return { created: false, eventId: null };
const stored = { ...event, id: randomUUID() };
this.events.push(stored);
return { created: true, eventId: stored.id };
}
async findPlayerByExternalId(serverId: string, externalPlayerId: string) {
return (
this.players.find(
(p) => p.serverId === serverId && p.externalPlayerId === externalPlayerId,
) ?? null
);
}
async findPlayerByName(serverId: string, displayName: string) {
return (
this.players.find((p) => p.serverId === serverId && p.displayName === displayName) ?? null
);
}
async createPlayer(input: {
serverId: string;
displayName: string;
externalPlayerId: string | null;
seenAt: Date;
}) {
const player = {
id: randomUUID(),
serverId: input.serverId,
displayName: input.displayName,
externalPlayerId: input.externalPlayerId,
firstSeenAt: input.seenAt,
lastSeenAt: input.seenAt,
};
this.players.push(player);
return player;
}
async updatePlayer(
playerId: string,
patch: { externalPlayerId?: string; displayName?: string; lastSeenAt?: Date },
) {
const player = this.players.find((p) => p.id === playerId);
if (player) Object.assign(player, patch);
}
async getOpenSession(serverId: string, playerId: string): Promise<OpenSessionRecord | null> {
const session = this.sessions.find(
(s) => s.serverId === serverId && s.playerId === playerId && s.disconnectedAt === null,
);
return session
? { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt }
: null;
}
async openSession(input: {
serverId: string;
playerId: string;
connectedAt: Date;
sourceLogPath: string;
}) {
const session: StoredSession = {
id: randomUUID(),
serverId: input.serverId,
playerId: input.playerId,
connectedAt: input.connectedAt,
disconnectedAt: null,
durationSeconds: null,
disconnectReason: null,
sourceLogPath: input.sourceLogPath,
};
this.sessions.push(session);
return { id: session.id, playerId: session.playerId, connectedAt: session.connectedAt };
}
async closeSession(
sessionId: string,
input: { disconnectedAt: Date; durationSeconds: number; disconnectReason: string | null },
) {
const session = this.sessions.find((s) => s.id === sessionId);
if (session) Object.assign(session, input);
}
async closeAllOpenSessions(serverId: string, disconnectedAt: Date, reason: string) {
const open = this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null);
for (const session of open) {
session.disconnectedAt = disconnectedAt;
session.durationSeconds = Math.max(
0,
Math.round((disconnectedAt.getTime() - session.connectedAt.getTime()) / 1000),
);
session.disconnectReason = reason;
}
return { closed: open.length };
}
openSessions(serverId: string) {
return this.sessions.filter((s) => s.serverId === serverId && s.disconnectedAt === null);
}
}
/** LogSource whose content can be mutated between syncs to simulate a live file. */
export class FakeLogSource implements LogSource {
content = '';
failWith: Error | null = null;
async fetchLog(_serverId: string, logPath: string, maxBytes: number): Promise<DownloadableFile> {
if (this.failWith) throw this.failWith;
const buffer = Buffer.from(this.content, 'utf8');
const trimmed =
buffer.byteLength > maxBytes ? buffer.subarray(buffer.byteLength - maxBytes) : buffer;
return {
path: logPath,
content: trimmed.toString('utf8'),
totalSizeBytes: buffer.byteLength,
contentStartOffset: buffer.byteLength - trimmed.byteLength,
truncated: trimmed.byteLength < buffer.byteLength,
};
}
}
+229
View File
@@ -0,0 +1,229 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { LogIngestionService } from '../src/modules/reforger-logs/ingestion/ingestion-service.js';
import { createLogger } from '../src/lib/logger.js';
import { FakeLogSource, InMemoryIngestionStore } from './helpers/in-memory-ingestion-store.js';
const SERVER_ID = 'srv-1';
const PROVIDER_ID = 'ptero-1';
const LOG_PATH = '/profile/logs/console.log';
const HEADER = 'Log started 2026-07-04 10:00:00\n';
function connect(time: string, num: number, name: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} (10.0.0.${num}:5000) connected'\n`;
}
function guid(time: string, num: number, name: string, id: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} - GUID: ${id}'\n`;
}
function disconnect(time: string, num: number, name: string): string {
return `${time} DEFAULT : BattlEye Server: 'Player #${num} ${name} disconnected'\n`;
}
function kill(time: string, victim: string, killer: string, friendly = false): string {
return `${time} SCRIPT : ServerAdminTools | Event serveradmintools_player_killed | player: ${victim}, instigator: ${killer}, friendly: ${friendly ? 'true' : 'false'}\n`;
}
describe('LogIngestionService', () => {
let store: InMemoryIngestionStore;
let source: FakeLogSource;
let service: LogIngestionService;
beforeEach(() => {
store = new InMemoryIngestionStore();
source = new FakeLogSource();
service = new LogIngestionService(source, store, createLogger('silent'), {
maxDownloadBytes: 2 * 1024 * 1024,
});
});
const sync = () => service.sync(SERVER_ID, PROVIDER_ID, LOG_PATH);
it('creates players, events, and open sessions from connect lines', async () => {
source.content =
HEADER +
connect('10:05:00.000', 1, 'Braeden') +
guid('10:05:01.000', 1, 'Braeden', 'aabbccdd11223344');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(store.players).toHaveLength(1);
expect(store.players[0]!.externalPlayerId).toBe('aabbccdd11223344');
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
expect(result.cursorReset).toBe(false);
});
it('closes sessions with duration on disconnect', async () => {
source.content =
HEADER + connect('10:00:10.000', 1, 'Braeden') + disconnect('10:42:10.000', 1, 'Braeden');
await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
const session = store.sessions[0]!;
expect(session.durationSeconds).toBe(42 * 60);
});
it('tracks multiple simultaneous players', async () => {
source.content =
HEADER +
connect('10:01:00.000', 1, 'Alpha') +
connect('10:02:00.000', 2, 'Bravo') +
connect('10:03:00.000', 3, 'Charlie') +
disconnect('10:30:00.000', 2, 'Bravo');
await sync();
expect(store.players).toHaveLength(3);
const open = store.openSessions(SERVER_ID);
expect(open).toHaveLength(2);
const openNames = open.map((s) => store.players.find((p) => p.id === s.playerId)!.displayName);
expect(openNames.sort()).toEqual(['Alpha', 'Charlie']);
});
it('stores ServerAdminTools killfeed events', async () => {
source.content = HEADER + kill('10:10:00.000', 'Victim', 'Killer');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(store.events[0]!.eventType).toBe('player_killed');
expect(store.events[0]!.payload).toMatchObject({
killerName: 'Killer',
victimName: 'Victim',
friendly: false,
distanceMeters: null,
weapon: null,
});
expect(store.players.map((player) => player.displayName).sort()).toEqual(['Killer', 'Victim']);
});
it('does not duplicate events when the same content is synced twice', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
// Force a cursor reset by clearing the cursor: same lines get re-read.
store.cursors.clear();
const second = await sync();
expect(second.createdEvents).toBe(0);
expect(store.events).toHaveLength(1);
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
});
it('processes only appended content on subsequent syncs', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
const first = await sync();
source.content += disconnect('10:45:00.000', 1, 'Braeden');
const second = await sync();
expect(first.createdEvents).toBe(1);
expect(second.createdEvents).toBe(1);
expect(second.cursorReset).toBe(false);
expect(second.processedLines).toBe(1);
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
});
it('carries a partial trailing line across syncs and parses it once complete', async () => {
const full = connect('10:05:00.000', 1, 'Braeden');
source.content = HEADER + full.slice(0, 40); // mid-line
const first = await sync();
expect(first.createdEvents).toBe(0);
source.content = HEADER + full;
const second = await sync();
expect(second.createdEvents).toBe(1);
expect(store.events[0]!.eventType).toBe('player_connected');
});
it('handles log rotation: resets the cursor and ingests the new file without duplicates', async () => {
source.content =
HEADER + connect('10:05:00.000', 1, 'Braeden') + disconnect('11:00:00.000', 1, 'Braeden');
await sync();
expect(store.events).toHaveLength(2);
// Rotation: much smaller replacement file with a fresh header.
source.content = 'Log started 2026-07-04 11:30:00\n' + connect('11:31:00.000', 1, 'Sable');
const afterRotation = await sync();
expect(afterRotation.cursorReset).toBe(true);
expect(afterRotation.createdEvents).toBe(1);
expect(store.events).toHaveLength(3);
});
it('bounds the first sync of a huge file instead of importing full history', async () => {
let old = HEADER;
for (let i = 0; i < 30_000; i += 1) {
old += `09:00:00.000 SCRIPT : filler line ${i} ${'x'.repeat(20)}\n`;
}
source.content = old + connect('10:05:00.000', 1, 'Braeden');
const result = await sync();
expect(result.createdEvents).toBe(1);
expect(result.processedLines).toBeLessThan(30_000);
});
it('closes orphaned sessions when a server start is detected', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
source.content += '11:00:00.000 DEFAULT : Game successfully created.\n';
const result = await sync();
expect(store.openSessions(SERVER_ID)).toHaveLength(0);
expect(store.sessions[0]!.disconnectReason).toBe('server_restart');
expect(store.events.map((e) => e.eventType)).toContain('server_restart_detected');
expect(result.updatedSessions).toBe(1);
});
it('closes a stale session when the same player reconnects without a disconnect', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
source.content += connect('12:00:00.000', 4, 'Braeden');
await sync();
const open = store.openSessions(SERVER_ID);
expect(open).toHaveLength(1);
expect(open[0]!.connectedAt.toISOString()).toBe('2026-07-04T12:00:00.000Z');
const closed = store.sessions.find((s) => s.disconnectedAt !== null)!;
expect(closed.disconnectReason).toBe('missed_disconnect');
});
it('records a sanitized error on the cursor when the download fails', async () => {
source.failWith = new Error('connect ETIMEDOUT 10.1.2.3:443 with apiKey=secret123');
await expect(sync()).rejects.toThrow();
const cursor = await store.getCursor(SERVER_ID, LOG_PATH);
expect(cursor?.lastErrorAt).toBeInstanceOf(Date);
expect(cursor?.lastErrorMessage).not.toContain('secret123');
expect(cursor?.lastSuccessfulSyncAt).toBeNull();
// Recovery: the next successful sync clears the error.
source.failWith = null;
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
const recovered = await store.getCursor(SERVER_ID, LOG_PATH);
expect(recovered?.lastErrorAt).toBeNull();
expect(recovered?.lastSuccessfulSyncAt).toBeInstanceOf(Date);
});
it('keeps one player when the logs emit both identityId and BattlEye GUID', async () => {
// Real logs emit BACKEND Authenticated (uuid) then the BE GUID line.
source.content =
HEADER +
'10:04:59.941 BACKEND : Authenticated player: rplIdentity=0x00000000 identityId=33cd5666-3466-477c-aeb8-010df1978756 name=Braeden\n' +
connect('10:05:00.000', 1, 'Braeden') +
guid('10:05:01.000', 1, 'Braeden', '8f1ec46b6979b3a3590e62aa8b757a68');
await sync();
expect(store.players).toHaveLength(1);
// First identity wins; the GUID does not split the player.
expect(store.players[0]!.externalPlayerId).toBe('33cd5666-3466-477c-aeb8-010df1978756');
expect(store.openSessions(SERVER_ID)).toHaveLength(1);
});
it('merges identity lines that arrive in a later sync via open-session name match', async () => {
source.content = HEADER + connect('10:05:00.000', 1, 'Braeden');
await sync();
source.content += guid('10:05:02.000', 1, 'Braeden', 'deadbeef00000001');
await sync();
expect(store.players).toHaveLength(1);
expect(store.players[0]!.externalPlayerId).toBe('deadbeef00000001');
});
});
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { ServerModsService } from '../src/modules/config/mods-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
import type { ServerRecord } from '../src/modules/servers/server-service.js';
import { createLogger } from '../src/lib/logger.js';
import { ApiError } from '../src/lib/errors.js';
const server: ServerRecord = {
id: 'srv-1',
slug: 'training-server',
name: 'SCAR Operations',
providerType: 'pterodactyl',
pterodactylServerId: 'abc123',
status: 'online',
maxPlayers: 16,
createdAt: new Date(),
updatedAt: new Date(),
};
describe('ServerModsService', () => {
let provider: MockGameServerProvider;
let service: ServerModsService;
let configSyncCalled: number;
beforeEach(() => {
provider = new MockGameServerProvider();
configSyncCalled = 0;
const configSync = {
sync: async () => {
configSyncCalled += 1;
return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 };
},
} as unknown as ConfigSyncService;
service = new ServerModsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
});
it('reads the current mods from config.json', async () => {
const result = await service.getMods(server);
expect(result.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
]);
});
it('writes the new mod list while preserving every other config field', async () => {
const result = await service.setMods(server, [
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
]);
expect(result.added).toBe(1);
expect(result.removed).toBe(0);
expect(result.requiresRestart).toBe(true);
expect(result.mods).toHaveLength(2);
const written = provider.writtenFiles.get('/config.json')!;
const parsed = JSON.parse(written);
// game.mods replaced…
expect(parsed.game.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
]);
// …everything else untouched.
expect(parsed.bindPort).toBe(2001);
expect(parsed.game.name).toBe('Mock Reforger Server');
expect(parsed.game.maxPlayers).toBe(16);
expect(parsed.operating.aiLimit).toBe(40);
});
it('writes a rollback backup of the previous file before modifying it', async () => {
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
await service.setMods(server, []);
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
// Removal reflected in the live file.
const after = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(after.game.mods).toEqual([]);
});
it('imports a config revision after a successful write', async () => {
await service.setMods(server, []);
expect(configSyncCalled).toBe(1);
});
it('normalizes mod ids to uppercase and drops empty name/version', async () => {
const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]);
});
it('fails the write when read-back verification does not match', async () => {
// Simulate a server that ignores writes to config.json.
const originalWrite = provider.writeTextFile.bind(provider);
vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => {
if (path === '/config.json') return; // swallow the write
await originalWrite(sid, path, content);
});
await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/);
});
it('refuses to modify a config without a game section', async () => {
await provider.writeTextFile('abc123', '/config.json', '{"something": true}');
await expect(service.setMods(server, [])).rejects.toThrow(ApiError);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
import type { ServerRecord } from '../src/modules/servers/server-service.js';
import { createLogger } from '../src/lib/logger.js';
const server: ServerRecord = {
id: 'srv-1',
slug: 'training-server',
name: 'SCAR Operations',
providerType: 'pterodactyl',
pterodactylServerId: 'abc123',
status: 'online',
maxPlayers: 16,
createdAt: new Date(),
updatedAt: new Date(),
};
describe('PerformanceSettingsService', () => {
let provider: MockGameServerProvider;
let service: PerformanceSettingsService;
beforeEach(() => {
provider = new MockGameServerProvider();
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
service = new PerformanceSettingsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
});
it('reads current values, reporting absent keys as null', async () => {
const { settings } = await service.get(server);
// Present in the mock config.json:
expect(settings.maxPlayers).toBe(16);
expect(settings.serverMaxViewDistance).toBe(2500);
expect(settings.aiLimit).toBe(40);
expect(settings.disableThirdPerson).toBe(false);
// Absent keys:
expect(settings.playerSaveTime).toBeNull();
expect(settings.fastValidation).toBeNull();
});
it('sets changed values and removes nulled keys, preserving everything else', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, {
...settings,
maxPlayers: 32,
playerSaveTime: 180, // new key
aiLimit: null, // remove key → game default
});
expect(result.changedFields.sort()).toEqual(['aiLimit', 'maxPlayers', 'playerSaveTime']);
expect(result.requiresRestart).toBe(true);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(written.game.maxPlayers).toBe(32);
expect(written.operating.playerSaveTime).toBe(180);
expect('aiLimit' in written.operating).toBe(false);
// Untouched fields preserved:
expect(written.bindPort).toBe(2001);
expect(written.game.scenarioId).toContain('Missions');
expect(written.game.mods).toHaveLength(1);
// Backup written:
expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy();
});
it('does not write the file at all when nothing changed', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, settings);
expect(result.changedFields).toEqual([]);
expect(provider.writtenFiles.has('/config.json')).toBe(false);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it, vi } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { PterodactylProvider } from '../src/modules/pterodactyl/pterodactyl-provider.js';
import { ApiError } from '../src/lib/errors.js';
const API_KEY = 'ptlc_super_secret_key_123';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('MockGameServerProvider', () => {
it('reports online with plausible resources by default', async () => {
const provider = new MockGameServerProvider();
expect(await provider.getServerStatus()).toBe('online');
const resources = await provider.getServerResources();
expect(resources.status).toBe('online');
expect(resources.memoryLimitBytes).toBeGreaterThan(0);
expect(resources.uptimeMs).toBeGreaterThan(0);
provider.dispose();
});
it('transitions through stopping on stop', async () => {
const provider = new MockGameServerProvider();
await provider.stopServer();
expect(await provider.getServerStatus()).toBe('stopping');
provider.dispose();
});
it('serves a parseable console.log fixture', async () => {
const provider = new MockGameServerProvider();
const file = await provider.downloadTextFile('any', '/profile/logs/console.log');
expect(file.content).toContain('connected');
expect(file.content).toContain('Log started');
expect(file.contentStartOffset).toBe(0);
provider.dispose();
});
it('rejects unknown paths instead of exposing a file system', async () => {
const provider = new MockGameServerProvider();
await expect(provider.downloadTextFile('any', '/etc/passwd')).rejects.toThrow(ApiError);
provider.dispose();
});
});
describe('PterodactylProvider', () => {
it('maps resource responses from the Client API', async () => {
const fetchImpl = vi.fn(async (url: string | URL, _init?: RequestInit) => {
const path = String(url);
if (path.endsWith('/resources')) {
return jsonResponse({
object: 'stats',
attributes: {
current_state: 'running',
resources: {
memory_bytes: 1024,
cpu_absolute: 51.5,
disk_bytes: 2048,
network_rx_bytes: 10,
network_tx_bytes: 20,
uptime: 5000,
},
},
});
}
return jsonResponse({ attributes: { limits: { cpu: 400, memory: 8192, disk: 40960 } } });
});
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const resources = await provider.getServerResources('abc123');
expect(resources.status).toBe('online');
expect(resources.cpuPercent).toBe(51.5);
expect(resources.cpuLimitPercent).toBe(400);
expect(resources.memoryLimitBytes).toBe(8192 * 1024 * 1024);
const [calledUrl, calledInit] = fetchImpl.mock.calls[0]!;
expect(String(calledUrl)).toBe('https://panel.example.com/api/client/servers/abc123/resources');
const headers = calledInit?.headers as Record<string, string>;
expect(headers.Authorization).toBe(`Bearer ${API_KEY}`);
});
it('sends power signals with the expected body', async () => {
const fetchImpl = vi.fn(async (_url: string | URL, _init?: RequestInit) => {
return new Response(null, { status: 204 });
});
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
await provider.restartServer('abc123');
const [url, init] = fetchImpl.mock.calls[0]!;
expect(String(url)).toContain('/power');
expect(init?.method).toBe('POST');
expect(JSON.parse(String(init?.body))).toEqual({ signal: 'restart' });
});
it('maps HTTP errors without leaking the API key or full URL', async () => {
const fetchImpl = vi.fn(async () => new Response('nope', { status: 500 }));
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const error = await provider.getServerResources('abc123').catch((e: unknown) => e as ApiError);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).code).toBe('UPSTREAM_UNAVAILABLE');
expect((error as ApiError).message).not.toContain(API_KEY);
expect((error as ApiError).message).not.toContain('panel.example.com');
expect((error as ApiError).message).toContain('500');
});
it('maps timeouts to a sanitized upstream error', async () => {
const timeoutError = new Error('The operation was aborted due to timeout');
timeoutError.name = 'TimeoutError';
const fetchImpl = vi.fn(async () => {
throw timeoutError;
});
const provider = new PterodactylProvider({
baseUrl: 'https://panel.example.com',
apiKey: API_KEY,
fetchImpl: fetchImpl as unknown as typeof fetch,
timeoutMs: 50,
});
const error = await provider.getServerStatus('abc123').catch((e: unknown) => e as ApiError);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toContain('timed out');
expect((error as ApiError).message).not.toContain(API_KEY);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"]
},
"include": ["src", "test", "tsup.config.ts", "drizzle.config.ts", "vitest.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/db/migrate.ts', 'src/db/seed.ts'],
format: ['esm'],
target: 'node22',
sourcemap: true,
clean: true,
// Bundle the source-only workspace package into the output.
noExternal: ['@reforger-panel/shared'],
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
environment: 'node',
},
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reforger Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@reforger-panel/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@reforger-panel/shared": "*",
"@tanstack/react-query": "^5.80.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.8.0",
"vite": "^6.3.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect } from 'react';
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { useCurrentUser } from './api/hooks.js';
import { api, ApiClientError } from './api/client.js';
import { Layout } from './components/layout.js';
import { Spinner } from './components/ui.js';
import { LoginPage } from './pages/login.js';
import { OverviewPage } from './pages/overview.js';
import { ModsPage } from './pages/mods.js';
import { LogsPage } from './pages/logs.js';
import {
ActivityPage,
ConfigurationsPage,
KillfeedPage,
PlayersPage,
SettingsPage,
} from './pages/simple-pages.js';
const queryClient = new QueryClient();
/** Redeems a stored invite code once, right after login, then refreshes /me. */
function InviteRedeemer() {
const client = useQueryClient();
useEffect(() => {
const code = localStorage.getItem('rp_invite');
if (!code) return;
localStorage.removeItem('rp_invite');
void api
.post('/api/invites/redeem', { code })
.then(() => client.invalidateQueries({ queryKey: ['auth', 'me'] }))
.catch(() => undefined); // invalid/expired codes fail quietly
}, [client]);
return null;
}
function AuthGate() {
const { data: user, isLoading, error } = useCurrentUser();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<Spinner label="Checking session…" />
</div>
);
}
if (error instanceof ApiClientError && error.status === 401) {
return <LoginPage />;
}
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-danger-400">
Could not reach the panel API. Is the backend running?
</div>
);
}
return (
<>
<InviteRedeemer />
<Routes>
<Route element={<Layout user={user} />}>
<Route index element={<OverviewPage user={user} />} />
<Route path="/mods" element={<ModsPage user={user} />} />
<Route path="/configuration" element={<ConfigurationsPage user={user} />} />
<Route path="/players" element={<PlayersPage />} />
<Route path="/killfeed" element={<KillfeedPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage user={user} />} />
{/* Old bookmarks from the tabbed server page and plural path. */}
<Route path="/server/:slug" element={<Navigate to="/" replace />} />
<Route path="/configurations" element={<Navigate to="/configuration" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</>
);
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthGate />
</BrowserRouter>
</QueryClientProvider>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { ApiErrorBody } from '@reforger-panel/shared';
export class ApiClientError extends Error {
readonly code: string;
readonly status: number;
constructor(status: number, code: string, message: string) {
super(message);
this.code = code;
this.status = status;
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = init.method ?? 'GET';
const headers: Record<string, string> = { ...(init.headers as Record<string, string>) };
if (method !== 'GET' && method !== 'HEAD') {
headers['X-CSRF-Protection'] = '1';
if (init.body) headers['Content-Type'] = 'application/json';
}
const response = await fetch(path, { ...init, method, headers, credentials: 'same-origin' });
if (!response.ok) {
let code = 'INTERNAL_ERROR';
let message = `Request failed (${response.status})`;
try {
const body = (await response.json()) as ApiErrorBody;
code = body.error.code;
message = body.error.message;
} catch {
// non-JSON error body
}
throw new ApiClientError(response.status, code, message);
}
return (await response.json()) as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body === undefined ? undefined : JSON.stringify(body),
}),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PUT',
body: body === undefined ? undefined : JSON.stringify(body),
}),
patch: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PATCH',
body: body === undefined ? undefined : JSON.stringify(body),
}),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
+371
View File
@@ -0,0 +1,371 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
ActivityItem,
ConfigurationResponse,
CurrentUser,
InviteSummary,
KillfeedEvent,
MissionsResponse,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
RawLogsResponse,
RestartScheduleInput,
ResourceHistoryResponse,
StartupResponse,
KnownPlayer,
LogIngestionHealth,
LogSyncResult,
ModPackSummary,
PanelUser,
PlayersResponse,
ReforgerConfigMod,
ServerModsResponse,
UpdateModsResult,
ServerResources,
ServerScheduleSummary,
ServerSummary,
WorkshopHealth,
WorkshopModDetail,
WorkshopSearchResponse,
} from '@reforger-panel/shared';
import { api, ApiClientError } from './client.js';
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'me'],
queryFn: () => api.get<CurrentUser>('/api/auth/me'),
retry: (failureCount, error) =>
!(error instanceof ApiClientError && error.status === 401) && failureCount < 2,
staleTime: 60_000,
});
}
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.post('/api/auth/logout'),
onSuccess: () => queryClient.clear(),
});
}
export function useServers() {
return useQuery({
queryKey: ['servers'],
queryFn: () => api.get<{ servers: ServerSummary[] }>('/api/servers'),
refetchInterval: 15_000,
});
}
export function useServer(slug: string) {
return useQuery({
queryKey: ['servers', slug],
queryFn: () => api.get<ServerSummary>(`/api/servers/${slug}`),
refetchInterval: 15_000,
});
}
export function useServerResources(slug: string, enabled = true) {
return useQuery({
queryKey: ['servers', slug, 'resources'],
queryFn: () => api.get<ServerResources>(`/api/servers/${slug}/resources`),
refetchInterval: 10_000,
enabled,
});
}
export function usePlayers(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'players'],
queryFn: () => api.get<PlayersResponse>(`/api/servers/${slug}/players`),
refetchInterval: 15_000,
});
}
export function useKnownPlayers(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'players', 'known'],
queryFn: () => api.get<{ players: KnownPlayer[] }>(`/api/servers/${slug}/players/known`),
refetchInterval: 30_000,
});
}
export function useActivity(slug: string, limit = 50) {
return useQuery({
queryKey: ['servers', slug, 'activity', limit],
queryFn: () =>
api.get<{ activity: ActivityItem[] }>(`/api/servers/${slug}/activity?limit=${limit}`),
refetchInterval: 20_000,
});
}
export function useKillfeed(slug: string, limit = 100) {
return useQuery({
queryKey: ['servers', slug, 'killfeed', limit],
queryFn: () =>
api.get<{ events: KillfeedEvent[] }>(`/api/servers/${slug}/killfeed?limit=${limit}`),
refetchInterval: 10_000,
});
}
export function useConfiguration(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'configuration'],
queryFn: () => api.get<ConfigurationResponse>(`/api/servers/${slug}/configuration`),
// Live download from the game server on each fetch — keep it calm.
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useMissions(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'missions'],
queryFn: () => api.get<MissionsResponse>(`/api/servers/${slug}/missions`),
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
}
export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'raw', lines],
queryFn: () => api.get<RawLogsResponse>(`/api/servers/${slug}/logs/raw?lines=${lines}`),
refetchInterval: autoRefresh ? 10_000 : false,
enabled,
});
}
export function useStartupVariables(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'startup'],
queryFn: () => api.get<StartupResponse>(`/api/servers/${slug}/startup`),
staleTime: 60_000,
refetchOnWindowFocus: false,
enabled,
});
}
export function useUpdateStartupVariable(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { key: string; value: string }) =>
api.put<{ ok: boolean; requiresRestart: boolean }>(
`/api/servers/${slug}/startup/variable`,
input,
),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'startup'] }),
});
}
export function useModPacks(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mod-packs'],
queryFn: () => api.get<{ modPacks: ModPackSummary[] }>(`/api/servers/${slug}/mod-packs`),
});
}
export function useLogHealth(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'health'],
queryFn: () => api.get<LogIngestionHealth>(`/api/servers/${slug}/logs/health`),
refetchInterval: 20_000,
enabled,
});
}
export function usePowerAction(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (action: 'start' | 'stop' | 'restart') =>
api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers'] });
},
});
}
export function useResourceHistory(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'resources', 'history'],
queryFn: () => api.get<ResourceHistoryResponse>(`/api/servers/${slug}/resources/history`),
refetchInterval: 15_000,
});
}
export function usePerformanceSettings(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'config', 'performance'],
queryFn: () => api.get<PerformanceSettingsResponse>(`/api/servers/${slug}/config/performance`),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useSetPerformanceSettings(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: PerformanceSettingsPatch) =>
api.put<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: boolean }>(
`/api/servers/${slug}/config/performance`,
settings,
),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useInvites(enabled: boolean) {
return useQuery({
queryKey: ['invites'],
queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'),
enabled,
});
}
export function useCreateInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { role: string; expiresInHours?: number | null }) =>
api.post<{ id: string; code: string; role: string; expiresAt: string }>(
'/api/invites',
input,
),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useDeleteInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/invites/${id}`),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useServerMods(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods'],
queryFn: () => api.get<ServerModsResponse>(`/api/servers/${slug}/mods`),
// Each call downloads config.json from Pterodactyl — no background polling.
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useSetServerMods(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useManualLogSync(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.post<LogSyncResult>(`/api/servers/${slug}/logs/sync`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useServerSchedules(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'schedules'],
queryFn: () =>
api.get<{ schedules: ServerScheduleSummary[]; fetchedAt: string }>(
`/api/servers/${slug}/schedules`,
),
enabled,
staleTime: 30_000,
});
}
export function useCreateRestartSchedule(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: RestartScheduleInput) =>
api.post<{ schedule: ServerScheduleSummary }>(
`/api/servers/${slug}/schedules/restarts`,
input,
),
onSuccess: () =>
void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }),
});
}
export function useUpdateRestartSchedule(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: RestartScheduleInput }) =>
api.put<{ schedule: ServerScheduleSummary }>(
`/api/servers/${slug}/schedules/${id}/restart`,
input,
),
onSuccess: () =>
void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }),
});
}
export function useDeleteSchedule(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/servers/${slug}/schedules/${id}`),
onSuccess: () =>
void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }),
});
}
export function useWorkshopHealth() {
return useQuery({
queryKey: ['workshop', 'health'],
queryFn: () => api.get<WorkshopHealth>('/api/workshop/health'),
refetchInterval: 60_000,
});
}
export function useWorkshopSearch(query: string, page: number, sort?: string) {
return useQuery({
queryKey: ['workshop', 'search', query, page, sort],
queryFn: () =>
api.get<WorkshopSearchResponse>(
`/api/workshop/search?q=${encodeURIComponent(query)}&page=${page}${
sort ? `&sort=${encodeURIComponent(sort)}` : ''
}`,
),
// An empty query browses the Workshop front page (/v1/mods).
placeholderData: (previous) => previous,
staleTime: 5 * 60_000,
});
}
export function useWorkshopMod(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId],
queryFn: () => api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`),
enabled: modId !== null,
staleTime: 5 * 60_000,
});
}
export function useUsers(enabled: boolean) {
return useQuery({
queryKey: ['users'],
queryFn: () => api.get<{ users: PanelUser[] }>('/api/users'),
enabled,
});
}
export function useSetUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
api.patch(`/api/users/${userId}/role`, { role }),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
+81
View File
@@ -0,0 +1,81 @@
export type ChartSeries = {
points: { t: number; v: number }[];
/** Any CSS color; used for the line and (when filled) the area. */
color: string;
fill?: boolean;
label?: string;
};
/**
* Dependency-free SVG time-series chart. Series share the x (time) axis and a
* single y scale (`max` fixes it, e.g. 100 for CPU%; otherwise it fits data).
*/
export function TimeSeriesChart({
series,
max,
height = 64,
className = '',
}: {
series: ChartSeries[];
max?: number | null;
height?: number;
className?: string;
}) {
const allPoints = series.flatMap((s) => s.points);
if (allPoints.length < 2) {
return (
<div
style={{ height }}
className={`flex items-center justify-center rounded bg-graphite-850 text-xs text-slate-dim ${className}`}
>
collecting data
</div>
);
}
const tMin = Math.min(...allPoints.map((p) => p.t));
const tMax = Math.max(...allPoints.map((p) => p.t));
const dataMax = Math.max(...allPoints.map((p) => p.v), 0);
const scale = max && max > 0 ? max : dataMax > 0 ? dataMax * 1.15 : 1;
const tSpan = Math.max(1, tMax - tMin);
const W = 100;
const H = 40;
const x = (t: number) => ((t - tMin) / tSpan) * W;
const y = (v: number) => H - Math.min(1, Math.max(0, v / scale)) * H;
return (
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ height }}
className={`w-full ${className}`}
role="img"
>
{/* 50% guide line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="currentColor" strokeOpacity="0.08" />
{series.map((s, index) => {
if (s.points.length < 2) return null;
const line = s.points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`)
.join(' ');
const first = s.points[0]!;
const last = s.points[s.points.length - 1]!;
const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`;
return (
<g key={s.label ?? index}>
{s.fill !== false && <path d={area} fill={s.color} fillOpacity="0.12" />}
<path
d={line}
fill="none"
stroke={s.color}
strokeWidth="1.1"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</svg>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react';
import type { Role } from '@reforger-panel/shared';
import { ROLE_LABELS } from '@reforger-panel/shared';
import { useCreateInvite, useDeleteInvite, useInvites } from '../api/hooks.js';
import { formatDateTime, formatRelativeTime } from '../lib/format.js';
import { Button, Card, EmptyState, RoleBadge, Spinner } from './ui.js';
const INVITABLE_ROLES: Role[] = ['server_admin', 'mission_lead', 'viewer'];
const INVITE_DURATIONS = [
{ label: 'Never expires', value: 'never', hours: null },
{ label: '7 days', value: '168', hours: 168 },
{ label: '30 days', value: '720', hours: 720 },
] as const;
function inviteLink(code: string): string {
return `${window.location.origin}/?invite=${code}`;
}
function isEffectivelyPermanent(expiresAt: string): boolean {
return new Date(expiresAt).getTime() - Date.now() > 20 * 365 * 24 * 60 * 60 * 1000;
}
export function InvitesCard() {
const { data, isLoading } = useInvites(true);
const createInvite = useCreateInvite();
const deleteInvite = useDeleteInvite();
const [role, setRole] = useState<Role>('mission_lead');
const [duration, setDuration] = useState<(typeof INVITE_DURATIONS)[number]['value']>('never');
const [copied, setCopied] = useState<string | null>(null);
const copy = async (code: string) => {
try {
await navigator.clipboard.writeText(inviteLink(code));
setCopied(code);
setTimeout(() => setCopied(null), 2000);
} catch {
setCopied(null);
}
};
return (
<Card
title="Invites"
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<select
value={role}
onChange={(event) => setRole(event.target.value as Role)}
className="input min-w-0 py-1.5"
>
{INVITABLE_ROLES.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
<select
value={duration}
onChange={(event) =>
setDuration(event.target.value as (typeof INVITE_DURATIONS)[number]['value'])
}
className="input min-w-0 py-1.5"
>
{INVITE_DURATIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<Button
variant="accent"
disabled={createInvite.isPending}
onClick={() =>
createInvite.mutate({
role,
expiresInHours: INVITE_DURATIONS.find((option) => option.value === duration)!.hours,
})
}
>
{createInvite.isPending ? 'Creating…' : 'Create invite'}
</Button>
</div>
}
>
{isLoading || !data ? (
<Spinner />
) : data.invites.length === 0 ? (
<EmptyState
title="No invites yet"
hint="Create one and send the link — the recipient logs in with Discord and gets the role automatically."
/>
) : (
<ul className="space-y-2">
{data.invites.map((invite) => {
const permanent = isEffectivelyPermanent(invite.expiresAt);
const expired = !permanent && new Date(invite.expiresAt).getTime() < Date.now();
const state = invite.usedAt ? 'used' : expired ? 'expired' : 'active';
return (
<li
key={invite.id}
className="flex flex-wrap items-center justify-between gap-3 rounded border border-graphite-800 px-3.5 py-2.5"
>
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-sm">
<code className="font-mono text-zinc-200">{invite.code}</code>
<RoleBadge role={invite.role} />
{state === 'active' && <span className="text-xs text-accent-400">active</span>}
{state === 'used' && (
<span className="text-xs text-slate-dim">
used by {invite.usedBy} {formatRelativeTime(invite.usedAt)}
</span>
)}
{state === 'expired' && <span className="text-xs text-warn-400">expired</span>}
</p>
<p className="text-xs text-slate-dim">
{permanent ? 'never expires' : `expires ${formatDateTime(invite.expiresAt)}`} ·
created by {invite.createdBy ?? '—'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{state === 'active' && (
<Button onClick={() => void copy(invite.code)}>
{copied === invite.code ? 'Copied!' : 'Copy link'}
</Button>
)}
<Button
variant="danger"
disabled={deleteInvite.isPending}
onClick={() => deleteInvite.mutate(invite.id)}
>
{state === 'active' ? 'Revoke' : 'Remove'}
</Button>
</div>
</li>
);
})}
</ul>
)}
<p className="mt-3 text-xs text-slate-dim">
Invite links are single-use and grant the selected role at login. Redeemed roles persist
until you change them under Users & roles.
</p>
</Card>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import type { Capability, CurrentUser } from '@reforger-panel/shared';
import { useLogout, useServers } from '../api/hooks.js';
import { RoleBadge, StatusBadge } from './ui.js';
import { PowerControls } from './widgets.js';
const NAV_ITEMS: {
to: string;
label: string;
exact?: boolean;
capability?: Capability;
}[] = [
{ to: '/', label: 'Overview', exact: true },
{ to: '/mods', label: 'Mods' },
{ to: '/configuration', label: 'Configuration' },
{ to: '/players', label: 'Players' },
{ to: '/killfeed', label: 'Killfeed' },
{ to: '/activity', label: 'Activity' },
{ to: '/logs', label: 'Logs', capability: 'ops.health.view' },
{ to: '/settings', label: 'Settings' },
];
export function Layout({ user }: { user: CurrentUser }) {
const logout = useLogout();
const { data: serversData } = useServers();
const server = serversData?.servers[0];
const [navOpen, setNavOpen] = useState(false);
return (
<div className="flex min-h-screen">
{navOpen && (
<div
aria-hidden
onClick={() => setNavOpen(false)}
className="fixed inset-0 z-20 bg-black/60 backdrop-blur-sm lg:hidden"
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-30 flex h-dvh w-56 shrink-0 flex-col border-r border-graphite-700/70 bg-graphite-900 transition-transform duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
navOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex min-h-16 items-center border-b border-graphite-700/60 px-5">
<div>
<p className="text-[13px] font-semibold uppercase leading-tight tracking-[0.12em] text-zinc-100">
DZR.TOOLS
</p>
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">
ARMA REFORGER OPS
</p>
</div>
</div>
<nav className="min-h-0 flex-1 space-y-1 overflow-y-auto p-3">
{NAV_ITEMS.filter(
(item) => !item.capability || user.capabilities.includes(item.capability),
).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.exact}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`block rounded-md border border-transparent px-3.5 py-2.5 text-sm transition-colors ${
isActive
? 'border-graphite-700 bg-graphite-850 font-medium text-zinc-100'
: 'text-slate-ink hover:bg-graphite-800 hover:text-zinc-200'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-graphite-700/60 px-5 py-4">
<div className="flex items-center gap-2.5">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt=""
className="h-8 w-8 rounded-full border border-graphite-600"
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-graphite-700 text-sm font-semibold text-zinc-300">
{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-200">{user.displayName ?? user.username}</p>
<RoleBadge role={user.role} />
</div>
<button
type="button"
title="Log out"
onClick={() =>
logout.mutate(undefined, { onSuccess: () => window.location.reload() })
}
className="rounded-md border border-graphite-600 px-2 py-1 text-xs text-slate-ink transition-colors hover:border-danger-400/50 hover:text-danger-400"
>
Exit
</button>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex min-h-16 shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-graphite-700/60 bg-graphite-900/85 px-4 py-3 backdrop-blur sm:px-6">
<button
type="button"
aria-label="Open navigation"
onClick={() => setNavOpen(true)}
className="rounded-md border border-graphite-600 p-2 text-slate-ink transition-colors hover:text-zinc-200 lg:hidden"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="h-5 w-5">
<path d="M4 6h16M4 12h16M4 18h16" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
{server ? (
<div className="flex min-w-0 flex-1 items-center gap-3 sm:gap-4">
<div className="min-w-28 truncate">
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">Server</p>
<h2 className="truncate text-base font-semibold text-zinc-100">{server.name}</h2>
</div>
<StatusBadge status={server.status} />
<span className="hidden text-sm text-slate-ink md:inline">
{server.onlinePlayerCount} / {server.maxPlayers ?? '—'} players
</span>
</div>
) : (
<div className="flex-1" />
)}
{server && <PowerControls user={user} server={server} />}
</header>
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<Outlet />
</main>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js';
import { shortScenario } from './widgets.js';
function missionSourceLabel(source: string): string {
if (source === 'official') return '';
if (source.startsWith('mod: ')) return `Mod: ${source.slice(5)}`;
return source;
}
/**
* Mission switcher. Options come from the scenario listing the server prints
* at boot (requires the -listScenarios launch flag, standard on Reforger eggs).
*/
export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data: config, refetch } = useConfiguration(slug);
const { data: missions } = useMissions(slug);
const save = useSetPerformanceSettings(slug);
const [selected, setSelected] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
if (!config) {
return (
<Card title="Mission">
<Spinner />
</Card>
);
}
const current = config.config.scenarioId;
const currentName =
missions?.missions.find((m) => m.scenarioId === current)?.name ?? shortScenario(current);
const value = selected ?? current;
const dirty = value !== current;
const submit = () => {
setMessage(null);
save.mutate(
{ scenarioId: value },
{
onSuccess: () => {
setSelected(null);
setMessage('Mission saved to config.json — restart the server to switch.');
void refetch();
},
onError: (error) => setMessage(error.message),
},
);
};
return (
<Card
title="Mission"
action={
canEdit &&
dirty && (
<div className="flex items-center gap-2">
<Button onClick={() => setSelected(null)} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" onClick={submit} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</div>
)
}
>
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-0 flex-1">
<p className="text-lg font-medium text-zinc-100">{currentName}</p>
<p className="truncate font-mono text-xs text-slate-dim" title={current}>
{shortScenario(current)}
</p>
</div>
{canEdit &&
(missions && missions.missions.length > 0 ? (
<select
value={value}
onChange={(event) => {
setMessage(null);
setSelected(event.target.value);
}}
className="input max-w-xs"
>
{!missions.missions.some((m) => m.scenarioId === current) && (
<option value={current}>{currentName} (current)</option>
)}
{missions.missions.map((mission) => (
<option key={mission.scenarioId} value={mission.scenarioId}>
{mission.name}
{missionSourceLabel(mission.source)
? ` [${missionSourceLabel(mission.source)}]`
: ''}
</option>
))}
</select>
) : (
<p className="text-xs text-slate-dim">
No scenario listing found in the current log make sure the server runs with
-listScenarios and has booted recently.
</p>
))}
</div>
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
</Card>
);
}
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
import type { PerformanceSettings } from '@reforger-panel/shared';
import { usePerformanceSettings, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js';
type NumberKey = {
[K in keyof PerformanceSettings]: PerformanceSettings[K] extends number | null ? K : never;
}[keyof PerformanceSettings];
type BooleanKey = Exclude<keyof PerformanceSettings, NumberKey>;
// Ranges/defaults from the Bohemia server-config reference. Blank fields are
// omitted from config.json so the game default applies.
// maxPlayers is deliberately absent: it is controlled via the MAX_PLAYERS
// startup variable to avoid two "max players" inputs on one page.
const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number; hint: string }[] = [
{
key: 'serverMaxViewDistance',
label: 'Server view distance (m)',
min: 500,
max: 10000,
hint: 'default 1600',
},
{
key: 'networkViewDistance',
label: 'Network view distance (m)',
min: 500,
max: 5000,
hint: 'default 1500',
},
{
key: 'serverMinGrassDistance',
label: 'Min grass distance (m)',
min: 0,
max: 150,
hint: '0 = client choice',
},
{ key: 'aiLimit', label: 'AI limit', min: -1, max: 1000, hint: '-1 = unlimited' },
{
key: 'playerSaveTime',
label: 'Player save interval (s)',
min: 1,
max: 3600,
hint: 'default 120',
},
{
key: 'slotReservationTimeout',
label: 'Slot reservation timeout (s)',
min: 5,
max: 300,
hint: 'default 60',
},
];
const BOOLEAN_FIELDS: { key: BooleanKey; label: string; hint: string }[] = [
{ key: 'disableThirdPerson', label: 'Disable third person', hint: 'default disabled' },
{ key: 'fastValidation', label: 'Fast validation', hint: 'default enabled' },
{ key: 'battlEye', label: 'BattlEye', hint: 'default enabled' },
{ key: 'lobbyPlayerSynchronise', label: 'Lobby player sync', hint: 'default enabled' },
];
type FormState = Record<string, string>;
function toFormState(settings: PerformanceSettings): FormState {
const state: FormState = {};
for (const field of NUMBER_FIELDS) {
const value = settings[field.key];
state[field.key] = value === null ? '' : String(value);
}
for (const field of BOOLEAN_FIELDS) {
const value = settings[field.key];
state[field.key] = value === null ? '' : String(value);
}
return state;
}
export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data, isLoading, error: loadError } = usePerformanceSettings(slug);
const save = useSetPerformanceSettings(slug);
const [form, setForm] = useState<FormState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (data && form === null) setForm(toFormState(data.settings));
}, [data, form]);
if (isLoading || (!form && !loadError)) return <Spinner label="Downloading config.json…" />;
if (loadError) return <p className="text-sm text-danger-400">{loadError.message}</p>;
if (!form || !data) return null;
const baseline = toFormState(data.settings);
const dirty = Object.keys(form).some((key) => form[key] !== baseline[key]);
const set = (key: string, value: string) => {
setMessage(null);
setForm({ ...form, [key]: value });
};
const validateAndBuild = (): PerformanceSettings | null => {
const errors: Record<string, string> = {};
const result = {} as Record<string, number | boolean | null>;
for (const field of NUMBER_FIELDS) {
const raw = (form[field.key] ?? '').trim();
if (raw === '') {
result[field.key] = null;
continue;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < field.min || value > field.max) {
errors[field.key] = `Must be a whole number between ${field.min} and ${field.max}.`;
continue;
}
result[field.key] = value;
}
for (const field of BOOLEAN_FIELDS) {
const raw = form[field.key] ?? '';
result[field.key] = raw === '' ? null : raw === 'true';
}
setFieldErrors(errors);
return Object.keys(errors).length > 0 ? null : (result as unknown as PerformanceSettings);
};
const submit = () => {
const settings = validateAndBuild();
if (!settings) return;
save.mutate(settings, {
onSuccess: (result) => {
setForm(null); // re-derive from the fresh server response on next load
setMessage(
result.changedFields.length > 0
? `Saved ${result.changedFields.length} change${result.changedFields.length === 1 ? '' : 's'} to config.json — restart the server to apply.`
: 'No changes to save.',
);
},
onError: (saveError) => setMessage(saveError.message),
});
};
const inputClass = (key: string) => `input w-32 ${fieldErrors[key] ? 'input-error' : ''}`;
return (
<Card
title="Performance settings (config.json)"
action={
canEdit &&
dirty && (
<div className="flex items-center gap-2">
<span className="text-xs text-warn-400">unsaved changes</span>
<Button onClick={() => setForm(toFormState(data.settings))} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" onClick={submit} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</div>
)
}
>
<div className="grid gap-x-8 gap-y-4 md:grid-cols-2">
{NUMBER_FIELDS.map((field) => (
<div key={field.key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-zinc-200">{field.label}</p>
<p className="text-xs text-slate-dim">
{field.min}{field.max} · {field.hint} · blank = game default
</p>
{fieldErrors[field.key] && (
<p className="text-xs text-danger-400">{fieldErrors[field.key]}</p>
)}
</div>
<input
type="number"
inputMode="numeric"
min={field.min}
max={field.max}
disabled={!canEdit}
value={form[field.key] ?? ''}
placeholder="default"
onChange={(event) => set(field.key, event.target.value)}
className={inputClass(field.key)}
/>
</div>
))}
{BOOLEAN_FIELDS.map((field) => (
<div key={field.key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-zinc-200">{field.label}</p>
<p className="text-xs text-slate-dim">{field.hint}</p>
</div>
<select
disabled={!canEdit}
value={form[field.key] ?? ''}
onChange={(event) => set(field.key, event.target.value)}
className="input w-32"
>
<option value="">Game default</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
))}
</div>
{message && <p className="mt-4 text-xs text-accent-400">{message}</p>}
<p className="mt-4 text-xs text-slate-dim">
Values are validated against the ranges in the Bohemia server-config reference and written
directly to config.json (backup kept as config.json.bak). Network/identity settings (bind
address, ports, passwords) are never touched here. Changes apply on the next restart.
</p>
</Card>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useEffect, useMemo, useState } from 'react';
import type { RestartScheduleInput, ServerScheduleSummary } from '@reforger-panel/shared';
import {
useCreateRestartSchedule,
useDeleteSchedule,
useServerSchedules,
useUpdateRestartSchedule,
} from '../api/hooks.js';
import { formatDateTime } from '../lib/format.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
const DAYS = [
{ value: '*', label: 'Every day' },
{ value: '0', label: 'Sunday' },
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
] as const;
function pad(n: number): string {
return String(n).padStart(2, '0');
}
function timeValue(schedule: ServerScheduleSummary): string {
const hour = Number(schedule.hour);
const minute = Number(schedule.minute);
if (!Number.isInteger(hour) || !Number.isInteger(minute)) return '09:00';
return `${pad(hour)}:${pad(minute)}`;
}
function isRestartSchedule(schedule: ServerScheduleSummary): boolean {
return schedule.tasks.some((task) => task.action === 'power' && task.payload === 'restart');
}
function describeSchedule(schedule: ServerScheduleSummary): string {
const day = DAYS.find((d) => d.value === schedule.dayOfWeek)?.label ?? schedule.dayOfWeek;
return `${day} at ${timeValue(schedule)}`;
}
function inputFromSchedule(schedule: ServerScheduleSummary): RestartScheduleInput {
const [hour, minute] = timeValue(schedule).split(':').map(Number);
return {
name: schedule.name,
isActive: schedule.isActive,
minute: minute ?? 0,
hour: hour ?? 9,
dayOfWeek: DAYS.some((d) => d.value === schedule.dayOfWeek)
? (schedule.dayOfWeek as RestartScheduleInput['dayOfWeek'])
: '*',
onlyWhenOnline: schedule.onlyWhenOnline,
};
}
const DEFAULT_INPUT: RestartScheduleInput = {
name: 'Daily restart',
isActive: true,
minute: 0,
hour: 9,
dayOfWeek: '*',
onlyWhenOnline: true,
};
export function SchedulesCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data, isLoading, error } = useServerSchedules(slug, canEdit);
const createSchedule = useCreateRestartSchedule(slug);
const updateSchedule = useUpdateRestartSchedule(slug);
const deleteSchedule = useDeleteSchedule(slug);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<RestartScheduleInput>(DEFAULT_INPUT);
const [message, setMessage] = useState<string | null>(null);
const schedules = data?.schedules ?? [];
const restartSchedules = useMemo(() => schedules.filter(isRestartSchedule), [schedules]);
const editing = restartSchedules.find((schedule) => schedule.id === editingId) ?? null;
useEffect(() => {
if (editing) setForm(inputFromSchedule(editing));
}, [editing]);
if (!canEdit) return null;
const submit = () => {
setMessage(null);
const options = {
onSuccess: () => {
setMessage(editingId ? 'Restart schedule updated.' : 'Restart schedule created.');
setEditingId(null);
setForm(DEFAULT_INPUT);
},
onError: (err: Error) => setMessage(err.message),
};
if (editingId) {
updateSchedule.mutate({ id: editingId, input: form }, options);
return;
}
createSchedule.mutate(form, options);
};
const busy = createSchedule.isPending || updateSchedule.isPending || deleteSchedule.isPending;
return (
<Card title="Restart schedules">
{isLoading ? (
<Spinner />
) : error ? (
<p className="text-sm text-danger-400">{error.message}</p>
) : (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="min-w-0">
{restartSchedules.length === 0 ? (
<EmptyState
title="No restart schedules"
hint="Create one here instead of switching back to Pterodactyl."
/>
) : (
<ul className="space-y-2">
{restartSchedules.map((schedule) => (
<li
key={schedule.id}
className="flex items-center justify-between gap-3 rounded-md border border-graphite-800 bg-graphite-950/20 px-3.5 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-200">{schedule.name}</p>
<p className="text-xs text-slate-dim">
{describeSchedule(schedule)} ·{' '}
{schedule.onlyWhenOnline ? 'only when online' : 'runs regardless'} ·{' '}
{schedule.isActive ? 'active' : 'paused'}
</p>
<p className="text-xs text-slate-dim">
next run {schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '—'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
disabled={busy}
onClick={() => {
setEditingId(schedule.id);
setMessage(null);
}}
>
Edit
</Button>
<Button
variant="danger"
disabled={busy}
onClick={() =>
deleteSchedule.mutate(schedule.id, {
onSuccess: () => setMessage('Schedule deleted.'),
onError: (err) => setMessage(err.message),
})
}
>
Delete
</Button>
</div>
</li>
))}
</ul>
)}
</div>
<div className="rounded-md border border-graphite-800 bg-graphite-950/20 p-4">
<h3 className="text-sm font-semibold text-zinc-200">
{editingId ? 'Edit restart' : 'New restart'}
</h3>
<div className="mt-3 space-y-3">
<label className="block">
<span className="mb-1 block text-xs text-slate-dim">Name</span>
<input
className="input w-full"
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-1 block text-xs text-slate-dim">Time</span>
<input
className="input w-full"
type="time"
value={`${pad(form.hour)}:${pad(form.minute)}`}
onChange={(event) => {
const [hour, minute] = event.target.value.split(':').map(Number);
setForm({ ...form, hour: hour ?? 0, minute: minute ?? 0 });
}}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-slate-dim">Day</span>
<select
className="input w-full"
value={form.dayOfWeek}
onChange={(event) =>
setForm({
...form,
dayOfWeek: event.target.value as RestartScheduleInput['dayOfWeek'],
})
}
>
{DAYS.map((day) => (
<option key={day.value} value={day.value}>
{day.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={form.isActive}
onChange={(event) => setForm({ ...form, isActive: event.target.checked })}
/>
Active
</label>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={form.onlyWhenOnline}
onChange={(event) => setForm({ ...form, onlyWhenOnline: event.target.checked })}
/>
Only run when server is online
</label>
<div className="flex items-center gap-2">
<Button
variant="accent"
disabled={busy || form.name.trim() === ''}
onClick={submit}
>
{busy ? 'Saving…' : editingId ? 'Save schedule' : 'Create schedule'}
</Button>
{editingId && (
<Button
disabled={busy}
onClick={() => {
setEditingId(null);
setForm(DEFAULT_INPUT);
setMessage(null);
}}
>
Cancel
</Button>
)}
</div>
{message && <p className="text-xs text-slate-dim">{message}</p>}
</div>
</div>
</div>
)}
</Card>
);
}
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { useStartupVariables, useUpdateStartupVariable } from '../api/hooks.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
/**
* Pterodactyl egg startup variables (passwords, launch options, ). Values
* are only visible to owner/server admin; changes apply on the next restart.
*/
// Controlled elsewhere in the panel (mission dropdown) or intentionally not
// exposed; hidden here to avoid duplicate/confusing inputs.
const HIDDEN_VARIABLES = new Set(['SCENARIO_ID', 'PUBLIC_ADDRESS']);
export function StartupVarsCard({ slug }: { slug: string }) {
const { data, isLoading, error } = useStartupVariables(slug, true);
const update = useUpdateStartupVariable(slug);
const [edits, setEdits] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const isSecret = (name: string) => /password|token|secret|key/i.test(name);
const saveVariable = (envVariable: string) => {
const value = edits[envVariable];
if (value === undefined) return;
setMessage(null);
update.mutate(
{ key: envVariable, value },
{
onSuccess: () => {
setEdits((prev) => {
const next = { ...prev };
delete next[envVariable];
return next;
});
setMessage(`${envVariable} saved — applies on the next restart.`);
},
onError: (updateError) => setMessage(updateError.message),
},
);
};
return (
<Card title="Startup variables (Pterodactyl)">
{isLoading ? (
<Spinner />
) : error ? (
<p className="text-sm text-danger-400">{error.message}</p>
) : !data || data.variables.length === 0 ? (
<EmptyState title="No startup variables" hint="The egg exposes none for this server." />
) : (
<ul className="space-y-3">
{data.variables
.filter((variable) => !HIDDEN_VARIABLES.has(variable.envVariable))
.map((variable) => {
const edited = edits[variable.envVariable];
const secret = isSecret(variable.envVariable) || isSecret(variable.name);
const shown = revealed[variable.envVariable] ?? false;
return (
<li
key={variable.envVariable}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-graphite-800 px-3.5 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-zinc-200">
{variable.name}{' '}
<code className="ml-1 text-xs text-slate-dim">{variable.envVariable}</code>
</p>
{variable.description && (
<p className="mt-0.5 text-xs text-slate-dim">{variable.description}</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<input
type={secret && !shown ? 'password' : 'text'}
className="input w-48"
disabled={!variable.isEditable || update.isPending}
value={edited ?? variable.value}
placeholder={variable.defaultValue || 'empty'}
onChange={(event) =>
setEdits({ ...edits, [variable.envVariable]: event.target.value })
}
/>
{secret && (
<Button
onClick={() => setRevealed({ ...revealed, [variable.envVariable]: !shown })}
>
{shown ? 'Hide' : 'Show'}
</Button>
)}
{variable.isEditable ? (
edited !== undefined &&
edited !== variable.value && (
<Button
variant="accent"
disabled={update.isPending}
onClick={() => saveVariable(variable.envVariable)}
>
Save
</Button>
)
) : (
<span className="text-xs text-slate-dim">read-only</span>
)}
</div>
</li>
);
})}
</ul>
)}
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
<p className="mt-3 text-xs text-slate-dim">
These are the same variables as Pterodactyl's Startup tab (server passwords live here, not
in config.json). Changes apply on the next server restart.
</p>
</Card>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useState, type ReactNode } from 'react';
import type { Role, ServerStatus } from '@reforger-panel/shared';
import { ROLE_LABELS } from '@reforger-panel/shared';
export function Card({
title,
action,
children,
className = '',
padded = true,
}: {
title?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
padded?: boolean;
}) {
return (
<section className={`panel-card ${className}`}>
{title !== undefined && (
<header className="panel-card-header">
<h2 className="panel-card-title">{title}</h2>
{action}
</header>
)}
<div className={padded ? 'p-5' : ''}>{children}</div>
</section>
);
}
/** Image with a quiet placeholder when the URL is missing or fails to load. */
export function ModImage({ src, className = '' }: { src: string | null; className?: string }) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
return (
<span
className={`flex shrink-0 items-center justify-center rounded-md border border-graphite-700 bg-graphite-800 text-slate-dim ${className}`}
>
<svg viewBox="0 0 24 24" fill="none" className="h-1/2 w-1/2" stroke="currentColor">
<rect x="3" y="4" width="18" height="16" rx="2" strokeWidth="1.5" />
<circle cx="9" cy="10" r="1.75" strokeWidth="1.5" />
<path d="M4 18l5-5 3 3 4-4 4 4" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
</span>
);
}
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className={`shrink-0 rounded-md border border-graphite-700 object-cover ${className}`}
/>
);
}
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
online: { dot: 'bg-accent-400', text: 'text-accent-400', label: 'Online' },
offline: { dot: 'bg-zinc-500', text: 'text-zinc-400', label: 'Offline' },
starting: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Starting' },
stopping: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Stopping' },
unknown: { dot: 'bg-zinc-600', text: 'text-zinc-500', label: 'Unknown' },
};
export function StatusBadge({ status }: { status: ServerStatus }) {
const style = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
return (
<span
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-current/20 bg-current/5 px-2.5 py-1 text-xs font-semibold ${style.text}`}
>
<span className={`h-2 w-2 rounded-full ${style.dot}`} />
{style.label}
</span>
);
}
const ROLE_STYLES: Record<Role, string> = {
owner: 'border-accent-500/40 bg-accent-500/10 text-accent-400',
server_admin: 'border-sky-500/40 bg-sky-500/10 text-sky-400',
mission_lead: 'border-warn-400/40 bg-warn-400/10 text-warn-400',
viewer: 'border-zinc-600 bg-zinc-800/60 text-zinc-400',
};
export function RoleBadge({ role }: { role: Role }) {
return (
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${ROLE_STYLES[role]}`}
>
{ROLE_LABELS[role]}
</span>
);
}
export function EmptyState({ title, hint }: { title: string; hint?: string }) {
return (
<div className="flex flex-col items-center justify-center gap-1 rounded-md border border-dashed border-graphite-700 bg-graphite-950/35 px-4 py-8 text-center">
<p className="text-sm font-medium text-zinc-300">{title}</p>
{hint && <p className="text-xs text-slate-dim">{hint}</p>}
</div>
);
}
export function Spinner({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-slate-dim">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-graphite-600 border-t-accent-500" />
{label}
</div>
);
}
export function StatBar({
value,
max,
warnAt = 0.8,
}: {
value: number;
max: number | null;
warnAt?: number;
}) {
if (!max || max <= 0) return null;
const ratio = Math.min(1, value / max);
const color = ratio >= warnAt ? 'bg-warn-400' : 'bg-accent-500';
return (
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-graphite-700">
<div className={`h-full rounded-full ${color}`} style={{ width: `${ratio * 100}%` }} />
</div>
);
}
export function Button({
children,
onClick,
disabled,
variant = 'default',
title,
}: {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: 'default' | 'accent' | 'danger';
title?: string;
}) {
const variants = {
default:
'border-graphite-600 bg-graphite-800 text-zinc-300 hover:border-graphite-600 hover:bg-graphite-700',
accent: 'border-accent-600/60 bg-accent-600/15 text-accent-400 hover:bg-accent-600/25',
danger: 'border-danger-400/40 bg-danger-400/10 text-danger-400 hover:bg-danger-400/20',
} as const;
return (
<button
type="button"
title={title}
onClick={onClick}
disabled={disabled}
className={`inline-flex min-h-9 items-center justify-center rounded-md border px-3.5 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]}`}
>
{children}
</button>
);
}
+343
View File
@@ -0,0 +1,343 @@
import { useState } from 'react';
import type {
ActivityItem,
Capability,
ConfigurationResponse,
CurrentUser,
PlayersResponse,
ServerSummary,
} from '@reforger-panel/shared';
import {
useActivity,
useLogHealth,
useManualLogSync,
usePlayers,
usePowerAction,
useWorkshopHealth,
} from '../api/hooks.js';
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
function can(user: CurrentUser, capability: Capability): boolean {
return user.capabilities.includes(capability);
}
export function PowerControls({ user, server }: { user: CurrentUser; server: ServerSummary }) {
const power = usePowerAction(server.slug);
const [message, setMessage] = useState<string | null>(null);
const run = (action: 'start' | 'stop' | 'restart') => {
setMessage(null);
power.mutate(action, {
onSuccess: (result) =>
setMessage(result.simulated ? `${action} simulated (mock mode)` : `${action} requested`),
onError: (error) => setMessage(error.message),
});
};
const canStart = can(user, 'server.power.start');
const canStop = can(user, 'server.power.stop');
const canRestart = can(user, 'server.power.restart');
if (!canStart && !canStop && !canRestart) return null;
return (
<div className="flex w-full flex-wrap items-center justify-end gap-2 md:w-auto">
{canStart && (
<Button
variant="accent"
disabled={power.isPending || server.status === 'online'}
onClick={() => run('start')}
>
Start
</Button>
)}
{canRestart && (
<Button disabled={power.isPending} onClick={() => run('restart')}>
Restart
</Button>
)}
{canStop && (
<Button
variant="danger"
disabled={power.isPending || server.status === 'offline'}
onClick={() => run('stop')}
>
Stop
</Button>
)}
{message && <span className="text-xs text-slate-dim">{message}</span>}
</div>
);
}
export function CurrentPlayersCard({
slug,
maxPlayers,
}: {
slug: string;
maxPlayers: number | null;
}) {
const { data, isLoading } = usePlayers(slug);
return (
<Card
title="Current players"
action={
data && (
<span className="text-xs text-slate-dim">
{data.stale ? (
<span className="text-warn-400">data may be stale</span>
) : (
<>last synchronized {formatRelativeTime(data.lastSyncedAt)}</>
)}
</span>
)
}
>
{isLoading || !data ? (
<Spinner />
) : (
<PlayersTable players={data} maxPlayers={maxPlayers ?? data.maxPlayers} />
)}
</Card>
);
}
function PlayersTable({
players,
maxPlayers,
}: {
players: PlayersResponse;
maxPlayers: number | null;
}) {
return (
<div>
<p className="mb-4 text-3xl font-semibold text-zinc-100">
{players.onlineCount}
<span className="text-base font-normal text-slate-dim"> / {maxPlayers ?? '—'} online</span>
</p>
{players.players.length === 0 ? (
<EmptyState
title="No players connected"
hint="Player presence is reconstructed from server logs and updates on each sync."
/>
) : (
<div className="data-table-scroll">
<table className="data-table">
<thead>
<tr>
<th>Player</th>
<th>Connected since</th>
<th className="text-right">Session</th>
</tr>
</thead>
<tbody>
{players.players.map((player) => (
<tr key={player.playerId}>
<td className="py-2 font-medium text-zinc-200">{player.displayName}</td>
<td className="py-2 text-slate-ink">{formatDateTime(player.connectedAt)}</td>
<td className="py-2 text-right font-mono text-xs text-accent-400">
{formatDuration(player.sessionDurationSeconds)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
const ACTIVITY_COLORS: Record<string, string> = {
player_connected: 'text-accent-400',
player_disconnected: 'text-slate-ink',
server_started: 'text-accent-400',
server_stopped: 'text-warn-400',
server_restart_detected: 'text-warn-400',
log_sync_failed: 'text-danger-400',
};
function logTimestamp(iso: string): string {
const date = new Date(iso);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
/** Log-style feed: monospace timestamps, fixed height, scrolls. */
export function ActivityList({
items,
maxHeight = 320,
}: {
items: ActivityItem[];
maxHeight?: number;
}) {
if (items.length === 0) {
return (
<EmptyState title="No activity yet" hint="Panel actions and server events appear here." />
);
}
return (
<div
className="overflow-y-auto rounded-md border border-graphite-800 bg-graphite-950/70 font-mono text-xs shadow-inner"
style={{ maxHeight }}
>
<ul>
{items.map((item) => (
<li
key={item.id}
className="flex items-baseline gap-3 border-b border-graphite-800/60 px-3 py-1.5 last:border-0 hover:bg-graphite-850/80"
title={new Date(item.occurredAt).toLocaleString()}
>
<span className="shrink-0 text-slate-dim">{logTimestamp(item.occurredAt)}</span>
<span
className={`min-w-0 flex-1 truncate ${ACTIVITY_COLORS[item.action] ?? 'text-zinc-300'}`}
>
{item.summary}
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-slate-dim">
{item.kind === 'panel_action' ? 'panel' : 'server'}
</span>
</li>
))}
</ul>
</div>
);
}
export function RecentActivityCard({ slug, limit = 50 }: { slug: string; limit?: number }) {
const { data, isLoading } = useActivity(slug, limit);
return (
<Card title="Recent activity">
{isLoading || !data ? <Spinner /> : <ActivityList items={data.activity} />}
</Card>
);
}
/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */
export function shortScenario(scenarioId: string): string {
const slash = scenarioId.lastIndexOf('/');
return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId;
}
export function ConfigSummaryRows({ config }: { config: ConfigurationResponse }) {
const c = config.config;
const rows: [string, string][] = [
['Mission', shortScenario(c.scenarioId)],
['Max players', String(c.maxPlayers)],
// Reforger uses -1 for "no AI limit".
['AI limit', c.aiLimit < 0 ? 'Unlimited' : String(c.aiLimit)],
['View distance', `${c.serverMaxViewDistance} m (network ${c.networkViewDistance} m)`],
['Third person', c.disableThirdPerson ? 'Disabled' : 'Allowed'],
['Cross-platform', c.crossPlatform ? 'Enabled' : 'Disabled'],
['Mods', `${c.mods.length}`],
];
return (
<dl className="space-y-2">
{rows.map(([label, value]) => (
<div key={label} className="flex items-baseline justify-between gap-4">
<dt className="shrink-0 text-xs uppercase tracking-wider text-slate-dim">{label}</dt>
<dd
className="truncate text-right font-mono text-xs text-zinc-300"
title={label === 'Mission' ? c.scenarioId : value}
>
{value}
</dd>
</div>
))}
</dl>
);
}
export function OpsHealthCard({ user, slug }: { user: CurrentUser; slug: string }) {
const visible = can(user, 'ops.health.view');
const { data: workshop } = useWorkshopHealth();
const { data: logs } = useLogHealth(slug, visible);
const syncNow = useManualLogSync(slug);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
if (!visible) return null;
return (
<Card
title="Operational health"
action={
can(user, 'logs.sync') && (
<Button
disabled={syncNow.isPending || logs?.configured === false}
onClick={() =>
syncNow.mutate(undefined, {
onSuccess: (result) =>
setSyncMessage(
`Synced: ${result.processedLines} lines, ${result.createdEvents} new events`,
),
onError: (error) => setSyncMessage(error.message),
})
}
>
{syncNow.isPending ? 'Syncing…' : 'Sync logs now'}
</Button>
)
}
>
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Workshop API</dt>
<dd>
{workshop ? (
workshop.ok ? (
<span className="text-accent-400">
healthy · {workshop.latencyMs} ms · {formatRelativeTime(workshop.checkedAt)}
</span>
) : (
<span className="text-danger-400" title={workshop.message ?? undefined}>
unreachable
</span>
)
) : (
<span className="text-slate-dim">checking</span>
)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Log ingestion</dt>
<dd>
{!logs ? (
<span className="text-slate-dim">checking</span>
) : !logs.configured ? (
<span className="text-slate-dim">not configured</span>
) : logs.stale ? (
<span className="text-warn-400">stale</span>
) : (
<span className="text-accent-400">healthy</span>
)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Last successful sync</dt>
<dd className="text-zinc-300">
{formatRelativeTime(logs?.lastSuccessfulSyncAt ?? null)}
</dd>
</div>
{logs?.lastSync && (
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Last sync processed</dt>
<dd className="font-mono text-xs text-zinc-300">
{logs.lastSync.processedLines} lines · {logs.lastSync.createdEvents} events
</dd>
</div>
)}
{logs?.lastErrorMessage && (
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 text-slate-ink">Last sync error</dt>
<dd
className="truncate text-xs text-danger-400"
title={`${formatRelativeTime(logs.lastErrorAt)}: ${logs.lastErrorMessage}`}
>
{logs.lastErrorMessage}
</dd>
</div>
)}
{syncMessage && <p className="text-xs text-slate-dim">{syncMessage}</p>}
</dl>
</Card>
);
}
+124
View File
@@ -0,0 +1,124 @@
@import 'tailwindcss';
@theme {
--color-graphite-950: #12161b;
--color-graphite-900: #191e24;
--color-graphite-850: #20262e;
--color-graphite-800: #29313a;
--color-graphite-700: #3a4552;
--color-graphite-600: #505c69;
--color-slate-ink: #b1bac4;
--color-slate-dim: #838e9a;
--color-accent-500: #6f8fab;
--color-accent-400: #9bb4ca;
--color-accent-600: #58758e;
--color-warn-400: #d2a85b;
--color-danger-400: #d37a70;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
}
body {
@apply bg-graphite-950 text-zinc-200 antialiased;
background: var(--color-graphite-950);
}
button,
a,
input,
select,
textarea {
@apply outline-none;
}
:focus-visible {
@apply ring-2 ring-accent-500/45 ring-offset-2 ring-offset-graphite-950;
}
::selection {
background: color-mix(in srgb, var(--color-accent-500) 35%, transparent);
}
.panel-card {
/* min-w-0 lets cards shrink inside grid tracks instead of widening them. */
@apply min-w-0 rounded-lg border border-graphite-700/70 bg-graphite-900 shadow-sm shadow-black/20;
}
.panel-card-header {
@apply flex flex-wrap items-center justify-between gap-3 border-b border-graphite-700/60 px-5 py-4;
}
.panel-card-title {
@apply text-xs font-semibold uppercase tracking-[0.14em] text-slate-ink;
}
.page-title {
@apply text-2xl font-semibold text-zinc-100;
letter-spacing: 0;
}
.page-kicker {
@apply mt-1 max-w-2xl text-sm leading-6 text-slate-ink;
}
.input {
@apply rounded-md border border-graphite-600 bg-graphite-950/55 px-3 py-2 text-sm text-zinc-200 shadow-sm transition-colors placeholder:text-slate-dim hover:border-slate-dim/70 focus:border-accent-500 disabled:cursor-not-allowed disabled:opacity-50;
}
.input-error {
@apply border-danger-400/70 focus:border-danger-400 focus:ring-danger-400/30;
}
/* No native number spinners — they clash with the theme. */
input[type='number'].input {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number'].input::-webkit-inner-spin-button,
input[type='number'].input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Selects: replace the native chrome with a themed chevron. */
select.input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b98a5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65rem center;
padding-right: 2rem;
}
select.input option {
@apply bg-graphite-850 text-zinc-200;
}
.data-table-scroll {
@apply overflow-x-auto;
}
.data-table {
@apply w-full min-w-fit text-sm;
}
.data-table th,
.data-table td {
@apply whitespace-nowrap pr-4 last:pr-0;
}
.data-table thead tr {
@apply border-b border-graphite-700/60 text-left text-[11px] uppercase tracking-wider text-slate-dim;
}
.data-table th {
@apply pb-2 font-medium;
}
.data-table tbody tr {
@apply border-b border-graphite-800/80 last:border-0 hover:bg-graphite-850/50;
}
.data-table td {
@apply py-2.5;
}
+47
View File
@@ -0,0 +1,47 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const exponent = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1);
const value = bytes / 2 ** (10 * exponent);
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`;
}
export function formatDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '—';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `${days}d ${hours % 24}h`;
}
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m`;
return `${Math.floor(totalSeconds)}s`;
}
export function formatRelativeTime(iso: string | null): string {
if (!iso) return '—';
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '—';
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? '' : 's'} ago`;
}
export function formatDateTime(iso: string | null): string {
if (!iso) return '—';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client.js';
function DiscordMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
<path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.865-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.1 18.058a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.291.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.06.06 0 0 0-.031-.03ZM8.02 15.331c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418Zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418Z" />
</svg>
);
}
export function LoginPage() {
const [devError, setDevError] = useState<string | null>(null);
const { data: options } = useQuery({
queryKey: ['auth', 'options'],
queryFn: () => api.get<{ discord: boolean; devLogin: boolean }>('/api/auth/options'),
staleTime: Infinity,
});
// Invite links land here before login; stash the code so it can be redeemed
// automatically right after the Discord round-trip.
const inviteCode = new URLSearchParams(window.location.search).get('invite');
if (inviteCode) {
localStorage.setItem('rp_invite', inviteCode);
}
const pendingInvite = inviteCode ?? localStorage.getItem('rp_invite');
const devLogin = async () => {
try {
await api.post('/api/auth/dev-login');
window.location.reload();
} catch {
setDevError('Dev login is not enabled (set DEV_AUTH_BYPASS=true locally).');
}
};
return (
<div className="relative flex min-h-dvh items-center justify-center overflow-hidden px-4">
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 h-[38rem] w-[38rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent-500/10 blur-3xl"
/>
<div className="relative w-full max-w-sm rounded-lg border border-graphite-700/70 bg-graphite-900 p-8 shadow-xl shadow-black/40">
<div
aria-hidden
className="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent"
/>
<div className="mb-8 text-center">
<span className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-lg border border-graphite-600 bg-graphite-850 font-mono text-lg font-bold tracking-tight text-accent-400 shadow-inner shadow-black/30">
DZR
</span>
<h1 className="text-xl font-semibold uppercase tracking-[0.14em] text-zinc-100">
DZR.TOOLS
</h1>
<p className="mt-1.5 text-[11px] font-medium uppercase tracking-[0.24em] text-slate-dim">
Arma Reforger Ops
</p>
</div>
{pendingInvite && (
<p className="mb-4 rounded-md border border-accent-600/40 bg-accent-600/10 px-3 py-2.5 text-center text-xs font-medium text-accent-400">
Invite detected. Sign in with Discord and the role will be applied automatically.
</p>
)}
<a
href="/api/auth/discord"
className="flex w-full items-center justify-center gap-2.5 rounded-md bg-[#5865F2] px-4 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
<DiscordMark className="h-5 w-5" />
Continue with Discord
</a>
{options?.devLogin && (
<button
type="button"
onClick={() => void devLogin()}
className="mt-3 w-full rounded-md border border-graphite-600 px-4 py-2.5 text-center text-xs font-medium text-slate-dim transition-colors hover:text-zinc-300"
>
Local development login
</button>
)}
{devError && <p className="mt-2 text-center text-xs text-danger-400">{devError}</p>}
</div>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react';
import { useRawLogs, useServers } from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js';
import { Button, Card, Spinner } from '../components/ui.js';
export function LogsPage() {
const { data: serversData } = useServers();
const slug = serversData?.servers[0]?.slug;
const [lines, setLines] = useState(300);
const [autoRefresh, setAutoRefresh] = useState(true);
const [follow, setFollow] = useState(true);
const { data, isLoading, error, refetch, isFetching } = useRawLogs(
slug ?? '',
lines,
autoRefresh,
slug !== undefined,
);
const viewportRef = useRef<HTMLPreElement | null>(null);
useEffect(() => {
if (follow && viewportRef.current) {
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
}
}, [data, follow]);
if (!slug) return <Spinner />;
return (
<div className="w-full space-y-5">
<h1 className="page-title">Logs</h1>
<Card
title={data ? data.path : 'console.log'}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{data && (
<span className="text-xs text-slate-dim">
fetched {formatRelativeTime(data.fetchedAt)}
</span>
)}
<select
value={lines}
onChange={(event) => setLines(Number(event.target.value))}
className="input py-1.5"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
<Button
variant={autoRefresh ? 'accent' : 'default'}
onClick={() => setAutoRefresh((v) => !v)}
title="Refresh every 10 seconds"
>
{autoRefresh ? 'Auto: on' : 'Auto: off'}
</Button>
<Button
variant={follow ? 'accent' : 'default'}
onClick={() => setFollow((v) => !v)}
title="Keep scrolled to the newest lines"
>
{follow ? 'Follow' : 'Free scroll'}
</Button>
<Button disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? '…' : 'Refresh'}
</Button>
</div>
}
>
{isLoading ? (
<Spinner label="Downloading log…" />
) : error ? (
<p className="text-sm text-danger-400">{error.message}</p>
) : (
<pre
ref={viewportRef}
className="max-h-[65vh] overflow-auto whitespace-pre rounded-md border border-graphite-800 bg-graphite-950 p-4 font-mono text-xs leading-relaxed text-zinc-300"
>
{data?.lines.join('\n')}
</pre>
)}
<p className="mt-3 text-xs text-slate-dim">
Read-only tail of the current Reforger console log, downloaded through the Pterodactyl
API. Visible to owner and server admins only.
</p>
</Card>
</div>
);
}
+435
View File
@@ -0,0 +1,435 @@
import { useState } from 'react';
import type { CurrentUser, ReforgerConfigMod, WorkshopModDetail } from '@reforger-panel/shared';
import { api } from '../api/client.js';
import {
useServerMods,
useServers,
useSetServerMods,
useWorkshopMod,
useWorkshopSearch,
} from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js';
import { Button, Card, EmptyState, ModImage, Spinner } from '../components/ui.js';
const COMMON_WORKSHOP_TAGS = [
'WEAPONS',
'VEHICLES',
'MISSIONS',
'EQUIPMENT',
'GAMEPLAY',
'MISC',
'QUALITY OF LIFE',
] as const;
const WORKSHOP_SORTS = [
{ value: 'popularity', label: 'Popular' },
{ value: 'newest', label: 'Newest' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'version_size', label: 'Size' },
] as const;
export function ModsPage({ user }: { user: CurrentUser }) {
const { data: serversData } = useServers();
const slug = serversData?.servers[0]?.slug;
if (!slug) return <Spinner />;
return <ModsBody slug={slug} user={user} />;
}
function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const canManage = user.capabilities.includes('mods.manage');
const { data, isLoading, error, refetch } = useServerMods(slug);
const save = useSetServerMods(slug);
const [draft, setDraft] = useState<ReforgerConfigMod[] | null>(null);
const [message, setMessage] = useState<string | null>(null);
const serverMods = data?.mods ?? [];
const mods = draft ?? serverMods;
const dirty = draft !== null && !sameMods(draft, serverMods);
const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const addMod = (mod: ReforgerConfigMod) => {
if (installedIds.has(mod.modId.toUpperCase())) return;
setMessage(null);
setDraft([...mods, mod]);
};
const removeMod = (modId: string) => {
setMessage(null);
setDraft(mods.filter((mod) => mod.modId !== modId));
};
const saveMods = () => {
setMessage(null);
save.mutate(mods, {
onSuccess: (result) => {
setDraft(null);
setMessage(
`Saved to config.json — ${result.added} added, ${result.removed} removed. ` +
'Restart the server to apply.',
);
void refetch();
},
onError: (saveError) => setMessage(saveError.message),
});
};
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Mods</h1>
<p className="page-kicker">
Review the live server mod list, stage changes, and pull metadata from the Reforger
Workshop before saving config.json.
</p>
</div>
<Card
title="Server mods (config.json)"
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{data && !dirty && (
<span className="text-xs text-slate-dim">
fetched {formatRelativeTime(data.fetchedAt)}
</span>
)}
{dirty && (
<>
<span className="text-xs text-warn-400">unsaved changes</span>
<Button onClick={() => setDraft(null)} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" onClick={saveMods} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</>
)}
</div>
}
>
{isLoading ? (
<Spinner label="Downloading config.json…" />
) : error ? (
<p className="text-sm text-danger-400">{error.message}</p>
) : mods.length === 0 ? (
<EmptyState
title="No mods installed"
hint={canManage ? 'Add mods from the Workshop below.' : 'The server runs vanilla.'}
/>
) : (
<ul className="grid max-h-72 gap-1.5 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
{mods.map((mod) => (
<li
key={mod.modId}
className="flex items-center justify-between rounded-md border border-graphite-800 bg-graphite-950/20 px-3 py-2.5"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-200">
{mod.name ?? mod.modId}
</p>
<p className="font-mono text-xs text-slate-dim">
{mod.modId}
{mod.version ? ` · v${mod.version}` : ' · latest version'}
</p>
</div>
{canManage && (
<Button variant="danger" onClick={() => removeMod(mod.modId)}>
Remove
</Button>
)}
</li>
))}
</ul>
)}
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
<p className="mt-3 text-xs text-slate-dim">
Changes are written directly to the server's config.json (a config.json.bak backup is
kept) and take effect on the next server restart.
</p>
</Card>
<WorkshopBrowser canManage={canManage} installedIds={installedIds} onAdd={addMod} />
</div>
);
}
function WorkshopBrowser({
canManage,
installedIds,
onAdd,
}: {
canManage: boolean;
installedIds: Set<string>;
onAdd: (mod: ReforgerConfigMod) => void;
}) {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');
const [activeTag, setActiveTag] = useState<string | null>(null);
const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity');
const [page, setPage] = useState(1);
const [selectedModId, setSelectedModId] = useState<string | null>(null);
const [addingId, setAddingId] = useState<string | null>(null);
const effectiveQuery = [query, activeTag].filter(Boolean).join(' ');
const { data, isFetching, error } = useWorkshopSearch(effectiveQuery, page, sort);
// Adding needs the mod's version, which only the detail endpoint provides.
const addFromWorkshop = async (modId: string, fallbackName: string) => {
setAddingId(modId);
try {
const detail = await api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`);
onAdd({
modId: detail.id,
name: detail.name || fallbackName,
...(detail.version ? { version: detail.version } : {}),
});
} catch {
onAdd({ modId, name: fallbackName });
} finally {
setAddingId(null);
}
};
return (
<Card title="Workshop">
<form
className="mb-3 grid gap-2 lg:grid-cols-[minmax(0,1fr)_180px_auto]"
onSubmit={(event) => {
event.preventDefault();
setPage(1);
setSelectedModId(null);
setQuery(input.trim());
}}
>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Search the Reforger Workshop… (empty shows the front page)"
className="input min-w-0 flex-1"
/>
<select
value={sort}
onChange={(event) => {
setPage(1);
setSort(event.target.value as (typeof WORKSHOP_SORTS)[number]['value']);
}}
className="input"
>
{WORKSHOP_SORTS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<Button variant="accent" disabled={isFetching}>
{isFetching ? 'Loading…' : 'Search'}
</Button>
</form>
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wider text-slate-dim">Tags</span>
{COMMON_WORKSHOP_TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => {
setPage(1);
setSelectedModId(null);
setActiveTag(activeTag === tag ? null : tag);
}}
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
activeTag === tag
? 'border-accent-500/50 bg-accent-500/15 text-accent-400'
: 'border-graphite-700 bg-graphite-950/20 text-slate-ink hover:border-graphite-600 hover:text-zinc-200'
}`}
>
{tag}
</button>
))}
{activeTag && (
<button
type="button"
onClick={() => {
setActiveTag(null);
setPage(1);
}}
className="text-xs text-slate-dim hover:text-zinc-200"
>
Clear tag
</button>
)}
</div>
{error && <p className="text-sm text-danger-400">{error.message}</p>}
{!data && !error && <Spinner label="Loading Workshop mods…" />}
{data && (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_430px]">
<div className={`min-w-0 ${isFetching ? 'opacity-60' : ''} transition-opacity`}>
<p className="mb-2 text-xs text-slate-dim">
{effectiveQuery
? `${data.meta.totalMods.toLocaleString()} results for “${effectiveQuery}`
: `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '}
· page {data.meta.currentPage} of {data.meta.totalPages}
</p>
<ul className="max-h-[62vh] space-y-1.5 overflow-y-auto pr-1">
{data.mods.map((mod) => {
const installed = installedIds.has(mod.id.toUpperCase());
return (
<li key={mod.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => setSelectedModId(mod.id)}
className={`flex min-w-0 flex-1 items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors ${
selectedModId === mod.id
? 'border-accent-600/60 bg-accent-600/10'
: 'border-graphite-800 bg-graphite-950/20 hover:border-graphite-600'
}`}
>
<ModImage src={mod.imageUrl} className="h-10 w-10" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-zinc-200">{mod.name}</span>
<span className="block truncate text-xs text-slate-dim">
{mod.author} · {mod.size ?? '—'} · {mod.rating ?? '—'}
</span>
</span>
</button>
{canManage && (
<Button
variant="accent"
disabled={installed || addingId === mod.id}
title={installed ? 'Already in the mod list' : undefined}
onClick={() => void addFromWorkshop(mod.id, mod.name)}
>
{installed ? 'Added' : addingId === mod.id ? '…' : 'Add'}
</Button>
)}
</li>
);
})}
</ul>
<div className="mt-3 flex items-center gap-2">
<Button disabled={page <= 1 || isFetching} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<Button
disabled={page >= data.meta.totalPages || isFetching}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
<ModDetailPanel
modId={selectedModId}
canManage={canManage}
installedIds={installedIds}
onAdd={onAdd}
onTagSelect={(tag) => {
setActiveTag(tag);
setPage(1);
setSelectedModId(null);
}}
/>
</div>
)}
</Card>
);
}
function ModDetailPanel({
modId,
canManage,
installedIds,
onAdd,
onTagSelect,
}: {
modId: string | null;
canManage: boolean;
installedIds: Set<string>;
onAdd: (mod: ReforgerConfigMod) => void;
onTagSelect: (tag: string) => void;
}) {
const { data: mod, isLoading } = useWorkshopMod(modId);
if (!modId) {
return (
<div className="rounded-md border border-dashed border-graphite-700 bg-graphite-950/20 p-6">
<EmptyState title="Select a mod" hint="Mod details load from the Workshop API." />
</div>
);
}
if (isLoading || !mod) return <Spinner />;
const installed = installedIds.has(mod.id.toUpperCase());
return (
<div className="max-h-[62vh] min-w-0 overflow-y-auto rounded-md border border-graphite-800 bg-graphite-950/20 p-4 xl:sticky xl:top-24">
<div className="flex items-start gap-4">
<ModImage src={mod.imageUrl} className="h-20 w-20" />
<div className="min-w-0">
<h3 className="text-base font-semibold text-zinc-100">{mod.name}</h3>
<p className="text-xs text-slate-ink">
by {mod.author} · v{mod.version ?? '—'} · game {mod.gameVersion ?? '—'}
</p>
<p className="text-xs text-slate-dim">
{mod.downloads?.toLocaleString() ?? '—'} downloads · {mod.rating ?? '—'} rating ·{' '}
{mod.size ?? '—'}
</p>
</div>
</div>
{mod.summary && <p className="mt-3 text-sm text-zinc-300">{mod.summary}</p>}
{mod.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{mod.tags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onTagSelect(tag)}
className="rounded-full border border-graphite-700 bg-graphite-900 px-2 py-0.5 text-xs text-slate-ink hover:border-accent-500/50 hover:text-accent-400"
>
{tag}
</button>
))}
</div>
)}
{mod.dependencies.length > 0 && (
<div className="mt-3">
<p className="text-xs uppercase tracking-wider text-warn-400">
Dependencies (add these too)
</p>
<ul className="mt-1 space-y-0.5 text-sm text-zinc-300">
{mod.dependencies.map((dep) => (
<li key={dep.id ?? dep.name}>{dep.name}</li>
))}
</ul>
</div>
)}
<div className="mt-4 flex items-center gap-2">
{canManage && (
<Button
variant="accent"
disabled={installed}
onClick={() =>
onAdd({
modId: mod.id,
name: mod.name,
...(mod.version ? { version: mod.version } : {}),
})
}
>
{installed ? 'Already added' : 'Add to server'}
</Button>
)}
{mod.workshopUrl && (
<a
href={mod.workshopUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-accent-400 hover:underline"
>
Open in Workshop
</a>
)}
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { Link } from 'react-router-dom';
import type { CurrentUser, ResourceSample } from '@reforger-panel/shared';
import {
useConfiguration,
useResourceHistory,
useServerResources,
useServers,
} from '../api/hooks.js';
import { formatBytes, formatDuration } from '../lib/format.js';
import { Card, Spinner } from '../components/ui.js';
import { TimeSeriesChart } from '../components/charts.js';
import {
ConfigSummaryRows,
CurrentPlayersCard,
OpsHealthCard,
RecentActivityCard,
} from '../components/widgets.js';
export function OverviewPage({ user }: { user: CurrentUser }) {
const { data: serversData, isLoading } = useServers();
const server = serversData?.servers[0];
if (isLoading) return <Spinner label="Loading dashboard…" />;
if (!server) {
return (
<Card title="No servers">
<p className="text-sm text-slate-ink">
No servers found. Run <code className="font-mono text-accent-400">npm run db:seed</code>{' '}
to create the training server.
</p>
</Card>
);
}
return <Dashboard user={user} slug={server.slug} />;
}
function seriesOf(
samples: ResourceSample[] | undefined,
pick: (s: ResourceSample) => number,
): { t: number; v: number }[] {
return (samples ?? []).map((s) => ({ t: s.t, v: pick(s) }));
}
function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
const { data: serversData } = useServers();
const server = serversData?.servers.find((s) => s.slug === slug);
const { data: resources } = useServerResources(slug);
const { data: config } = useConfiguration(slug);
const { data: history } = useResourceHistory(slug);
if (!server) return null;
const installedMods = config?.config.mods ?? [];
const samples = history?.samples;
const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null;
const cpuLimit = resources?.cpuLimitPercent ?? samples?.at(-1)?.cpuLimitPercent ?? 100;
return (
<div className="w-full space-y-5">
<div className="grid gap-4 md:grid-cols-3">
<Card title="CPU">
<p className="text-2xl font-semibold text-zinc-100">
{resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'}
<span className="text-sm font-normal text-slate-dim">
{cpuLimit && cpuLimit !== 100 ? ` / ${cpuLimit}%` : ''}
</span>
</p>
<TimeSeriesChart
className="mt-2"
max={cpuLimit}
series={[
{
points: seriesOf(samples, (s) => s.cpuPercent),
color: 'var(--color-accent-400)',
},
]}
/>
</Card>
<Card title="Memory">
<p className="text-2xl font-semibold text-zinc-100">
{resources ? formatBytes(resources.memoryBytes) : '—'}
<span className="text-sm font-normal text-slate-dim">
{memoryLimit ? ` / ${formatBytes(memoryLimit)}` : ''}
</span>
</p>
<TimeSeriesChart
className="mt-2"
max={memoryLimit}
series={[
{
points: seriesOf(samples, (s) => s.memoryBytes),
color: '#7dd3fc',
},
]}
/>
</Card>
<Card title="Network">
<p className="text-sm text-zinc-300">
<span className="text-accent-400">
{formatBytes(samples?.at(-1)?.networkRxRate ?? 0)}/s
</span>
<span className="ml-3 text-warn-400">
{formatBytes(samples?.at(-1)?.networkTxRate ?? 0)}/s
</span>
<span className="ml-3 text-slate-dim">
up{' '}
{resources && resources.uptimeMs > 0
? formatDuration(resources.uptimeMs / 1000)
: '—'}
</span>
</p>
<TimeSeriesChart
className="mt-2"
series={[
{
points: seriesOf(samples, (s) => s.networkRxRate),
color: 'var(--color-accent-400)',
label: 'rx',
},
{
points: seriesOf(samples, (s) => s.networkTxRate),
color: 'var(--color-warn-400)',
fill: false,
label: 'tx',
},
]}
/>
</Card>
</div>
<div className="grid gap-5 lg:grid-cols-3">
<div className="min-w-0 space-y-5 lg:col-span-2">
<CurrentPlayersCard slug={slug} maxPlayers={server.maxPlayers} />
<RecentActivityCard slug={slug} />
</div>
<div className="min-w-0 space-y-5">
<Card
title="Current configuration"
action={
<Link to="/configuration" className="text-xs text-accent-400 hover:underline">
View configuration
</Link>
}
>
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
<Card
title="Installed mods"
action={
<Link to="/mods" className="text-xs text-accent-400 hover:underline">
Manage
</Link>
}
>
{installedMods.length === 0 ? (
<p className="text-sm text-slate-dim">The server runs vanilla (no mods).</p>
) : (
<div>
<p className="text-sm text-zinc-200">
{installedMods.length} mod{installedMods.length === 1 ? '' : 's'} in config.json
</p>
<ul className="mt-2 space-y-1">
{installedMods.slice(0, 5).map((mod) => (
<li key={mod.modId} className="truncate text-xs text-slate-ink">
{mod.name ?? mod.modId}
</li>
))}
{installedMods.length > 5 && (
<li className="text-xs text-slate-dim">+ {installedMods.length - 5} more</li>
)}
</ul>
</div>
)}
</Card>
<OpsHealthCard user={user} slug={slug} />
</div>
</div>
</div>
);
}
+372
View File
@@ -0,0 +1,372 @@
import { useMemo, useState } from 'react';
import type { CurrentUser, Role } from '@reforger-panel/shared';
import { ROLES, ROLE_LABELS } from '@reforger-panel/shared';
import {
useActivity,
useConfiguration,
useKnownPlayers,
useKillfeed,
useLogHealth,
usePlayers,
useServers,
useSetUserRole,
useUsers,
useWorkshopHealth,
} from '../api/hooks.js';
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
import { Card, EmptyState, RoleBadge, Spinner } from '../components/ui.js';
import { ActivityList, ConfigSummaryRows, CurrentPlayersCard } from '../components/widgets.js';
import { InvitesCard } from '../components/invites-card.js';
import { MissionCard } from '../components/mission-card.js';
import { PerformanceForm } from '../components/performance-form.js';
import { SchedulesCard } from '../components/schedules-card.js';
import { StartupVarsCard } from '../components/startup-vars-card.js';
function usePrimarySlug(): string | null {
const { data } = useServers();
return data?.servers[0]?.slug ?? null;
}
export function ConfigurationsPage({ user }: { user: CurrentUser }) {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <ConfigurationsBody slug={slug} user={user} />;
}
function ConfigurationsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const { data: config } = useConfiguration(slug);
const canEdit = user.capabilities.includes('config.edit');
return (
<div className="w-full space-y-5">
<h1 className="page-title">Configuration</h1>
<MissionCard slug={slug} canEdit={canEdit} />
<PerformanceForm slug={slug} canEdit={canEdit} />
<SchedulesCard slug={slug} canEdit={canEdit} />
{canEdit && <StartupVarsCard slug={slug} />}
<Card title="Full config summary (live from the server)">
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
</div>
);
}
export function PlayersPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <PlayersBody slug={slug} />;
}
function PlayersBody({ slug }: { slug: string }) {
const { data: online } = usePlayers(slug);
const { data: known } = useKnownPlayers(slug);
const [sort, setSort] = useState<'online' | 'last_seen' | 'playtime' | 'sessions' | 'name'>(
'online',
);
const sortedPlayers = useMemo(() => {
const players = [...(known?.players ?? [])];
players.sort((a, b) => {
if (sort === 'online') {
if (a.online !== b.online) return a.online ? -1 : 1;
return b.lastSeenAt.localeCompare(a.lastSeenAt);
}
if (sort === 'last_seen') return b.lastSeenAt.localeCompare(a.lastSeenAt);
if (sort === 'playtime') return b.totalPlaytimeSeconds - a.totalPlaytimeSeconds;
if (sort === 'sessions') return b.totalSessions - a.totalSessions;
return a.displayName.localeCompare(b.displayName);
});
return players;
}, [known?.players, sort]);
return (
<div className="w-full space-y-5">
<h1 className="page-title">Players</h1>
<CurrentPlayersCard slug={slug} maxPlayers={online?.maxPlayers ?? null} />
<Card
title="All known players"
action={
<select
value={sort}
onChange={(event) => setSort(event.target.value as typeof sort)}
className="input py-1.5 text-xs"
>
<option value="online">Online first</option>
<option value="last_seen">Last seen</option>
<option value="playtime">Playtime</option>
<option value="sessions">Sessions</option>
<option value="name">Name</option>
</select>
}
>
{!known ? (
<Spinner />
) : known.players.length === 0 ? (
<EmptyState
title="No players recorded yet"
hint="Players are discovered from server log connect events."
/>
) : (
<div className="data-table-scroll">
<table className="data-table">
<thead>
<tr>
<th>Player</th>
<th>Identity</th>
<th>Last seen</th>
<th className="text-right">Sessions</th>
<th className="text-right">Playtime</th>
</tr>
</thead>
<tbody>
{sortedPlayers.map((player) => (
<tr key={player.id}>
<td className="py-2 font-medium text-zinc-200">
{player.displayName}
{player.online && (
<span className="ml-2 rounded bg-accent-600/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent-400">
online
</span>
)}
</td>
<td className="py-2 font-mono text-xs text-slate-dim">
{player.externalPlayerId ? (
player.externalPlayerId.slice(0, 12) + '…'
) : (
<span title="No stable ID in logs; matched by display name">name only</span>
)}
</td>
<td className="py-2 text-slate-ink">{formatRelativeTime(player.lastSeenAt)}</td>
<td className="py-2 text-right font-mono text-xs">{player.totalSessions}</td>
<td className="py-2 text-right font-mono text-xs">
{formatDuration(player.totalPlaytimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
export function ActivityPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <ActivityBody slug={slug} />;
}
export function KillfeedPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <KillfeedBody slug={slug} />;
}
function teamClass(team: string | null): string {
const normalized = team?.toLowerCase() ?? '';
if (normalized.includes('blue') || normalized.includes('blufor')) return 'bg-sky-500';
if (normalized.includes('opfor') || normalized.includes('red')) return 'bg-red-500';
if (normalized.includes('independent') || normalized.includes('green')) return 'bg-emerald-500';
return 'bg-slate-dim';
}
function positionLabel(position: { x: number; y: number; z?: number | null } | null): string {
if (!position) return 'position unknown';
const z = typeof position.z === 'number' ? `, ${position.z.toFixed(0)}` : '';
return `${position.x.toFixed(0)}, ${position.y.toFixed(0)}${z}`;
}
function KillfeedBody({ slug }: { slug: string }) {
const { data, isLoading } = useKillfeed(slug, 150);
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Killfeed</h1>
<p className="page-kicker">
Parsed from ServerAdminTools kill events. Team, position, distance, and weapon show when
the log line provides them.
</p>
</div>
<Card title="Recent kills">
{isLoading || !data ? (
<Spinner />
) : data.events.length === 0 ? (
<EmptyState
title="No kills recorded yet"
hint="Killfeed requires ServerAdminTools kill event lines in the server log."
/>
) : (
<ul className="space-y-2">
{data.events.map((event) => (
<li
key={event.id}
className="rounded-md border border-graphite-800 bg-graphite-950/20 px-3.5 py-3"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className={`h-2.5 w-2.5 rounded-full ${teamClass(event.killerTeam)}`} />
<span className="font-medium text-zinc-100">{event.killerName}</span>
<span className="text-slate-dim">killed</span>
<span className={`h-2.5 w-2.5 rounded-full ${teamClass(event.victimTeam)}`} />
<span className="font-medium text-zinc-100">{event.victimName}</span>
{event.friendly && (
<span className="rounded border border-warn-400/30 bg-warn-400/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-warn-400">
friendly
</span>
)}
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-dim">
<span>{formatDateTime(event.occurredAt)}</span>
<span>attacker {positionLabel(event.killerPosition)}</span>
<span>victim {positionLabel(event.victimPosition)}</span>
<span>
distance{' '}
{event.distanceMeters !== null ? `${event.distanceMeters.toFixed(0)} m` : '—'}
</span>
<span>weapon {event.weapon ?? '—'}</span>
</div>
</li>
))}
</ul>
)}
</Card>
</div>
);
}
function ActivityBody({ slug }: { slug: string }) {
const { data } = useActivity(slug, 100);
return (
<div className="w-full space-y-5">
<h1 className="page-title">Activity</h1>
<Card>{data ? <ActivityList items={data.activity} maxHeight={560} /> : <Spinner />}</Card>
</div>
);
}
export function SettingsPage({ user }: { user: CurrentUser }) {
const isOwner = user.role === 'owner';
const slug = usePrimarySlug();
const { data: users } = useUsers(isOwner);
const { data: workshop } = useWorkshopHealth();
const { data: logs } = useLogHealth(slug ?? '', isOwner && slug !== null);
const setRole = useSetUserRole();
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-kicker">
Manage private Discord access, server integrations, and the checks that matter before
exposing the panel to friends.
</p>
</div>
<Card title="Your account">
<div className="flex items-center gap-3">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt=""
className="h-11 w-11 rounded-full border border-graphite-600"
/>
) : (
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-graphite-600 bg-graphite-800 text-sm font-semibold text-zinc-300">
{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}
</span>
)}
<div>
<p className="text-sm font-medium text-zinc-200">
{user.displayName ?? user.username}{' '}
<span className="text-slate-dim">({user.username})</span>
</p>
<RoleBadge role={user.role} />
</div>
</div>
</Card>
{isOwner && (
<Card title="Users & roles">
{!users ? (
<Spinner />
) : (
<ul className="space-y-2">
{users.users.map((panelUser) => (
<li
key={panelUser.id}
className="flex items-center justify-between rounded-md border border-graphite-800 bg-graphite-950/20 px-3 py-2.5"
>
<div className="flex items-center gap-2">
{panelUser.avatarUrl ? (
<img src={panelUser.avatarUrl} alt="" className="h-7 w-7 rounded-full" />
) : (
<span className="h-7 w-7 rounded-full bg-graphite-700" />
)}
<div>
<p className="text-sm text-zinc-200">
{panelUser.displayName ?? panelUser.username}
</p>
<p className="text-xs text-slate-dim">
joined {formatDateTime(panelUser.createdAt)}
</p>
</div>
</div>
{panelUser.id === user.id ? (
<RoleBadge role={panelUser.role} />
) : (
<select
value={panelUser.role}
onChange={(event) =>
setRole.mutate({ userId: panelUser.id, role: event.target.value as Role })
}
className="input px-2 py-1 text-xs"
>
{ROLES.map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
)}
</li>
))}
</ul>
)}
</Card>
)}
{isOwner && <InvitesCard />}
{isOwner && (
<Card title="Integrations">
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-slate-ink">Workshop API</dt>
<dd className={workshop?.ok ? 'text-accent-400' : 'text-danger-400'}>
{workshop
? workshop.ok
? `healthy (${workshop.latencyMs} ms)`
: 'unreachable'
: '—'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-ink">Pterodactyl</dt>
<dd className="text-zinc-300">
{logs?.configured ? 'configured' : 'mock / not configured'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-ink">Log path</dt>
<dd className="font-mono text-xs text-zinc-300">{logs?.logPath ?? '—'}</dd>
</div>
</dl>
<p className="mt-3 text-xs text-slate-dim">
Connection settings are managed through environment variables. Use real Pterodactyl
client API credentials for production and keep mock mode off.
</p>
</Card>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"noEmit": true,
"useDefineForClassFields": true
},
"include": ["src", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: false,
},
},
},
});
+38
View File
@@ -0,0 +1,38 @@
# Private production deployment: Postgres + the panel in one stack.
# cp .env.example .env (fill in Discord, Pterodactyl, secrets)
# docker compose -f docker-compose.prod.yml up -d --build
# Put a reverse proxy with HTTPS (Caddy/nginx/Tailscale Serve) in front of
# port 3001 and point DISCORD_REDIRECT_URI / WEB_ORIGIN at that public URL.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: reforger
POSTGRES_PASSWORD: reforger
POSTGRES_DB: reforger_panel
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U reforger -d reforger_panel']
interval: 5s
timeout: 3s
retries: 10
panel:
build: .
restart: unless-stopped
env_file: .env
environment:
NODE_ENV: production
# Inside the compose network Postgres is reachable by service name.
DATABASE_URL: postgresql://reforger:reforger@postgres:5432/reforger_panel
DEV_AUTH_BYPASS: 'false'
ports:
- '3001:3001'
depends_on:
postgres:
condition: service_healthy
volumes:
postgres-data:
+22
View File
@@ -0,0 +1,22 @@
services:
postgres:
image: postgres:16-alpine
container_name: reforger-panel-postgres
restart: unless-stopped
environment:
POSTGRES_USER: reforger
POSTGRES_PASSWORD: reforger
POSTGRES_DB: reforger_panel
ports:
# Host port 5433 to avoid clashing with any locally installed Postgres.
- '127.0.0.1:5433:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U reforger -d reforger_panel']
interval: 5s
timeout: 3s
retries: 10
volumes:
postgres-data:
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
{
ignores: ['**/dist/**', '**/build/**', '**/node_modules/**', '**/coverage/**', '**/drizzle/**'],
},
js.configs.recommended,
...tseslint.configs.recommended,
prettier,
{
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
},
},
);
+9855
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "reforger-panel",
"private": true,
"type": "module",
"version": "0.1.0",
"description": "Private Arma Reforger control panel — Discord login, safe server controls, Workshop integration, and Pterodactyl-based log processing.",
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"dev": "npm-run-all --parallel dev:api dev:web",
"dev:api": "npm run dev --workspace @reforger-panel/api",
"dev:web": "npm run dev --workspace @reforger-panel/web",
"build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --workspaces --if-present",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "npm run test --workspaces --if-present",
"db:generate": "npm run db:generate --workspace @reforger-panel/api",
"db:migrate": "npm run db:migrate --workspace @reforger-panel/api",
"db:seed": "npm run db:seed --workspace @reforger-panel/api"
},
"devDependencies": {
"@eslint/js": "^9.30.0",
"eslint": "^9.30.0",
"eslint-config-prettier": "^10.1.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.0",
"typescript": "^5.8.0",
"typescript-eslint": "^8.35.0"
},
"engines": {
"node": ">=22"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@reforger-panel/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more