- Add migrate_config() to ConfigGenerator protocol for schema version upgrades - Add per-server operation lock to ProcessManager to prevent start/stop races - Add busy_timeout retry/backoff strategy (exponential: 1s, 2s, 4s) for DB lock exhaustion - Add ConfigForm testing strategy and error boundary for malformed schemas - Add schema cache invalidation on adapter version change - Add ConfigMigrationError to typed adapter exceptions
1380 lines
69 KiB
Markdown
1380 lines
69 KiB
Markdown
# Languard Servers Manager — Frontend Design
|
||
|
||
## Purpose
|
||
|
||
A real-time game server management dashboard that gives admins instant visibility and control over their dedicated servers. The interface must feel like a mission control center — dense with live data, fast to react, and unambiguous in its state signals.
|
||
|
||
**Audience:** Server administrators managing game servers. Technical, task-oriented, frequently under time pressure (server crashed, player is cheating, need to restart now).
|
||
|
||
**Emotional tone:** Confident, precise, operational. Not playful, not corporate, not minimal-for-the-sake-of-it.
|
||
|
||
**Visual direction:** **Dark neumorphic command center** — near-black surfaces with soft extruded/inset shadows creating tactile depth, amber and orange accents cutting through like instrument panel lights, monospaced data glowing against dark backgrounds. The aesthetic of a physical control panel — buttons you can feel, displays that look back-lit, surfaces that have real mass.
|
||
|
||
**One thing the user should remember:** "I can see exactly what's happening and act on it immediately."
|
||
|
||
---
|
||
|
||
## Technology Stack
|
||
|
||
| Layer | Technology | Rationale |
|
||
|-------|-----------|-----------|
|
||
| Framework | **React 18** + **TypeScript 5** | Ecosystem, type safety, team familiarity |
|
||
| Build | **Vite 5** | Fast HMR, native ESM, minimal config |
|
||
| Routing | **React Router v6** | Standard, nested layouts |
|
||
| State (server) | **TanStack Query v5** | Server state cache, background refetch, optimistic updates |
|
||
| State (client) | **Zustand** | Minimal boilerplate, no providers, good for WS state |
|
||
| Forms | **React Hook Form** + **Zod** | Adapter-driven dynamic form generation from JSON Schema |
|
||
| Styling | **Tailwind CSS v3** + CSS variables | Utility-first with design tokens for theming |
|
||
| Charts | **Recharts** | Lightweight, responsive, sufficient for CPU/RAM/player time series |
|
||
| Icons | **Lucide React** | Consistent stroke style, tree-shakeable |
|
||
| HTTP | **Ky** (fetch wrapper) | Hooks, retry, typed responses |
|
||
| WS | Native WebSocket + custom hook | Minimal abstraction over browser API |
|
||
| Code quality | **ESLint** + **Prettier** + **tsc --noEmit** | Lint, format, type-check |
|
||
|
||
No UI component library (shadcn, MUI, etc.). Every component is purpose-built to the design system below.
|
||
|
||
---
|
||
|
||
## Design Tokens
|
||
|
||
### Color
|
||
|
||
Black, white, dark yellow, and orange. The palette mirrors a military-grade instrument panel — near-black surfaces, white text for readability, amber/yellow for data highlights and warnings, orange for primary actions and live indicators.
|
||
|
||
```css
|
||
:root {
|
||
/* ── Surfaces ──────────────────────────────────────────────
|
||
Dark neumorphism: surfaces are all near-black but subtly
|
||
differentiated by lightness. Neumorphic shadows (below)
|
||
create the illusion of physical depth — raised panels,
|
||
sunken inputs, extruded buttons.
|
||
*/
|
||
--color-base: #0d0d0d; /* deepest — page background */
|
||
--color-surface: #141414; /* panels, cards */
|
||
--color-elevated:#1a1a1a; /* raised panels, modals */
|
||
--color-hover: #1f1f1f; /* hover state on interactive surfaces */
|
||
|
||
/* ── Neumorphic shadow source ──────────────────────────────
|
||
Neumorphism requires two opposing shadows on the same
|
||
surface: a lighter shadow (simulating top-left light)
|
||
and a darker shadow (simulating bottom-right depth).
|
||
The source colors are derived from the surface itself.
|
||
*/
|
||
--neu-light: #1e1e1e; /* light shadow — 4-5% above surface */
|
||
--neu-dark: #0a0a0a; /* dark shadow — 4-5% below surface */
|
||
|
||
/* ── Text ───────────────────────────────────────────────── */
|
||
--color-text: #f5f5f5; /* primary — near-white */
|
||
--color-text-dim: #999999; /* secondary — timestamps, meta */
|
||
--color-text-muted: #555555; /* disabled, placeholders */
|
||
|
||
/* ── Accent — dark yellow / orange ──────────────────────── */
|
||
--color-accent: #d4940a; /* dark yellow — primary actions */
|
||
--color-accent-hover: #e5a61c; /* lighter on hover */
|
||
--color-accent-dim: #8b6210; /* muted accent for backgrounds */
|
||
--color-orange: #d45e0a; /* orange — secondary accent */
|
||
--color-orange-hover: #e56e1c; /* lighter on hover */
|
||
--color-orange-dim: #8b3e0a; /* muted orange for backgrounds */
|
||
|
||
/* ── Status ─────────────────────────────────────────────── */
|
||
--color-running: #d4940a; /* amber glow — server is live */
|
||
--color-stopped: #555555; /* gray — idle */
|
||
--color-starting: #d4940a; /* amber — transitioning (pulsing) */
|
||
--color-crashed: #cc3333; /* red — needs attention */
|
||
--color-error: #cc3333; /* red — error states */
|
||
|
||
/* ── Danger ─────────────────────────────────────────────── */
|
||
--color-danger: #cc3333; /* red — destructive actions */
|
||
--color-danger-hover: #dd4444;
|
||
|
||
/* ── Glow ────────────────────────────────────────────────
|
||
Status indicators use a subtle glow (box-shadow) to
|
||
simulate back-lit LEDs on a dark panel.
|
||
*/
|
||
--glow-amber: 0 0 8px 2px oklch(72% 0.15 80 / 0.4);
|
||
--glow-red: 0 0 8px 2px oklch(55% 0.20 25 / 0.4);
|
||
--glow-orange: 0 0 8px 2px oklch(65% 0.17 50 / 0.35);
|
||
|
||
/* ── Overlays ──────────────────────────────────────────── */
|
||
--color-overlay: oklch(8% 0 0 / 0.85); /* modal backdrop */
|
||
}
|
||
```
|
||
|
||
**Color rules:**
|
||
- **Black is the only background.** No white backgrounds anywhere. White is text only.
|
||
- **Amber (dark yellow) is the primary accent** — used for: primary buttons, active tabs, selected items, the "running" status dot, data highlight values.
|
||
- **Orange is the secondary accent** — used for: warning states, important metrics, secondary CTAs.
|
||
- **Red is reserved for danger** — crashed, error, destructive actions. Never decorative.
|
||
- **Gray is the neutral** — stopped status, disabled states, borders.
|
||
- No purple. No blue. No decorative gradients.
|
||
- Status dots glow via `box-shadow` — like back-lit LEDs on a physical panel.
|
||
|
||
### Neumorphic Surface Treatment
|
||
|
||
Dark neumorphism creates the illusion of physical depth through opposing light/dark shadows on near-black surfaces. Every interactive element signals its affordance through shadow direction:
|
||
|
||
```css
|
||
/* ── Raised (extruded) ────────────────────────────────────
|
||
Buttons, cards, metric tiles — things that sit above the surface.
|
||
Light shadow top-left, dark shadow bottom-right.
|
||
*/
|
||
.neu-raised {
|
||
background: var(--color-surface);
|
||
border-radius: var(--radius-md);
|
||
box-shadow:
|
||
4px 4px 8px var(--neu-dark),
|
||
-4px -4px 8px var(--neu-light);
|
||
}
|
||
|
||
/* ── Inset (sunken) ────────────────────────────────────────
|
||
Input fields, log viewer, search bars — things that go into the surface.
|
||
Dark shadow top-left, light shadow bottom-right.
|
||
*/
|
||
.neu-inset {
|
||
background: var(--color-base);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow:
|
||
inset 3px 3px 6px var(--neu-dark),
|
||
inset -3px -3px 6px var(--neu-light);
|
||
}
|
||
|
||
/* ── Flat (flush) ──────────────────────────────────────────
|
||
Modal surfaces, overlays — flat on the surface, no shadow play.
|
||
*/
|
||
.neu-flat {
|
||
background: var(--color-elevated);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow:
|
||
0 8px 32px oklch(0% 0 0 / 0.5);
|
||
}
|
||
|
||
/* ── Pressed ───────────────────────────────────────────────
|
||
Active/pressed button state — flips to inset.
|
||
*/
|
||
.neu-raised:active {
|
||
box-shadow:
|
||
inset 3px 3px 6px var(--neu-dark),
|
||
inset -3px -3px 6px var(--neu-light);
|
||
}
|
||
|
||
/* ── Accent raised ─────────────────────────────────────────
|
||
Primary action buttons with amber/orange fill.
|
||
Neumorphic shadows on a colored surface.
|
||
*/
|
||
.neu-raised-accent {
|
||
background: var(--color-accent);
|
||
color: #0d0d0d;
|
||
border-radius: var(--radius-md);
|
||
box-shadow:
|
||
4px 4px 8px var(--neu-dark),
|
||
-4px -4px 8px var(--neu-light),
|
||
0 0 12px oklch(72% 0.15 80 / 0.2); /* subtle amber glow */
|
||
}
|
||
|
||
/* ── Status LED ────────────────────────────────────────────
|
||
Status indicator dots with glow. Looks like a back-lit LED.
|
||
*/
|
||
.led-running {
|
||
background: var(--color-running);
|
||
border-radius: var(--radius-full);
|
||
box-shadow: var(--glow-amber);
|
||
}
|
||
.led-crashed {
|
||
background: var(--color-crashed);
|
||
border-radius: var(--radius-full);
|
||
box-shadow: var(--glow-red);
|
||
}
|
||
```
|
||
|
||
**Neumorphism rules for this project:**
|
||
- **Shadow intensity is proportional to interaction.** Buttons get full shadows. Decorative cards get lighter shadows. Flat info panels get none.
|
||
- **Never use borders with neumorphism** — shadows define edges, not lines. The only exception is focus rings for accessibility.
|
||
- **Inset for input, raised for output.** Form fields, log viewers, and search bars are sunken. Metric tiles, buttons, and cards are raised.
|
||
- **Pressed = inset.** When a raised button is clicked, it flips to inset shadows for tactile feedback.
|
||
- **Subtle, not extreme.** Shadow offsets are 3-4px, blur 6-8px. This is not the exaggerated neumorphism of 2020 — it's understated depth that makes the interface feel like physical hardware.
|
||
- **Glow replaces color fill for status.** Running servers don't just get a colored dot — they get a dot that glows, like a real LED indicator on a server rack.
|
||
|
||
### Typography
|
||
|
||
```css
|
||
:root {
|
||
/* Display — for page titles */
|
||
--font-display: "Space Grotesk", sans-serif;
|
||
|
||
/* Body — for most UI text */
|
||
--font-body: "Inter", sans-serif;
|
||
|
||
/* Mono — for logs, code, ports, PIDs, timestamps */
|
||
--font-mono: "JetBrains Mono", monospace;
|
||
|
||
/* Scale */
|
||
--text-xs: 0.75rem; /* 12px — badges, tags */
|
||
--text-sm: 0.8125rem; /* 13px — table cells, meta */
|
||
--text-base: 0.875rem; /* 14px — body, form labels */
|
||
--text-lg: 1rem; /* 16px — section headings */
|
||
--text-xl: 1.25rem; /* 20px — page titles */
|
||
--text-2xl: 1.5rem; /* 24px — hero metrics */
|
||
--text-3xl: 2rem; /* 32px — big status numbers */
|
||
|
||
/* Weight */
|
||
--weight-regular: 400;
|
||
--weight-medium: 500;
|
||
--weight-semibold: 600;
|
||
|
||
/* Line height */
|
||
--leading-tight: 1.25;
|
||
--leading-normal: 1.5;
|
||
--leading-relaxed: 1.75; /* for log blocks */
|
||
}
|
||
```
|
||
|
||
**Rules:**
|
||
- All data values (ports, PIDs, player counts, IPs) use `--font-mono`.
|
||
- Log viewer is entirely monospaced.
|
||
- Page titles use `--font-display`. Everything else uses `--font-body`.
|
||
- Never use font weight alone to differentiate — combine with size or color.
|
||
|
||
### Spacing
|
||
|
||
```css
|
||
:root {
|
||
--space-1: 0.25rem; /* 4px — tight internal gaps */
|
||
--space-2: 0.5rem; /* 8px — form field spacing */
|
||
--space-3: 0.75rem; /* 12px — compact padding */
|
||
--space-4: 1rem; /* 16px — standard padding */
|
||
--space-5: 1.5rem; /* 24px — section gaps */
|
||
--space-6: 2rem; /* 32px — page margins */
|
||
--space-8: 3rem; /* 48px — major separations */
|
||
}
|
||
```
|
||
|
||
**Rhythm:** 4px base unit. All spacing is a multiple of 4px. No arbitrary padding values.
|
||
|
||
### Borders, Shadows, Radii
|
||
|
||
```css
|
||
:root {
|
||
/* Radii — consistent, not excessive */
|
||
--radius-sm: 6px; /* inputs, badges, small buttons */
|
||
--radius-md: 10px; /* cards, panels */
|
||
--radius-lg: 14px; /* modals, overlays */
|
||
--radius-full: 9999px; /* status LEDs, pills */
|
||
|
||
/* Neumorphism handles edge definition — borders are rare.
|
||
Only use borders for: focus rings, table row separators,
|
||
and explicit dividers between sections. */
|
||
--border-subtle: 1px solid #222222;
|
||
--border-focus: 2px solid var(--color-accent);
|
||
}
|
||
```
|
||
|
||
**Note on neumorphic shadows + Tailwind:** The neumorphic shadow classes above cannot be expressed as single Tailwind utilities. Use CSS custom classes (`.neu-raised`, `.neu-inset`, etc.) defined in `globals.css` and reference them via `@apply` or direct class names in components. Tailwind utilities handle everything else (spacing, layout, typography, color).
|
||
|
||
### Motion
|
||
|
||
```css
|
||
:root {
|
||
--duration-fast: 100ms; /* hover states, toggles */
|
||
--duration-normal: 200ms; /* panel transitions, modals */
|
||
--duration-slow: 400ms; /* page transitions */
|
||
|
||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||
--ease-in-out: cubic-bezier(0.45, 0, 0.55, 1);
|
||
}
|
||
```
|
||
|
||
**Rules:**
|
||
- Motion is for state transitions, not decoration.
|
||
- Status changes (stopped→starting→running) use `--duration-normal` + `--ease-out`.
|
||
- No scroll-triggered animations. No parallax. No loading spinners with decorative motion.
|
||
- Log streaming has zero animation — lines appear instantly.
|
||
- Modals slide in from bottom or fade in. Never bounce, never spring.
|
||
|
||
---
|
||
|
||
## Layout Architecture
|
||
|
||
### Shell
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ LOGO Languard [user] [settings] │ ← 48px header
|
||
├────────┬────────────────────────────────────────────────┤
|
||
│ │ │
|
||
│ NAV │ CONTENT AREA │
|
||
│ │ │
|
||
│ ┌────┐ │ ┌──────────────────────────────────────────┐ │
|
||
│ │📊 │ │ │ │ │
|
||
│ ├────┤ │ │ │ │
|
||
│ │🖥️ │ │ │ Page Content │ │
|
||
│ ├────┤ │ │ │ │
|
||
│ │📋 │ │ │ │ │
|
||
│ ├────┤ │ │ │ │
|
||
│ │⚙️ │ │ │ │ │
|
||
│ ├────┤ │ └──────────────────────────────────────────┘ │
|
||
│ │🔧 │ │ │
|
||
│ └────┘ │ │
|
||
│ 56px │ │
|
||
├────────┴────────────────────────────────────────────────┤
|
||
│ Status bar: connected servers / WS status / version │ ← 28px footer
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Sidebar:** 56px collapsed (icons only), expands to 200px on hover/click. Icon + label for each section.
|
||
- **Header:** App name left, user dropdown right. Dark, fixed.
|
||
- **Footer:** Always visible — shows WebSocket connection status (connected/reconnecting/disconnected), number of running servers, app version. Critical for trust.
|
||
- **Content:** Scrollable main area. Min width 1024px.
|
||
|
||
### Navigation Structure
|
||
|
||
| Route | Icon | Label | Access |
|
||
|-------|------|-------|--------|
|
||
| `/` | LayoutDashboard | Dashboard | All |
|
||
| `/servers` | Server | Servers | All |
|
||
| `/servers/:id` | — | (Server Detail) | All |
|
||
| `/servers/:id/config` | — | (Server Config) | Admin |
|
||
| `/missions` | — | (via server detail) | Admin |
|
||
| `/mods` | PuzzlePiece | Mods | All (view), Admin (edit) |
|
||
| `/bans` | ShieldOff | Bans | All (view), Admin (edit) |
|
||
| `/users` | Users | Users | Admin |
|
||
| `/settings` | Settings | Settings | Admin |
|
||
|
||
### Responsive Breakpoints
|
||
|
||
| Breakpoint | Layout | Sidebar |
|
||
|-----------|--------|---------|
|
||
| ≥1440px | Full layout | Expanded by default |
|
||
| 1024–1439px | Full layout | Collapsed by default |
|
||
| <1024px | **Not supported** | — |
|
||
|
||
This is a server management tool, not a consumer app. Mobile is not a target. The minimum viewport is 1024px wide. Below that, show a "Please use a desktop browser" message.
|
||
|
||
---
|
||
|
||
## Page Designs
|
||
|
||
### 1. Login
|
||
|
||
Single centered card on dark background. No illustration, no hero, no marketing copy. The card itself is `neu-raised` — it appears to float above the base surface.
|
||
|
||
```
|
||
┌─────────────────────────────────┐
|
||
│ │
|
||
│ Languard │ ← display font, white
|
||
│ Server Manager │ ← dim, monospaced?
|
||
│ │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ Username │ │ ← neu-inset input
|
||
│ └─────────────────────────┘ │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ Password │ │ ← neu-inset input
|
||
│ └─────────────────────────┘ │
|
||
│ │
|
||
│ ╔═══════════════════════╗ │ ← neu-raised-accent
|
||
│ ║ Sign In ║ │ (amber fill)
|
||
│ ╚═══════════════════════╝ │
|
||
│ │
|
||
│ Error message area (red glow) │
|
||
│ │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
- No "remember me", no "forgot password" (single-host tool, admin manages users via CLI or initial setup).
|
||
- Failed login shows inline error with rate-limit countdown after 5 attempts.
|
||
- JWT stored in `localStorage`. On token expiry, redirect to login with a toast "Session expired".
|
||
|
||
### 2. Dashboard
|
||
|
||
A command-center overview. Not a marketing dashboard — dense, data-first. Neumorphic raised cards for metrics, inset panels for lists.
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────┐
|
||
│ Dashboard [Refresh] │
|
||
├────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||
│ │(raised) │ │(raised) │ │(raised) │ │(raised) │ │
|
||
│ │ 3 │ │ 2 │ │ 15 │ │ 34% │ │
|
||
│ │ Total │ │ Running │ │ Players │ │ Avg CPU │ │
|
||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────┐ │
|
||
│ │ SERVER STATUS (raised panel) │ │
|
||
│ │ │ │
|
||
│ │ 🟡 Main Server Arma 3 15/40 34%CPU │ │ ← amber glow LED
|
||
│ │ 🟡 Altis COOP Arma 3 0/32 12%CPU │ │
|
||
│ │ ⚫ Test Server Arma 3 — — │ │ ← gray, no glow
|
||
│ │ │ │
|
||
│ └──────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────┐ │
|
||
│ │ RECENT EVENTS (inset panel) │ │
|
||
│ │ │ │
|
||
│ │ 10:05 PlayerOne kicked (AFK) Main Server │ │
|
||
│ │ 10:02 Server started Altis COOP │ │
|
||
│ │ 09:58 Crashed (exit 1) 🔴 Test Server │ │ ← red glow LED
|
||
│ │ 09:45 Ban added: Cheater42 Main Server │ │
|
||
│ │ │ │
|
||
│ └──────────────────────────────────────────────────┘ │
|
||
│ │
|
||
└────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Summary cards:** Neumorphic raised (`neu-raised`). Large amber number on top, white label below. Monospaced numbers. Running count glows amber. Crashed count glows red (if > 0).
|
||
- **Server status list:** Clickable rows → navigate to server detail. Status dot is an LED with glow (`led-running`). Player count in `mono`. CPU is color-coded: <50% white, 50-80% amber, >80% orange-red.
|
||
- **Recent events:** Inset panel (`neu-inset`). Last 10 events across all servers. Crash events show red LED.
|
||
|
||
### 3. Server List
|
||
|
||
Full CRUD list with filtering and bulk overview.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Servers [+ New Server] [Game: All ▾] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ Name │ Game │ Status │ Players │ CPU │ ⚙ │ │
|
||
│ ├───────────────┼────────┼─────────┼─────────┼─────┼───┤ │
|
||
│ │ Main Server │ Arma 3 │ 🟡 Run │ 15/40 │ 34% │ ⋮ │ │ ← amber LED
|
||
│ │ Altis COOP │ Arma 3 │ 🟡 Run │ 0/32 │ 12% │ ⋮ │ │
|
||
│ │ Test Server │ Arma 3 │ ⚫ Stop │ — │ — │ ⋮ │ │ ← gray, no glow
|
||
│ └───────────────┴────────┴─────────┴─────────┴─────┴───┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Game filter dropdown:** Populated from `GET /games`. Inset input style (`neu-inset`).
|
||
- **[+ New Server]:** Raised accent button (`neu-raised-accent`). Amber fill, dark text.
|
||
- **Row actions (⋮ menu):** Start/Stop/Restart/Kill, Edit, Delete. Actions are context-aware — "Start" is disabled when running, "Stop" is disabled when stopped.
|
||
- **Sort:** Click column headers. Default sort: status (running first), then name.
|
||
- **Status column:** LED dot + short label. Amber glow = running, gray = stopped, red glow = crashed.
|
||
|
||
### 4. New Server Dialog
|
||
|
||
Modal overlay. Multi-step for clarity, not wizardry.
|
||
|
||
```
|
||
┌──────────────────────────────────────┐
|
||
│ New Server │
|
||
│ │
|
||
│ ── Step 1: Game Type ────────── │
|
||
│ │
|
||
│ ┌────────┐ ┌────────┐ │
|
||
│ │ Arma 3 │ │ + Add │ ← greyed │
|
||
│ │ ★ │ │ more │ if no │
|
||
│ └────────┘ └────────┘ adapters │
|
||
│ │
|
||
│ ── Step 2: Details ──────────── │
|
||
│ │
|
||
│ Server Name [ ]│
|
||
│ Description [ ]│
|
||
│ Executable [/path/to/exe ]│
|
||
│ Game Port [2302 ]│
|
||
│ RCon Port [2306 ]│
|
||
│ │
|
||
│ ── Step 3: Config ────────────── │
|
||
│ │
|
||
│ (Pre-filled from adapter defaults) │
|
||
│ Hostname [My Arma 3 Server ]│
|
||
│ Max Players [40 ]│
|
||
│ Admin Password [•••••• ]│
|
||
│ ... │
|
||
│ │
|
||
│ [Cancel] [Create Server] │
|
||
│ │
|
||
└──────────────────────────────────────┘
|
||
```
|
||
|
||
- **Game type selector:** Visual cards, not a dropdown. Each shows game name + icon. Only registered game types appear (from `GET /games`).
|
||
- **Config sections:** Dynamically rendered from `GET /games/{type}/config-schema`. Each section becomes a collapsible group. Fields generated from JSON Schema types (string → text input, integer → number input, boolean → toggle, enum → dropdown).
|
||
- **Sensitive fields:** Password inputs with show/hide toggle. Pre-generated values shown once in a dismissible callout after creation.
|
||
- **Port auto-fill:** Game port defaults from adapter's `get_default_game_port()`. RCon port from `get_default_rcon_port()`. User can override.
|
||
|
||
### 5. Server Detail
|
||
|
||
The primary operating surface. Tabbed layout with real-time data.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ ← Servers │ Main Server │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 🟡 RUNNING Arma 3 PID: 12345 Uptime: 2h 15m │ ← amber LED + glow
|
||
│ │
|
||
│ [Stop] [Restart] [Kill ⚠] │ ← action bar
|
||
│ │
|
||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
||
│ │(neu-raised)│ │(neu-raised)│ │(neu-raised)│ │
|
||
│ │ 15 │ │ 34.2% │ │ 1850 MB │ │
|
||
│ │ Players │ │ CPU │ │ RAM │ │
|
||
│ └────────────┘ └────────────┘ └────────────┘ │
|
||
│ │
|
||
│ [Overview] [Logs] [Players] [Config] [Missions] [Mods] │
|
||
│ ═════════ │
|
||
│ │
|
||
│ ── Tab Content Area ── │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Action bar:**
|
||
- `[Stop]` and `[Restart]` are `neu-raised` (default surface style).
|
||
- `[Kill ⚠]` is `neu-raised` with red text (not a red button — danger signals through color, not surface).
|
||
- Buttons press to `neu-inset` on click (tactile feedback).
|
||
- Action bar is fixed at top of content — always accessible.
|
||
|
||
**Metric cards:**
|
||
- Neumorphic raised (`neu-raised`). Updated in real-time via WebSocket (`type: "metrics"`).
|
||
- Player count shows `current/max` in mono font, accent color when > 0.
|
||
- CPU color-coded: <50% white, 50-80% amber, >80% orange.
|
||
|
||
**Tabs:** Rendered/hidden based on adapter capabilities:
|
||
|
||
| Tab | Condition | Source |
|
||
|-----|-----------|--------|
|
||
| Overview | Always | Server detail + recent events |
|
||
| Logs | Always | WebSocket `type: "log"` + `GET /servers/{id}/logs` |
|
||
| Players | `has_capability("remote_admin")` | WebSocket `type: "players"` + `GET /servers/{id}/players` |
|
||
| Config | Always (admin only) | `GET /servers/{id}/config` |
|
||
| Missions | `has_capability("mission_manager")` | `GET /servers/{id}/missions` |
|
||
| Mods | `has_capability("mod_manager")` | `GET /servers/{id}/mods` + `GET /mods` |
|
||
|
||
Tabs that the adapter doesn't support are simply not rendered. No disabled tabs, no "coming soon" badges.
|
||
|
||
### 5a. Server Detail — Logs Tab
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ [Level: All ▾] [Search...] [Clear Logs ⚠] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ 10:05:23 INFO BattlEye Server: Initialized (v1.240) │
|
||
│ 10:05:24 INFO Player PlayerOne connected │
|
||
│ 10:05:30 WARN High ping detected: PlayerTwo (450ms) │
|
||
│ 10:06:01 ERROR BattlEye: RCon connection timeout │
|
||
│ 10:06:15 INFO Player PlayerOne disconnected │
|
||
│ │
|
||
│ ── streaming ── │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Entirely monospaced** (`--font-mono`). This is a log terminal, not a chat.
|
||
- **Container uses `neu-inset`** — the log area looks like a sunken display screen.
|
||
- **Level colors:** INFO = dim white, WARN = amber, ERROR = red. Color on the level tag only, not the whole line.
|
||
- **Streaming:** New lines prepend from top (newest-first) or append to bottom (oldest-first) — user toggle. Default: newest-first.
|
||
- **Virtualized list** for performance. Logs can grow to tens of thousands of lines.
|
||
- **Search** is client-side filter on loaded logs + server-side `?search=` for historical.
|
||
- **No auto-scroll lock** — if user scrolls up, stop auto-scroll. Resume when scrolled to bottom.
|
||
|
||
### 5b. Server Detail — Players Tab
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Players (15/40) [Say All] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────┬──────────┬────────┬──────┬──────────────────┐│
|
||
│ │ Slot │ Name │ GUID │ Ping │ Actions ││
|
||
│ ├─────────┼──────────┼────────┼──────┼──────────────────┤│
|
||
│ │ 1 │ PlayerOne│ abc... │ 45ms │ [Kick] [Ban] ││
|
||
│ │ 2 │ PlayerTwo│ def... │ 450ms│ [Kick] [Ban] ││
|
||
│ │ ... │ │ │ │ ││
|
||
│ └─────────┴──────────┴────────┴──────┴──────────────────┘│
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- **Real-time:** Table updates via WebSocket `type: "players"`. No manual refresh.
|
||
- **GUID** column shows truncated GUID (click to copy full).
|
||
- **Ping** column color-coded: <100 green, 100-300 default, >300 amber, >500 red.
|
||
- **Actions:** Kick opens a small popover for reason input. Ban opens a popover with reason + duration.
|
||
- **Say All button:** Opens a message input. Sends `POST /servers/{id}/remote-admin/say`.
|
||
- **Viewer role:** Sees the table, no action buttons.
|
||
|
||
### 5c. Server Detail — Config Tab
|
||
|
||
The most complex UI surface. Dynamic form generation from adapter's JSON Schema.
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Config [Preview Config] [Download ▾] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────┐ │
|
||
│ │ ▸ Server (modified*) │ │
|
||
│ │ ▾ Basic (neu-raised panel) │ │
|
||
│ │ ┌───────────────────────────────────────────────┐ │ │
|
||
│ │ │ Hostname ╔═══════════════════════════════╗ │ │ │ ← neu-inset input
|
||
│ │ │ ║ My Arma 3 Server ║ │ │ │
|
||
│ │ │ ╚═══════════════════════════════╝ │ │ │
|
||
│ │ │ Max Players ╔════════════╗ Password ╔════╗ │ │ │
|
||
│ │ │ ║ 40 ║ ║••••║ │ │ │
|
||
│ │ │ ╚════════════╝ [👁] ╚════╝ │ │ │
|
||
│ │ │ BattlEye [● On ] Verify Sig [2 ▾] │ │ │ ← toggle = raised
|
||
│ │ └───────────────────────────────────────────────┘ │ │
|
||
│ │ ▸ Profile │ │
|
||
│ │ ▸ Launch │ │
|
||
│ │ ▸ RCon │ │
|
||
│ └─────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ [Reset Section] ╔═══════════════╗ │
|
||
│ ║ Save Changes ║ ← neu-raised-accent │
|
||
│ ╚══════════════╝ (amber fill) │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Dynamic form generation:**
|
||
- Config sections are collapsible panels, one per adapter section.
|
||
- Each section's fields are rendered from `GET /games/{type}/config-schema` (JSON Schema).
|
||
- JSON Schema → form field mapping:
|
||
- `type: "string"` → text input (or textarea if `format: "multiline"`)
|
||
- `type: "integer"` → number input (with min/max from schema)
|
||
- `type: "number"` → number input (step=0.1)
|
||
- `type: "boolean"` → toggle switch
|
||
- `enum: [...]` → dropdown select
|
||
- `type: "array"` → repeatable field group (for motd_lines, etc.)
|
||
- Sensitive fields (from `get_sensitive_fields()`) → password input with show/hide
|
||
- Field descriptions from JSON Schema `description` → tooltip on hover.
|
||
|
||
**Optimistic locking:**
|
||
- Each section stores its `config_version` from the last read.
|
||
- On save (`PUT /servers/{id}/config/{section}`), sends the version.
|
||
- On 409 Conflict: shows a diff dialog with "Your changes" vs "Current server values", with options to override or merge.
|
||
|
||
**Dirty state:**
|
||
- Unsaved changes show `(modified*)` on the section header.
|
||
- Navigation away from dirty form triggers an unsaved-changes dialog.
|
||
- `Reset Section` reverts to the last saved state.
|
||
|
||
**Preview:**
|
||
- `Preview Config` opens a modal with rendered config from `GET /servers/{id}/config/preview`.
|
||
- Each entry in the `label→content` dict is shown as a labeled code block. Monospaced.
|
||
- `Download ▾` gives individual file downloads from `GET /servers/{id}/config/download/{filename}`.
|
||
|
||
### 5d. Server Detail — Missions Tab
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Missions [Upload .pbo] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ── Active Rotation ────────────────────────────────────── │
|
||
│ 1. MyMission.Altis (Regular) [↑] [↓] [✕] │
|
||
│ 2. ZeusOps.Altis (Veteran) [↑] [↓] [✕] │
|
||
│ │
|
||
│ ── Available Missions ─────────────────────────────────── │
|
||
│ ┌──────────────────────┬──────────┬────────┬──────────┐ │
|
||
│ │ Filename │ Terrain │ Size │ Actions │ │
|
||
│ ├──────────────────────┼──────────┼────────┼──────────┤ │
|
||
│ │ MyMission.Altis.pbo │ Altis │ 100 KB │ [+ Add] │ │
|
||
│ │ ZeusOps.Altis.pbo │ Altis │ 50 KB │ In rot. │ │
|
||
│ │ Training.Stratis.pbo│ Stratis │ 25 KB │ [+ Add] │ │
|
||
│ └──────────────────────┴──────────┴────────┴──────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- Only shown if `has_capability("mission_manager")`.
|
||
- **Upload:** Drag-and-drop zone + file picker. Extension validated from adapter's `MissionManager.file_extension`.
|
||
- **Rotation:** Ordered list. Reorder via drag-and-drop or arrow buttons. Difficulty dropdown per entry.
|
||
- **Add to rotation:** Button on each available mission. Moves it to rotation.
|
||
- **Remove from rotation:** Removes from rotation, mission stays on disk.
|
||
|
||
### 5e. Server Detail — Mods Tab
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Mods [Register Mod] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ── Active Mods ───────────────────────────────────────── │
|
||
│ ┌──────────┬─────────────────────┬────────────┬────────┐ │
|
||
│ │ Type │ Mod │ Workshop ID │ Remove │ │
|
||
│ ├──────────┼─────────────────────┼────────────┼────────┤ │
|
||
│ │ Client │ @CBA_A3 │ 450814997 │ [✕] │ │
|
||
│ │ Server │ @ACE_server │ — │ [✕] │ │
|
||
│ └──────────┴─────────────────────┴────────────┴────────┘ │
|
||
│ │
|
||
│ ── Available Mods ─────────────────────────────────────── │
|
||
│ ┌─────────────────────┬────────────┬──────────────────┐ │
|
||
│ │ @CBA_A3 │ 450814997 │ [+ Enable] │ │
|
||
│ │ @ACE │ 463289743 │ [+ Enable] │ │
|
||
│ └─────────────────────┴────────────┴──────────────────┘ │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- Only shown if `has_capability("mod_manager")`.
|
||
- **Client vs Server mod:** Toggle on each mod assignment. Determines `-mod=` vs `-serverMod=` in Arma 3.
|
||
- **Sort order:** Drag-and-drop reordering within each type. Affects load order.
|
||
- **Register Mod:** Opens a form to add a new mod folder path + metadata.
|
||
|
||
### 6. Bans Page
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Bans [Server: All ▾] │
|
||
├─────────────────────────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌──────────┬──────────┬───────────────┬────────┬──────────┐│
|
||
│ │ Server │ Name │ GUID │ Reason │ Expires ││
|
||
│ ├──────────┼──────────┼───────────────┼────────┼──────────┤│
|
||
│ │ Main │ Cheater42│ abc123... │ Hacking│ Perm ││
|
||
│ │ Altis │ Troll99 │ def456... │ Grief │ 2h left ││
|
||
│ └──────────┴──────────┴───────────────┴────────┴──────────┘│
|
||
│ │
|
||
│ [+ Add Ban Manually] │
|
||
│ │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- Cross-server view by default (all servers). Filterable by server.
|
||
- **Add Ban Manually:** Opens a form with GUID/Name/Reason/Duration/Server selector.
|
||
- **Unban:** Confirmation dialog → `DELETE /servers/{id}/bans/{ban_id}`.
|
||
- Adapter's `BanManager` syncs to game ban file automatically (no extra UI needed).
|
||
|
||
### 7. Users Page (Admin Only)
|
||
|
||
Simple CRUD table: username, role, created date. Add/delete users. Change role. No inline password editing (use `/auth/password`).
|
||
|
||
### 8. Settings Page (Admin Only)
|
||
|
||
- Change own password
|
||
- System info (version, uptime, supported games)
|
||
- API key management (future)
|
||
|
||
---
|
||
|
||
## Component Architecture
|
||
|
||
### Directory Structure
|
||
|
||
```
|
||
frontend/
|
||
├── index.html
|
||
├── vite.config.ts
|
||
├── tsconfig.json
|
||
├── package.json
|
||
├── tailwind.config.ts
|
||
├── postcss.config.js
|
||
│
|
||
├── public/
|
||
│ └── favicon.svg
|
||
│
|
||
└── src/
|
||
├── main.tsx # Mount point
|
||
├── App.tsx # Router + providers
|
||
├── vite-env.d.ts
|
||
│
|
||
├── styles/
|
||
│ ├── globals.css # CSS variables, resets, base styles
|
||
│ └── fonts.css # @font-face declarations
|
||
│
|
||
├── api/
|
||
│ ├── client.ts # Ky instance with base URL + auth interceptor
|
||
│ ├── auth.ts # Login, me, users
|
||
│ ├── servers.ts # Server CRUD, start/stop/kill
|
||
│ ├── config.ts # Config CRUD, preview, download
|
||
│ ├── players.ts # Players, kick, ban
|
||
│ ├── mods.ts # Mod registration, server mods
|
||
│ ├── missions.ts # Missions, upload, rotation
|
||
│ ├── bans.ts # Bans CRUD
|
||
│ ├── games.ts # Game type discovery, schemas, defaults
|
||
│ ├── logs.ts # Log queries
|
||
│ ├── metrics.ts # Metrics queries
|
||
│ └── events.ts # Event log queries
|
||
│
|
||
├── hooks/
|
||
│ ├── useWebSocket.ts # Connection management, reconnection, channel sub
|
||
│ ├── useAuth.ts # JWT state, login/logout, role check
|
||
│ ├── useServerStatus.ts # WS-driven status for a server (or all)
|
||
│ ├── useServerLogs.ts # WS-driven log stream
|
||
│ ├── useServerPlayers.ts # WS-driven player list
|
||
│ ├── useServerMetrics.ts # WS-driven metrics
|
||
│ ├── useConfigForm.ts # Dynamic form from JSON Schema + optimistic locking
|
||
│ ├── useCapability.ts # Check adapter.has_capability()
|
||
│ └── useConfirm.ts # Confirmation dialog hook
|
||
│
|
||
├── stores/
|
||
│ ├── authStore.ts # Zustand: token, user, role
|
||
│ └── wsStore.ts # Zustand: connection status, reconnect count
|
||
│
|
||
├── components/
|
||
│ ├── ui/ # Primitives (no business logic)
|
||
│ │ ├── Button.tsx # neu-raised / neu-raised-accent / neu-raised-danger
|
||
│ │ ├── Input.tsx # neu-inset
|
||
│ │ ├── Select.tsx # neu-inset + custom dropdown (neu-raised)
|
||
│ │ ├── Toggle.tsx # neu-raised (on) / neu-inset (off) with amber LED
|
||
│ │ ├── Badge.tsx
|
||
│ │ ├── Modal.tsx # neu-flat (drop shadow, no neumorphic play)
|
||
│ │ ├── Toast.tsx
|
||
│ │ ├── Tooltip.tsx
|
||
│ │ ├── ConfirmDialog.tsx
|
||
│ │ ├── EmptyState.tsx
|
||
│ │ ├── LoadingBar.tsx # amber bar, inset track
|
||
│ │ ├── CodeBlock.tsx # neu-inset
|
||
│ │ └── StatusLed.tsx # LED dot with glow (amber/red/gray)
|
||
│ │
|
||
│ ├── layout/
|
||
│ │ ├── AppShell.tsx # Header + sidebar + footer
|
||
│ │ ├── Sidebar.tsx
|
||
│ │ ├── Header.tsx
|
||
│ │ └── StatusBar.tsx # Footer: WS status, server count
|
||
│ │
|
||
│ ├── server/
|
||
│ │ ├── ServerCard.tsx # Dashboard summary card (neu-raised)
|
||
│ │ ├── ServerList.tsx # Table view (rows inside neu-raised container)
|
||
│ │ ├── ServerStatusDot.tsx # Status LED with glow
|
||
│ │ ├── ServerActionBar.tsx # Start/Stop/Restart/Kill buttons
|
||
│ │ ├── ServerMetricCard.tsx # Player/CPU/RAM card (neu-raised, amber numbers)
|
||
│ │ └── NewServerDialog.tsx # Multi-step creation modal (neu-flat)
|
||
│ │
|
||
│ ├── config/
|
||
│ │ ├── ConfigSection.tsx # Collapsible config panel (neu-raised)
|
||
│ │ ├── ConfigForm.tsx # Dynamic form from JSON Schema (neu-inset inputs)
|
||
│ │ ├── ConfigField.tsx # Single field renderer
|
||
│ │ ├── ConfigPreview.tsx # Modal with rendered config (neu-inset code blocks)
|
||
│ │ └── ConflictDialog.tsx # Optimistic locking 409 handler
|
||
│ │
|
||
│ ├── players/
|
||
│ │ ├── PlayerTable.tsx
|
||
│ │ ├── KickPopover.tsx
|
||
│ │ ├── BanPopover.tsx
|
||
│ │ └── SayAllDialog.tsx
|
||
│ │
|
||
│ ├── logs/
|
||
│ │ ├── LogViewer.tsx # Virtualized log stream
|
||
│ │ └── LogLine.tsx
|
||
│ │
|
||
│ ├── missions/
|
||
│ │ ├── MissionTable.tsx
|
||
│ │ ├── MissionUpload.tsx
|
||
│ │ ├── RotationList.tsx
|
||
│ │ └── RotationEntry.tsx
|
||
│ │
|
||
│ ├── mods/
|
||
│ │ ├── ModTable.tsx
|
||
│ │ ├── ServerModList.tsx
|
||
│ │ └── ModRegistrationDialog.tsx
|
||
│ │
|
||
│ ├── bans/
|
||
│ │ ├── BanTable.tsx
|
||
│ │ └── BanFormDialog.tsx
|
||
│ │
|
||
│ └── charts/
|
||
│ ├── MetricsChart.tsx # CPU + RAM time series
|
||
│ └── PlayerCountChart.tsx
|
||
│
|
||
├── pages/
|
||
│ ├── LoginPage.tsx
|
||
│ ├── DashboardPage.tsx
|
||
│ ├── ServerListPage.tsx
|
||
│ ├── ServerDetailPage.tsx
|
||
│ ├── ModsPage.tsx
|
||
│ ├── BansPage.tsx
|
||
│ ├── UsersPage.tsx
|
||
│ └── SettingsPage.tsx
|
||
│
|
||
└── lib/
|
||
├── jsonSchemaToFields.ts # JSON Schema → form field descriptors
|
||
├── formatUptime.ts # Seconds → "2h 15m"
|
||
├── formatBytes.ts # Bytes → human readable
|
||
├── timeAgo.ts # Timestamp → "5 minutes ago"
|
||
└── cn.ts # clsx + tailwind-merge utility
|
||
```
|
||
|
||
### Key Component Contracts
|
||
|
||
**`ConfigForm`** — the hardest component. Must handle:
|
||
- Dynamic rendering from JSON Schema (any game, any section)
|
||
- Sensitive field masking
|
||
- Dirty state tracking
|
||
- Optimistic locking (send `config_version` on save)
|
||
- 409 conflict resolution (show diff, allow override/merge)
|
||
- Validation errors from adapter (field-level, from Pydantic)
|
||
- **Error boundary**: if `jsonSchemaToFields()` encounters an unsupported or malformed schema type (deeply nested objects, unknown formats), render a fallback "This section uses an unsupported field type. Edit raw JSON." with a raw JSON editor instead of crashing the form
|
||
|
||
**ConfigForm test plan** (critical — highest-risk component):
|
||
- **Unit: `jsonSchemaToFields`** — test every supported type mapping (string→text, integer→number, boolean→toggle, enum→select, array→repeatable), test sensitive field masking, test unknown type → fallback descriptor with `type: "raw_json"`
|
||
- **Unit: `jsonSchemaToFields`** — test malformed schema input (missing `properties`, nested `$ref`, `oneOf`/`anyOf`) → returns fallback descriptor, never throws
|
||
- **Integration: `ConfigForm`** — render with a 2-section schema, verify field rendering, toggle a boolean, verify dirty state, submit and verify request payload
|
||
- **Integration: 409 conflict** — after save, mock a 409 response, verify ConflictDialog appears with diff
|
||
- **Integration: error boundary** — mount `ConfigForm` with a schema that has unsupported `type: "object"` nested 3 levels deep, verify raw JSON fallback renders instead of crash
|
||
|
||
**`LogViewer`** — performance-critical:
|
||
- Virtualized rendering (react-window or similar)
|
||
- Newest-first or oldest-first toggle
|
||
- Level filter, text search
|
||
- Auto-scroll with manual-override detection
|
||
- Streams via WebSocket, paginated fallback via HTTP
|
||
|
||
**`ServerDetailPage`** — orchestrator:
|
||
- Resolves adapter from `server.game_type`
|
||
- Checks `has_capability()` to show/hide tabs
|
||
- Manages WS subscriptions for the server
|
||
- Handles status transitions in real-time (start → starting → running)
|
||
|
||
---
|
||
|
||
## State Management Strategy
|
||
|
||
### Server State (TanStack Query)
|
||
|
||
All API data uses TanStack Query with appropriate stale times:
|
||
|
||
| Query | staleTime | refetchInterval |
|
||
|-------|----------|-----------------|
|
||
| Server list | 30s | Background 30s (fallback for WS) |
|
||
| Server detail | 15s | — |
|
||
| Config sections | 5min | — (manual save) |
|
||
| Players | 0s | WS-driven |
|
||
| Logs | 0s | WS-driven |
|
||
| Metrics | 0s | WS-driven |
|
||
| Missions | 5min | — |
|
||
| Mods | 5min | — |
|
||
| Bans | 2min | — |
|
||
| Game types | 30min | — (rarely changes) |
|
||
| Config schema | 30min | — (tied to adapter version) **Invalidation**: when `GET /games/{type}` returns a different `schema_version` than previously cached, TanStack Query invalidates all config schema queries for that game type. This prevents stale forms after an adapter update. |
|
||
|
||
**Optimistic updates** on:
|
||
- Server start/stop → immediately update status in cache, rollback on error
|
||
- Player kick/ban → immediately remove from player list cache
|
||
- Config save → immediately update config cache, rollback on 409 or error
|
||
|
||
### Client State (Zustand)
|
||
|
||
Only two stores — keep it minimal:
|
||
|
||
**`authStore`:**
|
||
```typescript
|
||
interface AuthState {
|
||
token: string | null;
|
||
user: { id: number; username: string; role: "admin" | "viewer" } | null;
|
||
setAuth: (token: string, user: User) => void;
|
||
logout: () => void;
|
||
isAdmin: () => boolean;
|
||
}
|
||
```
|
||
|
||
**`wsStore`:**
|
||
```typescript
|
||
interface WsState {
|
||
status: "connected" | "reconnecting" | "disconnected";
|
||
reconnectAttempts: number;
|
||
lastEventAt: number | null;
|
||
}
|
||
```
|
||
|
||
### URL State
|
||
|
||
Persisted in URL query params:
|
||
- Server list filters (`game_type`, `sort`)
|
||
- Log viewer filters (`level`, `search`)
|
||
- Ban list filters (`server`, `active_only`)
|
||
- Metrics chart time range (`from`, `to`, `resolution`)
|
||
|
||
---
|
||
|
||
## WebSocket Integration
|
||
|
||
### Connection Lifecycle
|
||
|
||
```
|
||
App Mount
|
||
│
|
||
├── Connect to ws://localhost:8000/ws/all?token=<JWT>
|
||
│ ├── On open: wsStore.status = "connected"
|
||
│ ├── On close: wsStore.status = "disconnected"
|
||
│ │ └── Auto-reconnect with exponential backoff (1s → 2s → 4s → ... → 30s)
|
||
│ ├── On error: wsStore.status = "reconnecting"
|
||
│ └── On message: dispatch to handlers
|
||
│
|
||
├── Subscribe to: ["status", "event"]
|
||
│
|
||
└── On server detail navigation:
|
||
└── Subscribe to: ["logs", "players", "metrics", "status", "event"]
|
||
for that specific server_id
|
||
```
|
||
|
||
### Message Dispatch
|
||
|
||
```typescript
|
||
// hooks/useWebSocket.ts
|
||
function handleWsMessage(msg: WsMessage) {
|
||
switch (msg.type) {
|
||
case "status":
|
||
queryClient.setQueryData(["servers", msg.server_id], (old) => ({
|
||
...old, status: msg.data.status, pid: msg.data.pid, started_at: msg.data.started_at,
|
||
}));
|
||
break;
|
||
|
||
case "log":
|
||
// Prepend to log cache (newest-first)
|
||
queryClient.setQueryData(["servers", msg.server_id, "logs"], (old) => ({
|
||
...old, logs: [msg.data, ...old.logs],
|
||
}));
|
||
break;
|
||
|
||
case "players":
|
||
queryClient.setQueryData(["servers", msg.server_id, "players"], msg.data.players);
|
||
break;
|
||
|
||
case "metrics":
|
||
queryClient.setQueryData(["servers", msg.server_id, "metrics"], (old) => ({
|
||
...old, current: msg.data,
|
||
}));
|
||
break;
|
||
|
||
case "event":
|
||
// Prepend to events cache + show toast for critical events
|
||
if (msg.data.event_type === "crashed") {
|
||
toast.error(`Server ${msg.server_id} crashed`);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
```
|
||
|
||
### Reconnection UX
|
||
|
||
- **StatusBar** shows connection state at all times: green dot = connected, amber spinner = reconnecting, red X = disconnected.
|
||
- During reconnection, all WS-driven data shows its last known value with a subtle "last updated X seconds ago" indicator.
|
||
- On reconnect, TanStack Query invalidates all server data to get fresh state.
|
||
- If JWT expires during WS connection, server closes the socket. Client detects 4xx on reconnect → redirect to login.
|
||
|
||
---
|
||
|
||
## Adapter-Aware UI Patterns
|
||
|
||
The frontend never hardcodes game-specific logic. It queries adapter metadata and renders accordingly.
|
||
|
||
### Capability Check Pattern
|
||
|
||
```typescript
|
||
// hooks/useCapability.ts
|
||
function useCapability(serverId: number) {
|
||
const { data: server } = useServerDetail(serverId);
|
||
const { data: gameType } = useGameType(server?.game_type);
|
||
|
||
return {
|
||
hasMissions: gameType?.capabilities.includes("mission_manager") ?? false,
|
||
hasMods: gameType?.capabilities.includes("mod_manager") ?? false,
|
||
hasRemoteAdmin: gameType?.capabilities.includes("remote_admin") ?? false,
|
||
hasBanManager: gameType?.capabilities.includes("ban_manager") ?? false,
|
||
};
|
||
}
|
||
|
||
// Usage in ServerDetailPage:
|
||
const { hasMissions, hasMods, hasRemoteAdmin } = useCapability(serverId);
|
||
// Only render tabs that the adapter supports
|
||
```
|
||
|
||
### Dynamic Config Form Pattern
|
||
|
||
```typescript
|
||
// lib/jsonSchemaToFields.ts
|
||
interface FieldDescriptor {
|
||
name: string;
|
||
label: string;
|
||
type: "text" | "number" | "boolean" | "select" | "textarea" | "password" | "array" | "raw_json";
|
||
default?: unknown;
|
||
min?: number;
|
||
max?: number;
|
||
step?: number;
|
||
enum?: string[];
|
||
description?: string;
|
||
isSensitive?: boolean;
|
||
}
|
||
|
||
function jsonSchemaToFields(
|
||
schema: JsonSchema,
|
||
sensitiveFields: string[]
|
||
): FieldDescriptor[] {
|
||
// Walk schema.properties, map each to a FieldDescriptor
|
||
// Mark fields in sensitiveFields as type: "password"
|
||
// Unknown types (nested objects, oneOf/anyOf, $ref) → type: "raw_json"
|
||
// The form renders a textarea with JSON editing for these fields
|
||
// rather than crashing or hiding the field silently
|
||
}
|
||
```
|
||
|
||
### Game Type Card Pattern
|
||
|
||
```typescript
|
||
// Used in NewServerDialog and Dashboard
|
||
<GameTypeCard
|
||
gameType="arma3"
|
||
displayName="Arma 3"
|
||
capabilities={[...]}
|
||
selected={selectedGame === "arma3"}
|
||
onSelect={() => setSelectedGame("arma3")}
|
||
/>
|
||
```
|
||
|
||
Future adapters register themselves; the card list auto-populates from `GET /games`.
|
||
|
||
### Schema Cache Invalidation
|
||
|
||
Adapter config schemas are cached with `staleTime: 30min`. When an adapter is updated and its `schema_version` changes, the frontend must not serve a stale schema. The invalidation pattern:
|
||
|
||
```typescript
|
||
// When fetching game type info, compare schema_version
|
||
function useGameTypeWithInvalidation(gameType: string) {
|
||
const queryClient = useQueryClient();
|
||
return useQuery({
|
||
queryKey: ["gameType", gameType],
|
||
staleTime: 30 * 60 * 1000,
|
||
onSuccess: (data) => {
|
||
const prevVersion = localStorage.getItem(`schema_version_${gameType}`);
|
||
if (prevVersion && prevVersion !== data.schema_version) {
|
||
// Adapter was updated — invalidate all config schema queries
|
||
queryClient.invalidateQueries({ queryKey: ["configSchema", gameType] });
|
||
localStorage.setItem(`schema_version_${gameType}`, data.schema_version);
|
||
} else if (!prevVersion) {
|
||
localStorage.setItem(`schema_version_${gameType}`, data.schema_version);
|
||
}
|
||
},
|
||
});
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Error Handling UX
|
||
|
||
| Scenario | UI Response |
|
||
|----------|-------------|
|
||
| API 401 | Redirect to login with "Session expired" toast |
|
||
| API 403 | "You don't have permission" inline message |
|
||
| API 404 | Empty state component with "Not found" |
|
||
| API 409 (config conflict) | ConflictDialog with diff + override/merge |
|
||
| API 422 (validation) | Field-level red highlights + error messages |
|
||
| API 429 (rate limit) | "Too many requests, try again in X seconds" toast |
|
||
| API 500 | "Server error" toast with retry button |
|
||
| WS disconnect | StatusBar indicator + stale data with timestamp |
|
||
| WS reconnect | Automatic; no user action needed |
|
||
| Server crashed | Toast notification + status dot turns red |
|
||
| Malformed adapter schema | Raw JSON fallback in ConfigForm section ("This section uses an unsupported field type. Edit raw JSON.") |
|
||
|
||
**No `alert()` calls.** All feedback uses toast (transient) or inline (persistent) patterns.
|
||
|
||
---
|
||
|
||
## Performance Budget
|
||
|
||
| Metric | Target |
|
||
|--------|--------|
|
||
| First Contentful Paint | < 1.5s |
|
||
| Time to Interactive | < 3s |
|
||
| Bundle size (gzipped) | < 250KB JS |
|
||
| CSS size (gzipped) | < 40KB |
|
||
| Log viewer render (1000 lines) | < 16ms per frame |
|
||
| WebSocket message processing | < 5ms per message |
|
||
|
||
**Techniques:**
|
||
- Vite code splitting per route (lazy `React.lazy` + `Suspense`)
|
||
- Virtual list for log viewer (react-window)
|
||
- TanStack Query deduplication (same query key = same request)
|
||
- Tailwind purge for minimal CSS
|
||
- Fonts: preload critical weights only (`Inter` 400/500/600, `JetBrains Mono` 400)
|
||
- No icon font — Lucide is tree-shaken SVGs
|
||
|
||
---
|
||
|
||
## Accessibility
|
||
|
||
- **Focus management:** Modals trap focus. Close on Escape. Return focus to trigger.
|
||
- **Keyboard navigation:** All interactive elements reachable via Tab. Action bar buttons have shortcuts (S=Start, X=Stop, R=Restart).
|
||
- **Color is not the only status indicator:** Status LEDs are paired with text labels. Logs use prefix tags (INFO, WARN, ERROR), not just color.
|
||
- **Contrast:** All text meets WCAG AA against its surface (4.5:1 minimum for body text).
|
||
- **Reduced motion:** Respect `prefers-reduced-motion` — disable transitions when active.
|
||
- **ARIA:** Live regions for WS status changes. `aria-label` on icon-only buttons.
|
||
|
||
---
|
||
|
||
## Toast System
|
||
|
||
Transient notifications. Stack in bottom-right.
|
||
|
||
| Type | Use | Duration |
|
||
|------|-----|----------|
|
||
| Success | Config saved, server started, mod enabled | 3s |
|
||
| Error | API errors, WS disconnect, validation failures | 7s (or dismiss) |
|
||
| Warning | Rate limited, high CPU, approaching memory limit | 5s |
|
||
| Info | Ban synced to file, mission uploaded | 3s |
|
||
|
||
Max 3 visible at once. Oldest dismissed automatically.
|
||
|
||
---
|
||
|
||
## Loading States
|
||
|
||
- **Route transitions:** Suspense boundary with a minimal loading bar at the top of the content area (not a full-page spinner).
|
||
- **Data loading:** Skeleton placeholders matching the layout shape of the loaded content. No spinners.
|
||
- **Actions:** Button shows inline spinner for duration of request. Disabled during request.
|
||
- **Initial load:** Dashboard shows skeleton cards → real data populates in place.
|
||
|
||
---
|
||
|
||
## Empty States
|
||
|
||
Every list/table has a designed empty state:
|
||
|
||
| Context | Empty State |
|
||
|---------|------------|
|
||
| No servers | "No servers yet" + [Create Server] button |
|
||
| No players | "No players connected" (dimmed, no action needed) |
|
||
| No missions | "No missions uploaded" + [Upload Mission] button |
|
||
| No mods registered | "No mods registered" + [Register Mod] button |
|
||
| No bans | "No active bans" (this is good news — no action needed) |
|
||
| No logs | "No log entries yet — server may not have started" |
|
||
|
||
---
|
||
|
||
## Build & Dev Commands
|
||
|
||
```bash
|
||
# Development
|
||
npm run dev # Vite dev server on :5173
|
||
|
||
# Production build
|
||
npm run build # tsc + vite build
|
||
npm run preview # Preview production build locally
|
||
|
||
# Quality
|
||
npm run lint # ESLint
|
||
npm run format # Prettier
|
||
npm run typecheck # tsc --noEmit
|
||
```
|
||
|
||
### globals.css (neumorphic classes)
|
||
|
||
The neumorphic shadow classes live in `src/styles/globals.css` and are referenced by Tailwind components via `@apply` or direct class names. They cannot be expressed as Tailwind utilities (multi-shadow syntax):
|
||
|
||
```css
|
||
/* src/styles/globals.css — neumorphic primitives */
|
||
|
||
.neu-raised {
|
||
background: var(--color-surface);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
box-shadow:
|
||
4px 4px 8px var(--neu-dark),
|
||
-4px -4px 8px var(--neu-light);
|
||
transition: box-shadow var(--duration-fast) var(--ease-out);
|
||
}
|
||
|
||
.neu-raised:active {
|
||
box-shadow:
|
||
inset 3px 3px 6px var(--neu-dark),
|
||
inset -3px -3px 6px var(--neu-light);
|
||
}
|
||
|
||
.neu-inset {
|
||
background: var(--color-base);
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
box-shadow:
|
||
inset 3px 3px 6px var(--neu-dark),
|
||
inset -3px -3px 6px var(--neu-light);
|
||
}
|
||
|
||
.neu-flat {
|
||
background: var(--color-elevated);
|
||
border: none;
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5);
|
||
}
|
||
|
||
.neu-raised-accent {
|
||
background: var(--color-accent);
|
||
color: #0d0d0d;
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
box-shadow:
|
||
4px 4px 8px var(--neu-dark),
|
||
-4px -4px 8px var(--neu-light),
|
||
0 0 12px oklch(72% 0.15 80 / 0.2);
|
||
transition: box-shadow var(--duration-fast) var(--ease-out);
|
||
}
|
||
|
||
.neu-raised-accent:hover {
|
||
background: var(--color-accent-hover);
|
||
}
|
||
|
||
.neu-raised-accent:active {
|
||
box-shadow:
|
||
inset 3px 3px 6px var(--neu-dark),
|
||
inset -3px -3px 6px var(--neu-light);
|
||
}
|
||
|
||
/* LED status indicators */
|
||
.led {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 9999px;
|
||
display: inline-block;
|
||
}
|
||
|
||
.led-running {
|
||
composes: led;
|
||
background: var(--color-running);
|
||
box-shadow: var(--glow-amber);
|
||
}
|
||
|
||
.led-crashed {
|
||
composes: led;
|
||
background: var(--color-crashed);
|
||
box-shadow: var(--glow-red);
|
||
}
|
||
|
||
.led-stopped {
|
||
composes: led;
|
||
background: var(--color-stopped);
|
||
box-shadow: none;
|
||
}
|
||
```
|
||
|
||
### Vite Proxy (dev only)
|
||
|
||
```typescript
|
||
// vite.config.ts
|
||
export default defineConfig({
|
||
server: {
|
||
proxy: {
|
||
'/api': 'http://localhost:8000',
|
||
'/ws': {
|
||
target: 'ws://localhost:8000',
|
||
ws: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Frontend ↔ Backend Contract Summary
|
||
|
||
| Frontend Action | API Call | WS Channel |
|
||
|----------------|----------|------------|
|
||
| View server list | `GET /servers` | — |
|
||
| View server detail | `GET /servers/{id}` | Subscribe to server |
|
||
| Start server | `POST /servers/{id}/start` | status |
|
||
| Stop server | `POST /servers/{id}/stop` | status |
|
||
| View live logs | `GET /servers/{id}/logs` (initial) | log |
|
||
| View player list | `GET /servers/{id}/players` (initial) | players |
|
||
| View metrics | `GET /servers/{id}/metrics` (initial) | metrics |
|
||
| Edit config | `GET/PUT /servers/{id}/config/{section}` | — |
|
||
| Preview config | `GET /servers/{id}/config/preview` | — |
|
||
| Upload mission | `POST /servers/{id}/missions/upload` | — |
|
||
| Manage rotation | `GET/PUT /servers/{id}/missions/rotation` | — |
|
||
| Enable mods | `PUT /servers/{id}/mods` | — |
|
||
| Kick player | `POST /servers/{id}/players/{slot}/kick` | players |
|
||
| Ban player | `POST /servers/{id}/players/{slot}/ban` | players |
|
||
| View events | `GET /servers/{id}/events` | event |
|
||
| Check capabilities | `GET /games/{type}` | — |
|
||
| Get config schema | `GET /games/{type}/config-schema` | — | |