feat(panel): add three-sided web console (User, Admin, SysAdmin)

Vite + TypeScript + Tailwind single-page console presenting the three operator tiers and consuming the external felis-api face. Build output and design notes are not tracked.
This commit is contained in:
flyemoji committed 2026-06-26 23:32:39 +09:00
1 parent 47fcd90f75
commit eee00c2772
52 files changed
+7626

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Dependencies
node_modules/
# Build output
dist/
# TypeScript incremental build info
*.tsbuildinfo
# Local env overrides
.env.local
.env.*.local
# Logs
npm-debug.log*
+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>Felis · Control Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4150
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "felis-panel",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Felis control panel — external face of felis-api (spec §7)",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^2.6.0",
"three": "^0.172.0"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^4.1.9"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+4
View File
@@ -0,0 +1,4 @@
{
"apiBase": "/api/v1",
"rootDomain": "example.net"
}
+52
View File
@@ -0,0 +1,52 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { TierProvider } from "@/lib/tier";
import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { Dashboard } from "@/pages/Dashboard";
import { MyServers } from "@/pages/MyServers";
import { ServerConsole } from "@/pages/ServerConsole";
import { Account } from "@/pages/Account";
import { AdminHome } from "@/pages/admin/AdminHome";
import { ServerAdmin } from "@/pages/admin/ServerAdmin";
import { ImageAdmin } from "@/pages/admin/ImageAdmin";
import { OpsOverview } from "@/pages/ops/OpsOverview";
import { FleetTable } from "@/pages/ops/FleetTable";
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
// / User-Side — app-tier, every authenticated principal
// /admin/* Admin-Side — admin-tier, server & content administration
// /ops/* SysAdmin-Side— admin-tier, platform observability cockpit
// Both admin groups sit behind the SAME RequireAdmin wrapper (one is_admin gate);
// the split is by concern, not by tier. TierProvider fetches /me once at boot.
export default function App() {
return (
<TierProvider>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
{/* User-Side — app-tier */}
<Route index element={<Dashboard />} />
<Route path="servers" element={<MyServers />} />
<Route path="servers/:name" element={<ServerConsole />} />
<Route path="account" element={<Account />} />
{/* Admin-Side — admin-tier (server & content ops) */}
<Route path="admin" element={<RequireAdmin />}>
<Route index element={<AdminHome />} />
<Route path="servers" element={<ServerAdmin />} />
<Route path="images" element={<ImageAdmin />} />
</Route>
{/* SysAdmin-Side — admin-tier (platform observability) */}
<Route path="ops" element={<RequireAdmin />}>
<Route index element={<OpsOverview />} />
<Route path="fleet" element={<FleetTable />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</TierProvider>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { NavLink, Outlet } from "react-router-dom";
import { Cat } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTier } from "@/lib/tier";
import { visibleSections } from "@/lib/nav";
export function AppShell() {
const { isAdmin } = useTier();
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
// so admin sections appear only once identity is confirmed.
const sections = visibleSections(isAdmin);
return (
<div className="flex min-h-screen">
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-card/40 p-4 md:flex">
<div className="mb-6 flex items-center gap-2 px-2">
<Cat className="h-6 w-6 text-primary" />
<span className="text-lg font-semibold tracking-tight">Felis</span>
</div>
<nav className="flex flex-col gap-4">
{sections.map((section) => (
<div key={section.id} className="flex flex-col gap-1">
{section.title && (
<div className="px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.title}
</div>
)}
{section.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</div>
))}
</nav>
<div className="mt-auto px-3 pt-4 text-xs text-muted-foreground">
K8s-native Minecraft orchestration
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center gap-2 border-b border-border px-4 md:hidden">
<Cat className="h-5 w-5 text-primary" />
<span className="font-semibold">Felis</span>
</header>
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
// CreateServerDialog IS the §15 收口 ("structured form, not free YAML"): every
// dangerous knob (hostNetwork, hostPath, arbitrary image, raw resource quantities)
// is simply absent from the UI. The image is chosen from the server-side whitelist
// (GET /images); memory/storage are constrained text the API re-validates. Nothing
// here can express a privileged pod — the form's value space IS the allowlist.
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
const POLICIES: { value: AutostartPolicy; label: string }[] = [
{ value: "ownerOnly", label: "Owner only — wake from the panel" },
{ value: "public", label: "Public — any player join wakes it" },
{ value: "allowlist", label: "Allowlist — listed players wake it" },
];
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
interface Props {
cfg: RuntimeConfig;
onCreated: () => void;
}
export function CreateServerDialog({ cfg, onCreated }: Props) {
const [open, setOpen] = useState(false);
const images = useAsync(() => api.listImages(), []);
const [form, setForm] = useState<CreateServerRequest>({
name: "",
subdomain: "",
displayName: "",
image: "",
memory: "4Gi",
storage: "10Gi",
autostartPolicy: "ownerOnly",
});
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
function set<K extends keyof CreateServerRequest>(k: K, v: CreateServerRequest[K]) {
setForm((f) => ({ ...f, [k]: v }));
}
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
const subdomainValid = SUBDOMAIN_RE.test(form.subdomain);
const canSubmit =
!!form.name &&
subdomainValid &&
!!form.image &&
!!form.memory &&
!!form.storage &&
!submitting;
async function submit() {
setSubmitting(true);
setError(null);
try {
const payload: CreateServerRequest = {
...form,
displayName: form.displayName?.trim() || undefined,
};
await api.createServer(payload);
setOpen(false);
onCreated();
setForm((f) => ({ ...f, name: "", subdomain: "", displayName: "" }));
} catch (e) {
setError(humanizeError(e));
} finally {
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus /> New server
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a server</DialogTitle>
<DialogDescription>
Pick from whitelisted images and sizes — the platform provisions the
rest. No raw cluster config is exposed here.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cs-name">Name</Label>
<Input
id="cs-name"
placeholder="survival"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cs-sub">Subdomain</Label>
<Input
id="cs-sub"
placeholder="survival"
value={form.subdomain}
onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
/>
<p className="text-xs text-muted-foreground">
{form.subdomain && !subdomainValid
? "Lowercase letters, digits and hyphens only."
: `Will be reachable at ${hostFor(form.subdomain || "name", cfg)}`}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="cs-display">Display name (optional)</Label>
<Input
id="cs-display"
placeholder="Survival World"
value={form.displayName ?? ""}
onChange={(e) => set("displayName", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>Image</Label>
<Select value={form.image} onValueChange={(v) => set("image", v)}>
<SelectTrigger>
<SelectValue
placeholder={
images.loading
? "Loading images…"
: enabledImages.length
? "Choose an image"
: "No whitelisted images"
}
/>
</SelectTrigger>
<SelectContent>
{enabledImages.map((img) => (
<SelectItem key={img.image_ref} value={img.image_ref}>
{img.image_ref}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Memory</Label>
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MEMORY_OPTIONS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Storage</Label>
<Select value={form.storage} onValueChange={(v) => set("storage", v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STORAGE_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label>Autostart policy</Label>
<Select
value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{POLICIES.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={!canSubmit}>
{submitting ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useLogStream } from "@/lib/useLogStream";
import type { LogLevel, StreamStatus } from "@/lib/logstream";
// Per-level tint. Plain/info are the default readable foreground; warn/error draw
// the eye. Debug is dimmed so it recedes. The console body is a fixed dark
// surface so it reads as a terminal regardless of the page theme.
const LEVEL_CLASS: Record<LogLevel, string> = {
error: "text-red-400",
warn: "text-amber-300",
info: "text-zinc-200",
debug: "text-zinc-500",
plain: "text-zinc-300",
};
const PIN_THRESHOLD_PX = 24;
function StatusIndicator({ status }: { status: StreamStatus }) {
const map: Record<StreamStatus, { dot: string; label: string; text: string }> = {
connecting: { dot: "bg-amber-400 animate-pulse", label: "Connecting…", text: "text-amber-300" },
open: { dot: "bg-emerald-400", label: "Live", text: "text-emerald-300" },
reconnecting: { dot: "bg-amber-400 animate-pulse", label: "Reconnecting…", text: "text-amber-300" },
ended: { dot: "bg-zinc-500", label: "Stream ended", text: "text-zinc-400" },
};
const s = map[status];
return (
<span className={cn("inline-flex items-center gap-1.5 text-xs font-medium", s.text)}>
<span className={cn("h-2 w-2 rounded-full", s.dot)} />
{s.label}
</span>
);
}
/**
* LogConsole renders the §8 read-side live console: a one-way SSE feed of the
* running pod's log. It is intentionally NOT a terminal emulator (xterm) — the
* feed is read-only, so a purpose-built log viewport is the right tool. Writes
* go through the separate RCON command path; the panel never holds an RCON
* password (spec §8/§286).
*
* UX follows modern log viewers: it follows the tail, but if you scroll up to
* read history it stops yanking you down and offers a "Jump to latest" pill;
* scrolling back to the bottom re-pins. The buffer is bounded by the controller.
*/
export function LogConsole({ url }: { url: string }) {
const { lines, status, clear, reconnect } = useLogStream(url);
const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true);
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setPinned(distance <= PIN_THRESHOLD_PX);
}, []);
// Follow the tail only while pinned. Runs after layout so the scroll lands on
// the just-appended line; when the user has scrolled up (pinned=false) it does
// nothing, leaving their position untouched.
useLayoutEffect(() => {
if (!pinned) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines, pinned]);
const jumpToLatest = useCallback(() => setPinned(true), []);
return (
<div className="overflow-hidden rounded-md border border-border bg-zinc-950">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2">
<StatusIndicator status={status} />
<div className="flex items-center gap-1.5">
{status === "ended" && (
<Button
variant="outline"
size="sm"
onClick={reconnect}
className="border-zinc-700 bg-transparent text-zinc-200 hover:bg-zinc-800 hover:text-zinc-50"
>
<RotateCw className="h-3.5 w-3.5" /> Reconnect
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={clear}
disabled={lines.length === 0}
className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50"
>
<Trash2 className="h-3.5 w-3.5" /> Clear
</Button>
</div>
</div>
{/* Viewport */}
<div className="relative">
<div
ref={scrollRef}
onScroll={onScroll}
className="h-[28rem] overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed"
>
{lines.length === 0 ? (
<p className="select-none py-8 text-center text-zinc-600">
{status === "ended"
? "Stream ended — reconnect to resume following the log."
: "Waiting for output…"}
</p>
) : (
lines.map((line) => (
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
{line.text || " "}
</div>
))
)}
</div>
{/* Jump-to-latest pill, shown only when the user has scrolled away from
the tail. */}
{!pinned && (
<button
type="button"
onClick={jumpToLatest}
className="absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg transition-colors hover:bg-primary/90"
>
<ArrowDown className="h-3.5 w-3.5" /> Jump to latest
</button>
)}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { PHASE_COLOR, phaseColor, phaseVariant } from "@/components/PhaseBadge";
import type { Phase } from "@/lib/types";
// The closed lifecycle set felis-api can emit — the Go source of truth is
// internal/apis/felis/v1alpha1 MinecraftServerPhase (Unknown/Stopped/Starting/
// Running/Stopping/Failed), passed through verbatim by the status projection
// (internal/api/k8scluster.go). `satisfies readonly Phase[]` is the real guard:
// it fails the build if any of these strings drops out of the TS Phase union, the
// exact desync that left a "Stopping" server rendering with no variant/colour.
// tsc enforces map↔union agreement, but ONLY this pins union↔backend agreement.
const BACKEND_PHASES = [
"Unknown",
"Stopped",
"Starting",
"Running",
"Stopping",
"Failed",
] as const satisfies readonly Phase[];
describe("phase palette parity", () => {
it("resolves every backend phase to a concrete colour and variant", () => {
for (const phase of BACKEND_PHASES) {
expect(phaseColor(phase), `colour for ${phase}`).toMatch(/^#[0-9a-f]{6}$/i);
expect(phaseVariant(phase), `variant for ${phase}`).toBeTruthy();
}
});
it("gives every phase a distinct hue so transitions are legible", () => {
const hues = BACKEND_PHASES.map((p) => phaseColor(p));
expect(new Set(hues).size).toBe(BACKEND_PHASES.length);
});
});
describe("unmodelled phase fallback", () => {
// `phase` crosses an unvalidated JSON boundary (api.status() is parsed, never
// schema-checked), so the backend emitting a phase the panel predates must NOT
// produce an undefined variant/colour — it degrades to the neutral Unknown
// styling. Casting simulates that out-of-union runtime value.
const future = "Hibernating" as Phase;
it("degrades an out-of-union phase to the Unknown colour", () => {
expect(phaseColor(future)).toBe(PHASE_COLOR.Unknown);
});
it("degrades an out-of-union phase to the Unknown variant", () => {
expect(phaseVariant(future)).toBe(phaseVariant("Unknown"));
});
});
+58
View File
@@ -0,0 +1,58 @@
import { Badge } from "@/components/ui/badge";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
export type BadgeVariant = "default" | "muted" | "destructive" | "outline";
/** PHASE_COLOR maps a lifecycle Phase to a foreground hue. Kept in sync with the
* voxel palette (VoxelFleet reads it through phaseColor) so the 3D scene and the
* list read identically. "Stopping" is orange — a teardown sibling to Starting's
* amber, distinct from Stopped's slate so the transient state is legible. */
export const PHASE_COLOR: Record<Phase, string> = {
Running: "#22c55e",
Starting: "#eab308",
Stopping: "#f97316",
Stopped: "#64748b",
Failed: "#ef4444",
Unknown: "#94a3b8",
};
const VARIANT: Record<Phase, BadgeVariant> = {
Running: "default",
Starting: "outline",
Stopping: "outline",
Stopped: "muted",
Failed: "destructive",
Unknown: "muted",
};
// Phases that should pulse: the two in-flight transitions a pod is moving through.
const TRANSIENT: ReadonlySet<Phase> = new Set<Phase>(["Starting", "Stopping"]);
/** phaseColor / phaseVariant resolve a Phase to its hue / Badge variant, degrading
* to the Unknown styling for any value outside the modelled union. `phase` arrives
* from felis-api as parsed-but-unvalidated JSON, so a raw map index can be
* undefined when the backend emits a phase the panel hasn't modelled yet; the
* fallback keeps the badge and the voxel scene rendering a sane neutral instead. */
export function phaseColor(phase: Phase): string {
return PHASE_COLOR[phase] ?? PHASE_COLOR.Unknown;
}
export function phaseVariant(phase: Phase): BadgeVariant {
return VARIANT[phase] ?? VARIANT.Unknown;
}
export function PhaseBadge({ phase }: { phase: Phase }) {
return (
<Badge variant={phaseVariant(phase)} className="gap-1.5">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
TRANSIENT.has(phase) && "animate-pulse",
)}
style={{ backgroundColor: phaseColor(phase) }}
/>
{phase}
</Badge>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Outlet } from "react-router-dom";
import { useTier } from "@/lib/tier";
import { Loading, NotAuthorized } from "@/components/States";
// RequireAdmin is the single route wrapper for BOTH /admin/* and /ops/* (the two
// admin-tier concerns). It is belt-and-suspenders: it spares non-admins a wall of
// 403s, but the server enforces the boundary independently on every data call.
//
// While /me is still loading we show a spinner rather than NotAuthorized, so a
// genuine admin is never briefly told "not authorized" on a slow boot. Once
// settled, isAdmin is the fail-closed verdict (false on any /me failure).
export function RequireAdmin() {
const { isAdmin, loading } = useTier();
if (loading) return <Loading label="Checking access…" />;
return isAdmin ? <Outlet /> : <NotAuthorized />;
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import type { ServerInfo } from "@/lib/types";
interface Props {
server: ServerInfo;
cfg: RuntimeConfig;
/** Called after a successful mutation so the parent can refetch. */
onChanged: () => void;
}
export function ServerCard({ server, cfg, onChanged }: Props) {
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
const [error, setError] = useState<string | null>(null);
const host = hostFor(server.subdomain, cfg);
async function act(kind: "wake" | "stop" | "claim", fn: () => Promise<unknown>) {
setBusy(kind);
setError(null);
try {
await fn();
onChanged();
} catch (e) {
setError(humanizeError(e));
} finally {
setBusy(null);
}
}
const running = server.phase === "Running";
// Starting and Stopping both have (or had) a pod in flight — neither should
// offer "Wake". Grouping them keeps the footer on the Stop side until the
// server has fully settled into Stopped/Failed.
const transitioning = server.phase === "Starting" || server.phase === "Stopping";
return (
<Card className="flex flex-col transition-colors hover:border-primary/40">
<CardHeader className="flex-row items-start justify-between space-y-0">
<div className="min-w-0">
<div className="truncate font-semibold">
{server.displayName || server.name}
</div>
<a
href={`https://${host}`}
target="_blank"
rel="noreferrer"
className="mt-0.5 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{host}
<ExternalLink className="h-3 w-3" />
</a>
</div>
<PhaseBadge phase={server.phase} />
</CardHeader>
<CardContent className="flex-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
{running ? (
<span>
{server.players ?? 0}
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online
</span>
) : (
<span>—</span>
)}
</div>
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
</CardContent>
<CardFooter className="flex flex-wrap gap-2">
{server.owned && (
<Link to={`/servers/${server.name}`}>
<Button variant="ghost" size="sm">
<Terminal /> Console
</Button>
</Link>
)}
{server.claimable && !server.owned && (
<Button
size="sm"
variant="outline"
disabled={busy !== null}
onClick={() => act("claim", () => api.claim(server.name))}
>
<Hand /> {busy === "claim" ? "Claiming…" : "Claim"}
</Button>
)}
{server.owned &&
(running || transitioning ? (
<Button
size="sm"
variant="outline"
disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))}
>
<Square /> {busy === "stop" ? "Stopping…" : "Stop"}
</Button>
) : (
<Button
size="sm"
disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))}
>
<Play /> {busy === "wake" ? "Waking…" : "Wake"}
</Button>
))}
</CardFooter>
</Card>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react";
import { Link } from "react-router-dom";
import { humanizeError } from "@/lib/api";
export function Loading({ label = "Loading…" }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{label}
</div>
);
}
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<AlertTriangle className="h-6 w-6 text-destructive" />
<p className="text-sm text-muted-foreground">{humanizeError(error)}</p>
{onRetry && (
<button
onClick={onRetry}
className="text-sm font-medium text-primary hover:underline"
>
Try again
</button>
)}
</div>
);
}
export function EmptyState({
title,
hint,
children,
}: {
title: string;
hint?: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-16 text-center">
<Inbox className="h-7 w-7 text-muted-foreground" />
<div>
<p className="font-medium">{title}</p>
{hint && <p className="mt-1 text-sm text-muted-foreground">{hint}</p>}
</div>
{children}
</div>
);
}
export function NotAuthorized() {
return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium">Not authorized</p>
<p className="mt-1 text-sm text-muted-foreground">
This area is for platform administrators. If you reached it by typing the
URL, your account does not have admin access — and the API would refuse
the request anyway.
</p>
</div>
<Link to="/" className="text-sm font-medium text-primary hover:underline">
Back to your dashboard
</Link>
</div>
);
}
/**
* PendingBackend is an HONEST placeholder for a surface whose backend read does
* not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend
* source is an honest placeholder, never a mock chart presented as live"). It
* names the missing endpoint so the gap is visible, not papered over with fake
* data.
*/
export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-16 text-center">
<Construction className="h-7 w-7 text-muted-foreground" />
<div>
<p className="font-medium">Not wired yet</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">{note}</p>
<p className="mt-2 font-mono text-xs text-muted-foreground/80">
pending backend: {endpoint}
</p>
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { phaseColor } from "@/components/PhaseBadge";
import type { ServerInfo } from "@/lib/types";
// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by
// lifecycle phase — the 3D view of the same state machine the list shows (spec
// §22 phases). Design constraints (panel/DESIGN.md §"where 3D earns its place"):
// • exactly ONE WebGLRenderer for the whole app surface;
// • the render loop is paused while the tab is hidden (no background GPU burn);
// • everything is disposed on unmount (no context leak on route changes).
// Raw three.js (not react-three-fiber) keeps the dependency/build surface minimal.
interface Props {
servers: ServerInfo[];
}
const STARTING = "Starting";
export function VoxelFleet({ servers }: Props) {
const mountRef = useRef<HTMLDivElement>(null);
// Latest servers without re-running the heavy setup effect on every poll.
const serversRef = useRef(servers);
serversRef.current = servers;
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(6, 5.5, 9);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
mount.appendChild(renderer.domElement);
renderer.domElement.style.display = "block";
scene.add(new THREE.AmbientLight(0xffffff, 0.55));
const key = new THREE.DirectionalLight(0xffffff, 1.1);
key.position.set(5, 8, 6);
scene.add(key);
const fill = new THREE.DirectionalLight(0x6699ff, 0.35);
fill.position.set(-6, 2, -4);
scene.add(fill);
const group = new THREE.Group();
scene.add(group);
const geometry = new THREE.BoxGeometry(0.9, 0.9, 0.9);
// Tracks the live meshes so we can dispose materials and rebuild on change.
let voxels: { mesh: THREE.Mesh; pulsing: boolean; base: number }[] = [];
function clearVoxels() {
for (const v of voxels) {
group.remove(v.mesh);
(v.mesh.material as THREE.Material).dispose();
}
voxels = [];
}
function build(list: ServerInfo[]) {
clearVoxels();
const n = Math.max(list.length, 1);
const cols = Math.ceil(Math.sqrt(n));
const rows = Math.ceil(n / cols);
const spacing = 1.25;
const offX = ((cols - 1) * spacing) / 2;
const offZ = ((rows - 1) * spacing) / 2;
list.forEach((s, i) => {
const color = new THREE.Color(phaseColor(s.phase));
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.45,
metalness: 0.1,
emissive: color.clone().multiplyScalar(s.phase === "Running" ? 0.25 : 0.05),
});
const mesh = new THREE.Mesh(geometry, material);
const c = i % cols;
const r = Math.floor(i / cols);
mesh.position.set(c * spacing - offX, 0, r * spacing - offZ);
const base = s.phase === STARTING ? 0.5 : 0.9;
mesh.scale.setScalar(base);
group.add(mesh);
voxels.push({ mesh, pulsing: s.phase === STARTING, base });
});
}
build(serversRef.current);
// Rebuild only when the *shape* (names + phases) of the fleet changes, polled
// cheaply against a signature so we avoid teardown churn on identical data.
let signature = sig(serversRef.current);
const rebuildTimer = window.setInterval(() => {
const next = sig(serversRef.current);
if (next !== signature) {
signature = next;
build(serversRef.current);
}
}, 1000);
function resize() {
const w = mount!.clientWidth || 1;
const h = mount!.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
resize();
const ro = new ResizeObserver(resize);
ro.observe(mount);
let raf = 0;
let t = 0;
let running = true;
const tick = () => {
if (!running) return;
raf = requestAnimationFrame(tick);
t += 0.016;
group.rotation.y = t * 0.18;
for (const v of voxels) {
if (v.pulsing) {
const s = v.base + Math.sin(t * 4) * 0.12 + 0.12;
v.mesh.scale.setScalar(s);
}
}
renderer.render(scene, camera);
};
tick();
// Pause the loop while the tab is hidden — no GPU work in the background.
const onVisibility = () => {
if (document.hidden) {
running = false;
cancelAnimationFrame(raf);
} else if (!running) {
running = true;
tick();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
running = false;
cancelAnimationFrame(raf);
window.clearInterval(rebuildTimer);
document.removeEventListener("visibilitychange", onVisibility);
ro.disconnect();
clearVoxels();
geometry.dispose();
renderer.dispose();
if (renderer.domElement.parentNode === mount) {
mount.removeChild(renderer.domElement);
}
};
}, []);
return <div ref={mountRef} className="h-full w-full" />;
}
/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent
* per index): name+phase pairs. Player counts don't change the voxels, so they
* don't trigger a rebuild. */
function sig(servers: ServerInfo[]): string {
return servers.map((s) => `${s.name}:${s.phase}`).join("|");
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
muted: "border-transparent bg-muted text-muted-foreground",
destructive: "border-transparent bg-destructive/15 text-destructive",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { badgeVariants };
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-6",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { buttonVariants };
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-5", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
export const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-5 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
+98
View File
@@ -0,0 +1,98 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-left", className)}
{...props}
/>
);
}
export function DialogFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
export const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
));
Input.displayName = "Input";
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
+80
View File
@@ -0,0 +1,80 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
+58
View File
@@ -0,0 +1,58 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 142 71% 45%;
--primary-foreground: 144 80% 10%;
--muted: 210 40% 96%;
--muted-foreground: 215 16% 47%;
--accent: 210 40% 96%;
--accent-foreground: 222 47% 11%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 142 71% 45%;
--radius: 0.6rem;
}
.dark {
--background: 224 47% 7%;
--foreground: 210 40% 96%;
--card: 222 40% 10%;
--card-foreground: 210 40% 96%;
--popover: 222 44% 9%;
--popover-foreground: 210 40% 96%;
--primary: 142 70% 45%;
--primary-foreground: 144 80% 8%;
--muted: 217 33% 16%;
--muted-foreground: 215 20% 65%;
--accent: 217 33% 18%;
--accent-foreground: 210 40% 96%;
--destructive: 0 63% 45%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 18%;
--input: 217 33% 20%;
--ring: 142 70% 45%;
}
* {
border-color: hsl(var(--border));
}
body {
margin: 0;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary
// (request() does `return parsed as T`), so nothing but a test pins the snake_case
// keys to the Go handler. If the handler's JSON ever drifts to camelCase — or the
// client starts transforming keys — `identity.is_admin` becomes undefined, which is
// falsy, and EVERY admin silently renders as a non-admin while typecheck/build stay
// green. This fixture is the canonical mirror of handlers_user.go handleMe.
vi.mock("./config", () => ({
loadConfig: async () => ({ apiBase: "", rootDomain: "example.test" }),
}));
// Imported after the mock so api.ts picks up the mocked loadConfig.
const { api } = await import("./api");
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
return vi.fn(async () => ({
ok: init?.ok ?? true,
status: init?.status ?? 200,
statusText: "OK",
text: async () => JSON.stringify(body),
})) as unknown as typeof fetch;
}
describe("api.me wire shape", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("surfaces snake_case is_admin from GET /me verbatim (admin)", async () => {
// EXACTLY the JSON handlers_user.go handleMe emits.
const body = { user_id: "u1", email: "[email protected]", role: "admin", is_admin: true };
vi.stubGlobal("fetch", fakeFetch(body));
const id = await api.me();
expect(id.is_admin).toBe(true);
expect(id.user_id).toBe("u1");
expect(id.email).toBe("[email protected]");
expect(id.role).toBe("admin");
});
it("reports is_admin:false for a non-admin identity", async () => {
const body = { user_id: "u2", email: "[email protected]", role: "user", is_admin: false };
vi.stubGlobal("fetch", fakeFetch(body));
const id = await api.me();
expect(id.is_admin).toBe(false);
expect(id.role).toBe("user");
});
it("calls GET on the /me path", async () => {
const fetchSpy = fakeFetch({
user_id: "u3",
email: "[email protected]",
role: "user",
is_admin: false,
});
vi.stubGlobal("fetch", fetchSpy);
await api.me();
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock
.calls[0];
expect(String(url)).toBe("/me");
expect((opts as RequestInit).method).toBe("GET");
expect((opts as RequestInit).credentials).toBe("include");
});
});
+129
View File
@@ -0,0 +1,129 @@
import type {
ApiError,
CreateServerRequest,
Identity,
LinkResult,
LinkStatus,
ServerInfo,
WhitelistImage,
} from "./types";
import { loadConfig } from "./config";
// Typed client for the felis-api external face (spec §7). Credentials are sent so
// the upstream Zero-Trust / Access cookie rides along; the panel never holds a
// service token, and the RCON password is never requested (spec §8).
function isApiError(x: unknown): x is { error: { code: string; message: string } } {
return (
typeof x === "object" &&
x !== null &&
"error" in x &&
typeof (x as { error: unknown }).error === "object"
);
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const { apiBase } = await loadConfig();
const res = await fetch(`${apiBase}${path}`, {
method,
credentials: "include",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const parsed: unknown = text ? JSON.parse(text) : null;
if (!res.ok) {
const err: ApiError = {
status: res.status,
code: isApiError(parsed) ? parsed.error.code : "error",
message: isApiError(parsed) ? parsed.error.message : res.statusText,
};
throw err;
}
return parsed as T;
}
export const api = {
// Identity (spec §7 GET /me) — the tier keystone. is_admin is server-computed
// (Principal.IsAdmin); the panel reads it but re-deriving admin-ness is the
// backend's job. Drives nav + route guards only; every admin route 403s on its
// own regardless of what the panel renders.
me: () => request<Identity>("GET", "/me"),
myServers: () =>
request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
status: (name: string) => request<ServerInfo>("GET", `/servers/${name}/status`),
wake: (name: string) =>
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`),
stop: (name: string) =>
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/stop`),
claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`),
listImages: () =>
request<{ images: WhitelistImage[] }>("GET", "/images").then((r) => r.images ?? []),
createServer: (req: CreateServerRequest) =>
request<{ name: string; subdomain: string; desiredState: string }>(
"POST",
"/servers",
req,
),
// Account linking (spec §10). Both are POST: start reports status from the
// session principal (no body, side-effect-free), verify consumes a code the
// player was shown in-game. The panel can never mint a code — that is the
// internal in-game face — so there is no client method for it.
linkStatus: () => request<LinkStatus>("POST", "/account/link/start"),
linkVerify: (code: string) =>
request<LinkResult>("POST", "/account/link/verify", { code }),
};
/**
* consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors
* request()'s `${apiBase}${path}` join, but is a plain string builder rather than
* a fetch: an EventSource is *constructed* from a URL (and carries the Access
* cookie via withCredentials), it is not requested through this module. The name
* is percent-encoded defensively — server names are validated `[a-z0-9-]`
* upstream, but the URL is built from a router param, so encoding keeps a stray
* value from breaking the URL.
*/
export function consoleStreamURL(apiBase: string, name: string): string {
return `${apiBase}/servers/${encodeURIComponent(name)}/console`;
}
/** humanizeError turns the stable error code into a user-facing line. */
export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>;
switch (err.code) {
case "not_linked":
return "Link your Minecraft account before claiming (Account → Link).";
case "invalid_code":
return "That link code is invalid or expired — run /link again in-game for a fresh code.";
case "already_linked":
return "That Minecraft account is already linked to another user.";
case "quota_exceeded":
return "You have reached your server quota.";
case "already_claimed":
return "Someone else just claimed this server.";
case "image_not_whitelisted":
return "That image is not on the whitelist.";
case "subdomain_taken":
return "That subdomain is already in use.";
case "already_exists":
return "A server with that name already exists.";
case "cooldown":
return "Wake is cooling down — try again shortly.";
default:
if (err.status === 401) return "Your session expired — please sign in again.";
if (err.status === 403) return "You are not allowed to do that.";
return err.message ?? "Something went wrong.";
}
}
+40
View File
@@ -0,0 +1,40 @@
// Runtime config: the panel is domain-agnostic at BUILD time (CI red line — no
// rootDomain literal may appear in source). apiBase + rootDomain are fetched from
// /config.json at boot, so one build serves any deployment (mirrors the Go side's
// --set root_domain portability, spec §16/§27). A dev fallback reads VITE_* env.
export interface RuntimeConfig {
apiBase: string;
rootDomain: string;
}
const FALLBACK: RuntimeConfig = {
apiBase: import.meta.env.VITE_API_BASE ?? "/api/v1",
rootDomain: import.meta.env.VITE_ROOT_DOMAIN ?? "localhost",
};
let cached: RuntimeConfig | null = null;
/** loadConfig fetches /config.json once and caches it; failures fall back to env
* so the SPA still mounts (and surfaces the misconfig in the UI). */
export async function loadConfig(): Promise<RuntimeConfig> {
if (cached) return cached;
try {
const res = await fetch("/config.json", { cache: "no-store" });
if (!res.ok) throw new Error(`config.json ${res.status}`);
const raw = (await res.json()) as Partial<RuntimeConfig>;
cached = {
apiBase: raw.apiBase ?? FALLBACK.apiBase,
rootDomain: raw.rootDomain ?? FALLBACK.rootDomain,
};
} catch {
cached = FALLBACK;
}
return cached;
}
/** hostFor composes a server's public hostname from its subdomain and the runtime
* rootDomain — never a hardcoded domain (red line). */
export function hostFor(subdomain: string, cfg: RuntimeConfig): string {
return `${subdomain}.${cfg.rootDomain}`;
}
+67
View File
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { loadConfig, type RuntimeConfig } from "./config";
/** useConfig resolves the runtime config (apiBase + rootDomain) once on mount.
* Returns null until loaded; loadConfig itself never rejects (it falls back). */
export function useConfig(): RuntimeConfig | null {
const [cfg, setCfg] = useState<RuntimeConfig | null>(null);
useEffect(() => {
let alive = true;
loadConfig().then((c) => {
if (alive) setCfg(c);
});
return () => {
alive = false;
};
}, []);
return cfg;
}
export interface AsyncState<T> {
data: T | null;
error: unknown;
loading: boolean;
/** Re-run the async fn (e.g. after a mutation). */
reload: () => void;
}
/** useAsync runs an async producer on mount and on demand, guarding against
* setState-after-unmount and out-of-order responses. */
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncState<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<unknown>(null);
const [loading, setLoading] = useState(true);
const seq = useRef(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
const run = useCallback(fn, deps);
const reload = useCallback(() => {
const ticket = ++seq.current;
setLoading(true);
setError(null);
run().then(
(d) => {
if (ticket === seq.current) {
setData(d);
setLoading(false);
}
},
(e) => {
if (ticket === seq.current) {
setError(e);
setLoading(false);
}
},
);
}, [run]);
useEffect(() => {
reload();
return () => {
seq.current++;
};
}, [reload]);
return { data, error, loading, reload };
}
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect } from "vitest";
import {
LogStreamController,
classifyLogLine,
type EventSourceFactory,
type EventSourceLike,
} from "./logstream";
// FakeEventSource drives the controller without a browser: Node has no
// EventSource, and even in jsdom it would dial a real socket. The controller
// takes its EventSource via a factory precisely so this fake can model the
// lifecycle — open, message, transient drop (reconnecting), fatal close (ended),
// and teardown — deterministically. It matches EventSourceLike structurally.
class FakeEventSource implements EventSourceLike {
onopen: ((ev: unknown) => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: ((ev: unknown) => void) | null = null;
readyState = 0; // CONNECTING
closed = false;
readonly url: string;
constructor(url: string) {
this.url = url;
}
close(): void {
this.closed = true;
this.readyState = 2; // CLOSED
}
// Test drivers mirroring what the browser would invoke.
emitOpen(): void {
this.readyState = 1; // OPEN
this.onopen?.({});
}
emitMessage(data: string): void {
this.onmessage?.({ data });
}
emitError(readyState: number): void {
this.readyState = readyState;
this.onerror?.({});
}
}
function makeFactory(): { factory: EventSourceFactory; created: FakeEventSource[] } {
const created: FakeEventSource[] = [];
const factory: EventSourceFactory = (url) => {
const es = new FakeEventSource(url);
created.push(es);
return es;
};
return { factory, created };
}
describe("classifyLogLine", () => {
const cases: Array<[string, ReturnType<typeof classifyLogLine>]> = [
["[12:30:01] [Server thread/INFO]: Done (5.123s)!", "info"],
["[12:30:02] [Server thread/WARN]: Can't keep up!", "warn"],
["[12:30:03] [Server thread/ERROR]: Encountered an exception", "error"],
["[12:30:04] [Server thread/SEVERE]: fatal boot failure", "error"],
["[12:30:05] [Worker/FATAL]: out of memory", "error"],
["[12:30:06] [Server thread/DEBUG]: tick took 1ms", "debug"],
["[12:30:07] [Server thread/TRACE]: packet in", "debug"],
["[12:30:08] [Server thread/WARNING]: deprecated config", "warn"],
["[INFO] bracketed level form", "info"],
[" at net.minecraft.server.Foo.bar(Foo.java:42)", "plain"],
["<Steve> the build had an ERROR earlier lol", "plain"], // chat must not tint
["plain server line with no marker", "plain"],
];
for (const [line, want] of cases) {
it(`classifies ${JSON.stringify(line)} as ${want}`, () => {
expect(classifyLogLine(line)).toBe(want);
});
}
});
describe("LogStreamController", () => {
it("transitions connecting -> open and appends classified lines", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/api/v1/servers/s/console", factory });
ctrl.open();
expect(created).toHaveLength(1);
expect(created[0].url).toBe("/api/v1/servers/s/console");
expect(ctrl.getSnapshot().status).toBe("connecting");
created[0].emitOpen();
expect(ctrl.getSnapshot().status).toBe("open");
created[0].emitMessage("[12:00:00] [Server thread/WARN]: heads up");
const { lines } = ctrl.getSnapshot();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({ seq: 0, level: "warn" });
expect(lines[0].text).toContain("heads up");
});
it("bounds the ring buffer, dropping the oldest lines", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory, maxLines: 2 });
ctrl.open();
created[0].emitMessage("line0");
created[0].emitMessage("line1");
created[0].emitMessage("line2");
const { lines } = ctrl.getSnapshot();
expect(lines).toHaveLength(2);
// Oldest dropped; the survivors keep their monotonic seq (1 then 2).
expect(lines.map((l) => l.text)).toEqual(["line1", "line2"]);
expect(lines.map((l) => l.seq)).toEqual([1, 2]);
});
it("returns a stable snapshot reference between mutations", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
ctrl.open(); // status already 'connecting' -> no commit, snapshot stable
const s1 = ctrl.getSnapshot();
expect(ctrl.getSnapshot()).toBe(s1);
created[0].emitMessage("x");
expect(ctrl.getSnapshot()).not.toBe(s1);
});
it("notifies subscribers on mutation and stops after unsubscribe", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
ctrl.open();
let hits = 0;
const unsub = ctrl.subscribe(() => {
hits++;
});
created[0].emitOpen(); // commit
created[0].emitMessage("a"); // commit
expect(hits).toBe(2);
unsub();
created[0].emitMessage("b");
expect(hits).toBe(2); // no further notifications
});
it("maps a transient drop to reconnecting and a fatal close to ended", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
ctrl.open();
created[0].emitOpen();
created[0].emitError(0); // readyState CONNECTING -> auto-retrying
expect(ctrl.getSnapshot().status).toBe("reconnecting");
created[0].emitError(2); // readyState CLOSED -> fatal (e.g. 409 not_running)
expect(ctrl.getSnapshot().status).toBe("ended");
});
it("ignores events after close (teardown linchpin)", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
ctrl.open();
created[0].emitOpen();
ctrl.close();
expect(created[0].closed).toBe(true);
// A late event from the now-detached source must not mutate state.
created[0].emitMessage("ghost");
created[0].emitError(0);
expect(ctrl.getSnapshot().lines).toHaveLength(0);
expect(ctrl.getSnapshot().status).toBe("open");
});
it("reconnect closes the old stream and dials a fresh one", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
ctrl.open();
created[0].emitError(2); // ended
ctrl.reconnect();
expect(created).toHaveLength(2);
expect(created[0].closed).toBe(true);
expect(ctrl.getSnapshot().status).toBe("connecting");
created[1].emitOpen();
expect(ctrl.getSnapshot().status).toBe("open");
});
it("clear empties the buffer without disturbing status", () => {
const { factory, created } = makeFactory();
const ctrl = new LogStreamController({ url: "/c", factory });
ctrl.open();
created[0].emitOpen();
created[0].emitMessage("a");
ctrl.clear();
expect(ctrl.getSnapshot().lines).toHaveLength(0);
expect(ctrl.getSnapshot().status).toBe("open");
});
});
+221
View File
@@ -0,0 +1,221 @@
// Read-side console core (spec §8 读=pods/log follow). The felis-api endpoint
// `GET /servers/{name}/console` relays the running pod's log as Server-Sent
// Events — one `data: <line>` per log line — authenticated by the same
// Cloudflare Access cookie the rest of api.ts rides on (credentials:"include").
// A native browser EventSource with { withCredentials: true } therefore attaches
// directly; no Authorization header is needed (EventSource cannot set one).
//
// This module is deliberately framework-agnostic: the EventSource is injected
// (see EventSourceFactory) so the lifecycle — status transitions, the bounded
// ring buffer, reconnect, teardown — is unit-tested under Node against a fake,
// not left to a "tsc + build" green that only proves it compiles. The thin React
// binding lives in useLogStream.ts; the viewport in components/LogConsole.tsx.
/** LogLevel is the coarse severity used only for tinting a line. */
export type LogLevel = "error" | "warn" | "info" | "debug" | "plain";
/**
* StreamStatus models the EventSource lifecycle as the UI cares about it:
* - connecting: the initial dial (or a manual reconnect) is in flight
* - open: the stream is live and delivering lines
* - reconnecting: a transient drop; native EventSource is auto-retrying
* (readyState returned to CONNECTING after onerror)
* - ended: a fatal close — a non-200 response (e.g. the 409 a stopped
* server returns, or wrong content-type) makes EventSource go
* CLOSED with no auto-retry; the user may reconnect manually
*/
export type StreamStatus = "connecting" | "open" | "reconnecting" | "ended";
/** A single rendered log line. seq is a monotonic id for stable React keys. */
export interface LogLine {
seq: number;
text: string;
level: LogLevel;
}
/** Immutable snapshot consumed by useSyncExternalStore. */
export interface LogStreamSnapshot {
lines: LogLine[];
status: StreamStatus;
}
// EventSource readyState constants. We avoid referencing the DOM EventSource
// global here so the module loads under Node (tests) where it is undefined.
const ES_CLOSED = 2;
/**
* EventSourceLike is the minimal slice of the DOM EventSource the controller
* depends on, so a fake can drive it in tests. The handler params are `any`
* rather than a precise event type on purpose: under strictFunctionTypes a real
* browser EventSource (handlers typed `(ev: Event)` / `(ev: MessageEvent)`) is
* only assignable to this shim if the shim's params are at least as wide as the
* DOM's — `any` is bivariant, so a browser EventSource satisfies it structurally
* while a stricter test fake (e.g. `(ev: { data: string })`) still implements
* it. We never inspect the event beyond `.data`, so the looseness costs nothing.
*/
export interface EventSourceLike {
onopen: ((ev: any) => void) | null;
onmessage: ((ev: any) => void) | null;
onerror: ((ev: any) => void) | null;
readyState: number;
close(): void;
}
/** Builds an EventSource for a URL (production: the browser ctor; tests: a fake). */
export type EventSourceFactory = (url: string) => EventSourceLike;
export interface LogStreamOptions {
url: string;
factory: EventSourceFactory;
/** Ring-buffer bound; older lines are dropped past this (default 2000). */
maxLines?: number;
}
const DEFAULT_MAX_LINES = 2000;
/**
* classifyLogLine maps a Minecraft log line to a coarse level for tinting.
*
* Minecraft and its proxies/loaders (vanilla, Paper, Fabric, Forge, NeoForge,
* Velocity) all log via log4j2, whose default pattern carries the level as a
* `thread/LEVEL]` marker, e.g.
*
* [12:30:01] [Server thread/INFO]: Done (5.123s)! For help, type "help"
* [12:30:02] [Server thread/WARN]: Can't keep up! Is the server overloaded?
* [12:30:03] [Server thread/ERROR]: Encountered an unexpected exception
*
* We match that marker (and the bracketed `[LEVEL]` variant) rather than scanning
* the whole line, so a chat message that merely mentions "error" is not tinted
* red. Anything unrecognized — stack-trace continuations, plain output — is
* "plain" and renders in the default colour.
*/
export function classifyLogLine(line: string): LogLevel {
const m = line.match(/[/[](SEVERE|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\]/);
if (!m) return "plain";
switch (m[1]) {
case "SEVERE":
case "FATAL":
case "ERROR":
return "error";
case "WARNING":
case "WARN":
return "warn";
case "INFO":
return "info";
default:
return "debug"; // DEBUG, TRACE
}
}
/**
* LogStreamController owns one EventSource and exposes a useSyncExternalStore
* surface (subscribe + cached getSnapshot). It is the single place that handles:
*
* - status transitions driven by EventSource events,
* - the bounded ring buffer (memory stays capped under a long live follow),
* - reconnect (manual, after a fatal `ended`),
* - teardown (close()) — the linchpin that the React effect's cleanup calls so
* a StrictMode dev double-mount, or a route change, never leaks a stream.
*/
export class LogStreamController {
private es: EventSourceLike | null = null;
private lines: LogLine[] = [];
private status: StreamStatus = "connecting";
private seq = 0;
private readonly url: string;
private readonly factory: EventSourceFactory;
private readonly maxLines: number;
private readonly listeners = new Set<() => void>();
// snapshot is rebuilt only inside commit(), so getSnapshot returns a stable
// reference between mutations — required by useSyncExternalStore to avoid an
// infinite render loop.
private snapshot: LogStreamSnapshot;
constructor(opts: LogStreamOptions) {
this.url = opts.url;
this.factory = opts.factory;
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
this.snapshot = { lines: this.lines, status: this.status };
}
subscribe = (fn: () => void): (() => void) => {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
};
getSnapshot = (): LogStreamSnapshot => this.snapshot;
/** open dials the stream. Call once after construction (the hook's effect). */
open(): void {
this.connect();
}
/** reconnect closes any current stream and dials again (manual retry). */
reconnect(): void {
this.disconnect();
this.connect();
}
/** clear empties the buffer without touching the stream. */
clear(): void {
this.lines = [];
this.commit();
}
/** close tears the stream down for good. Idempotent. */
close(): void {
this.disconnect();
}
private connect(): void {
this.setStatus("connecting");
const es = this.factory(this.url);
this.es = es;
es.onopen = () => this.setStatus("open");
es.onmessage = (ev) => this.pushLine(ev.data);
es.onerror = () => {
// Native EventSource auto-reconnects on a transient drop (readyState
// returns to CONNECTING); only a fatal response — non-200 / wrong
// content-type, e.g. the 409 a stopped server returns — goes CLOSED, with
// no further retry. Distinguish so the UI shows "reconnecting" vs "ended".
if (this.es && this.es.readyState === ES_CLOSED) {
this.setStatus("ended");
} else {
this.setStatus("reconnecting");
}
};
}
private disconnect(): void {
if (this.es) {
// Detach handlers first so a close()-induced onerror can't flip status.
this.es.onopen = null;
this.es.onmessage = null;
this.es.onerror = null;
this.es.close();
this.es = null;
}
}
private pushLine(text: string): void {
const next = this.lines.concat({ seq: this.seq++, text, level: classifyLogLine(text) });
if (next.length > this.maxLines) {
next.splice(0, next.length - this.maxLines);
}
this.lines = next;
this.commit();
}
private setStatus(s: StreamStatus): void {
if (this.status === s) return;
this.status = s;
this.commit();
}
private commit(): void {
this.snapshot = { lines: this.lines, status: this.status };
this.listeners.forEach((fn) => fn());
}
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { NAV_SECTIONS, visibleSections } from "./nav";
// visibleSections is the load-bearing tier decision: get it wrong and either an
// admin loses their tools or a non-admin sees admin nav. These cases pin the
// fail-closed default (loading/error → false → User-Side only) and the full-admin
// expansion, and guard the invariant that User-Side is never admin-gated.
describe("visibleSections", () => {
it("shows only the User-Side section to a non-admin", () => {
const ids = visibleSections(false).map((s) => s.id);
expect(ids).toEqual(["user"]);
});
it("treats the fail-closed default (false) exactly like a non-admin", () => {
// TierProvider passes `false` while /me is loading or after it rejects. That
// path MUST collapse to User-Side only, never leak Admin/Ops nav.
expect(visibleSections(false)).toHaveLength(1);
expect(visibleSections(false)[0].id).toBe("user");
});
it("shows User-Side, Admin-Side and SysAdmin-Side to an admin", () => {
const ids = visibleSections(true).map((s) => s.id);
expect(ids).toEqual(["user", "admin", "ops"]);
});
it("keeps the User-Side section ungated so it survives both branches", () => {
const user = NAV_SECTIONS.find((s) => s.id === "user");
expect(user?.adminOnly).toBe(false);
expect(visibleSections(true)).toContainEqual(user);
expect(visibleSections(false)).toContainEqual(user);
});
it("gates every non-user section behind admin", () => {
for (const s of NAV_SECTIONS) {
if (s.id !== "user") expect(s.adminOnly).toBe(true);
}
});
});
+81
View File
@@ -0,0 +1,81 @@
import {
LayoutDashboard,
Server,
UserRound,
ShieldCheck,
ServerCog,
Boxes,
Gauge,
Network,
type LucideIcon,
} from "lucide-react";
// The shell navigation model, kept as plain data so the "which sections does this
// identity see" decision is a pure function (visibleSections) that vitest can pin
// without rendering React. The three sections map onto DESIGN-WEB-3SIDES §2:
// User-Side is always present (app-tier); Admin-Side and SysAdmin-Side are a
// navigational separation of *concern* over the SAME admin tier — both gated by
// the one `is_admin` flag, surfaced as two sections only for admins.
//
// Hiding a section is UX convenience, NOT a security control: every /admin and
// /ops data call is independently 403-gated server-side (the RequireAdmin route
// wrapper is belt-and-suspenders on top of that).
export interface NavItem {
to: string;
label: string;
icon: LucideIcon;
/** react-router NavLink `end` — exact-match active styling for index routes. */
end?: boolean;
}
export interface NavSection {
id: "user" | "admin" | "ops";
/** Section heading shown in the sidebar; null renders no heading (User-Side). */
title: string | null;
/** When true the section is shown only to admins (is_admin === true). */
adminOnly: boolean;
items: NavItem[];
}
export const NAV_SECTIONS: NavSection[] = [
{
id: "user",
title: null,
adminOnly: false,
items: [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/servers", label: "My servers", icon: Server },
{ to: "/account", label: "Account", icon: UserRound },
],
},
{
id: "admin",
title: "Admin",
adminOnly: true,
items: [
{ to: "/admin", label: "Overview", icon: ShieldCheck, end: true },
{ to: "/admin/servers", label: "Servers", icon: ServerCog },
{ to: "/admin/images", label: "Images", icon: Boxes },
],
},
{
id: "ops",
title: "SysAdmin",
adminOnly: true,
items: [
{ to: "/ops", label: "Overview", icon: Gauge, end: true },
{ to: "/ops/fleet", label: "Fleet", icon: Network },
],
},
];
/**
* visibleSections returns the sections a caller with the given admin flag may see.
* Pure and total: a non-admin (or the fail-closed `false` used while /me is still
* loading or after it errors) gets exactly the User-Side section; an admin gets all
* three. This is the single decision the sidebar renders from.
*/
export function visibleSections(isAdmin: boolean): NavSection[] {
return NAV_SECTIONS.filter((s) => !s.adminOnly || isAdmin);
}
+77
View File
@@ -0,0 +1,77 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { Identity } from "./types";
import { api } from "./api";
// TierProvider fetches GET /me exactly once at boot and exposes the result through
// context. Two design rules, both load-bearing:
//
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
// flight (`identity === null`) or after it rejects, isAdmin is false — admin
// nav/routes stay hidden. The `=== true` (not just truthy) also closes the
// wire-shape trap: if the JSON ever arrives without `is_admin`, the value is
// `undefined`, which is correctly non-admin rather than a thrown access.
//
// 2. Still-functional on failure: a /me rejection is caught and swallowed to a
// null identity, NOT re-thrown. A logged-in user whose /me momentarily fails
// still gets the full User-Side app; they simply don't see admin surfaces.
// (The backend 403s admin data calls independently, so this is safe.)
//
// This is UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
export interface TierState {
/** The caller's identity, or null while loading or after a failed /me. */
identity: Identity | null;
/** True only while the initial /me request is in flight. */
loading: boolean;
/** Server-computed admin flag; false while loading or on failure (fail-closed). */
isAdmin: boolean;
}
const TierContext = createContext<TierState>({
identity: null,
loading: true,
isAdmin: false,
});
export function TierProvider({ children }: { children: ReactNode }) {
const [identity, setIdentity] = useState<Identity | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
api
.me()
.then((id) => {
if (alive) setIdentity(id);
})
.catch(() => {
// Fail-closed but functional: stay non-admin, keep rendering User-Side.
if (alive) setIdentity(null);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
const isAdmin = identity?.is_admin === true;
return (
<TierContext.Provider value={{ identity, loading, isAdmin }}>
{children}
</TierContext.Provider>
);
}
/** useTier reads the boot-time identity/tier state. */
export function useTier(): TierState {
return useContext(TierContext);
}
+100
View File
@@ -0,0 +1,100 @@
// Mirror of the felis-api external-face JSON (spec §7). Kept intentionally narrow:
// only the fields the panel renders, so the UI never depends on internal shapes.
// The full lifecycle set felis-api can emit (v1alpha1 MinecraftServerPhase, passed
// through verbatim by the status projection). "Stopping" is the transient teardown
// state between Running and Stopped; the panel must model it or a stopping server
// renders with no variant/color. Consumers that index a phase→x map MUST still
// fall back for any value outside this union — `phase` crosses an unvalidated JSON
// boundary, so the type is a documentation of intent, not a runtime guarantee.
export type Phase =
| "Stopped"
| "Starting"
| "Running"
| "Stopping"
| "Failed"
| "Unknown";
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** ServerInfo is the projection returned by GET /servers/{name}/status and
* GET /me/servers (the latter wraps a list under { servers: [...] }). */
export interface ServerInfo {
name: string;
subdomain: string;
displayName?: string;
phase: Phase;
desiredState?: "Running" | "Stopped";
players?: number;
maxPlayers?: number;
autostartPolicy?: AutostartPolicy;
/** Whether the caller may claim this server (unowned + linked + quota). */
claimable?: boolean;
/** Whether the caller owns it. */
owned?: boolean;
}
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
export interface WhitelistImage {
image_ref: string;
enabled: boolean;
source?: string;
}
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
export interface CreateServerRequest {
name: string;
subdomain: string;
displayName?: string;
image: string;
memory: string;
storage: string;
autostartPolicy?: AutostartPolicy;
resources?: {
cpu?: string;
cpuRequest?: string;
memory?: string;
memoryRequest?: string;
};
}
/** LinkStatus projects POST /account/link/start (spec §10): whether the caller's
* session is already bound to a Minecraft identity. The endpoint also returns an
* API-consumer `instructions` string; the panel renders its own player-facing copy
* (the raw string names the REST endpoint), so it is intentionally not modelled. */
export interface LinkStatus {
linked: boolean;
}
/** LinkResult projects a successful POST /account/link/verify (spec §10): the
* account_links binding is written and the now-linked Minecraft UUID is echoed
* back (the only place the panel learns the UUID — start never returns it). */
export interface LinkResult {
linked: boolean;
mc_uuid: string;
}
/** ApiError is the stable error envelope ({ error: { code, message } }). */
export interface ApiError {
status: number;
code: string;
message: string;
}
/** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may
* read *their own* identity). The keys are snake_case because they mirror the Go
* handler's JSON map verbatim (handlers_user.go handleMe → {user_id, email, role,
* is_admin}); a camelCase rename here would silently read `undefined` across the
* untyped fetch().json() boundary and make every admin look like a non-admin.
*
* `is_admin` is the ONLY field the tier model trusts: it is computed server-side as
* Principal.IsAdmin() (Role=="admin" AND ViaAdminAccess — the graded-ZT rule), so
* the panel never re-derives admin-ness from `role` alone. `role` is carried for
* display only and, like `phase`, is documentation of intent, not a runtime
* guarantee — it crosses an unvalidated JSON boundary. */
export interface Identity {
user_id: string;
email: string;
role: "user" | "admin";
is_admin: boolean;
}
+53
View File
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import {
LogStreamController,
type EventSourceFactory,
type StreamStatus,
type LogLine,
} from "./logstream";
// browserEventSource is the production factory: a native EventSource with
// credentials, so the Cloudflare Access cookie rides along exactly as api.ts's
// fetch(credentials:"include") does. EventSource cannot set an Authorization
// header, so cookie auth is what makes a direct SSE attach viable (spec §8).
const browserEventSource: EventSourceFactory = (url) =>
new EventSource(url, { withCredentials: true });
export interface UseLogStream {
lines: LogLine[];
status: StreamStatus;
clear: () => void;
reconnect: () => void;
}
/**
* useLogStream binds a LogStreamController to React. The controller is the store
* (useSyncExternalStore); this hook only manages its lifecycle:
*
* - One controller per url. A ref keyed on url keeps a single stable instance
* across re-renders (incl. StrictMode's double render) so buffered lines
* survive; it is recreated only when url actually changes.
* - The effect calls open() on mount and close() on cleanup. That cleanup is
* the teardown linchpin: under StrictMode's dev mount→unmount→remount, the
* first EventSource is closed before the second opens, so no stream leaks and
* lines are never doubled.
*/
export function useLogStream(url: string): UseLogStream {
const ref = useRef<{ url: string; ctrl: LogStreamController } | null>(null);
if (ref.current === null || ref.current.url !== url) {
ref.current = { url, ctrl: new LogStreamController({ url, factory: browserEventSource }) };
}
const controller = ref.current.ctrl;
useEffect(() => {
controller.open();
return () => controller.close();
}, [controller]);
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
const clear = useCallback(() => controller.clear(), [controller]);
const reconnect = useCallback(() => controller.reconnect(), [controller]);
return { lines: snapshot.lines, status: snapshot.status, clear, reconnect };
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** cn merges conditional class names and resolves Tailwind conflicts. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+10
View File
@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+196
View File
@@ -0,0 +1,196 @@
import { useState, type FormEvent } from "react";
import { CheckCircle2, Link2, ShieldCheck } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
// The Account page is the web half of the §10 link flow. A code is born in-game
// (online-mode auth proves the UUID) and consumed here (the session proves the
// user) — so this page only ever reports status and redeems a code; it can never
// originate a binding. The Minecraft-link card is a small state machine: checking
// → linked, or → the two-step "get a code in-game, enter it here" form.
export function Account() {
const status = useAsync(() => api.linkStatus(), []);
// Verify is a mutation, not a read, so it is hand-rolled (the useAsync producer
// is for the status read). On success we flip linked locally and keep the echoed
// UUID — the start endpoint never returns it, so this is the only confirmation
// of *which* account bound, shown without a refetch.
const [code, setCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [verifiedUUID, setVerifiedUUID] = useState<string | null>(null);
const linked = verifiedUUID !== null || status.data?.linked === true;
async function submit(e: FormEvent) {
e.preventDefault();
const trimmed = code.trim().toUpperCase();
if (!trimmed || submitting) return;
setSubmitting(true);
setError(null);
try {
const res = await api.linkVerify(trimmed);
setVerifiedUUID(res.mc_uuid);
setCode("");
} catch (err) {
setError(humanizeError(err));
} finally {
setSubmitting(false);
}
}
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Account</h1>
<p className="text-sm text-muted-foreground">
Identity and Minecraft linking.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4 text-primary" /> Session
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
You reached this panel through the platform's identity proxy (Zero-Trust /
Access). The panel itself holds no credentials — every request rides your
existing session cookie (spec §7/§8).
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-primary" /> Minecraft link
</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{linked ? (
<LinkedState uuid={verifiedUUID} />
) : status.loading && !status.data ? (
<Loading label="Checking link status…" />
) : status.error ? (
<ErrorState error={status.error} onRetry={status.reload} />
) : (
<LinkForm
code={code}
setCode={setCode}
submitting={submitting}
error={error}
onSubmit={submit}
/>
)}
</CardContent>
</Card>
</div>
);
}
/** LinkedState confirms the binding. The UUID is shown only when this session
* just verified it (start does not return it), so a pre-existing link renders
* the confirmation without a UUID rather than inventing one. */
function LinkedState({ uuid }: { uuid: string | null }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
Your Minecraft account is linked.
</div>
<p className="text-muted-foreground">
Linked sessions can claim and own servers — ownership operations (claim,
wake, stop) are unblocked.
</p>
{uuid && (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs uppercase tracking-wide">UUID</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{uuid}
</code>
</div>
)}
</div>
);
}
/** LinkForm is the two-step redemption UX: get a code in-game, then enter it. The
* input auto-uppercases for instant feedback; the server also trims + uppercases,
* so this is cosmetic, not the source of truth. */
function LinkForm({
code,
setCode,
submitting,
error,
onSubmit,
}: {
code: string;
setCode: (v: string) => void;
submitting: boolean;
error: string | null;
onSubmit: (e: FormEvent) => void;
}) {
return (
<ol className="space-y-4">
<li className="flex gap-3">
<StepBadge n={1} />
<div className="space-y-1">
<p className="font-medium text-foreground">Get a code in-game</p>
<p className="text-muted-foreground">
Join any server and run{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
/link
</code>{" "}
in chat. The server already knows your verified identity, so it hands
you a one-time code (valid about 10 minutes).
</p>
</div>
</li>
<li className="flex gap-3">
<StepBadge n={2} />
<div className="w-full space-y-2">
<p className="font-medium text-foreground">Enter it here</p>
<form onSubmit={onSubmit} className="space-y-2">
<Label htmlFor="link-code">Link code</Label>
<div className="flex gap-2">
<Input
id="link-code"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="ABCD2345"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
maxLength={8}
className="max-w-[14rem] font-mono uppercase tracking-[0.3em]"
aria-invalid={error ? true : undefined}
/>
<Button
type="submit"
disabled={submitting || code.trim().length === 0}
>
{submitting ? "Verifying…" : "Link"}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</form>
</div>
</li>
</ol>
);
}
function StepBadge({ n }: { n: number }) {
return (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{n}
</span>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { lazy, Suspense, useMemo } from "react";
import { Link } from "react-router-dom";
import { Activity, Server, Play, Hand } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import type { Phase, ServerInfo } from "@/lib/types";
// three.js is heavy and only the Dashboard renders it — split it into its own
// async chunk so the rest of the panel doesn't pay for it on first load.
const VoxelFleet = lazy(() =>
import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })),
);
function Stat({
icon: Icon,
label,
value,
}: {
icon: typeof Server;
label: string;
value: number | string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="rounded-md bg-primary/15 p-2 text-primary">
<Icon className="h-5 w-5" />
</div>
<div>
<div className="text-2xl font-semibold leading-none">{value}</div>
<div className="mt-1 text-xs text-muted-foreground">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function Dashboard() {
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const counts = useMemo(() => {
const servers = data ?? [];
const by = (p: Phase) => servers.filter((s) => s.phase === p).length;
return {
total: servers.length,
running: by("Running"),
players: servers.reduce((n, s) => n + (s.players ?? 0), 0),
};
}, [data]);
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">Your fleet at a glance.</p>
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : (
<FleetView servers={data ?? []} counts={counts} hasConfig={!!cfg} />
)}
</div>
);
}
function FleetView({
servers,
counts,
hasConfig,
}: {
servers: ServerInfo[];
counts: { total: number; running: number; players: number };
hasConfig: boolean;
}) {
if (servers.length === 0) {
return (
<EmptyState
title="No servers yet"
hint="Create your first server or claim an unowned one."
>
<Link to="/servers">
<Button>Go to My servers</Button>
</Link>
</EmptyState>
);
}
return (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Stat icon={Server} label="Servers" value={counts.total} />
<Stat icon={Play} label="Running" value={counts.running} />
<Stat icon={Activity} label="Players online" value={counts.players} />
</div>
<Card className="overflow-hidden">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Fleet</CardTitle>
<Link to="/servers">
<Button variant="ghost" size="sm">
Manage <Hand className="h-3.5 w-3.5" />
</Button>
</Link>
</CardHeader>
<CardContent className="p-0">
{hasConfig ? (
<div className="h-[340px] w-full bg-gradient-to-b from-transparent to-primary/5">
<Suspense fallback={<Loading label="Loading scene…" />}>
<VoxelFleet servers={servers} />
</Suspense>
</div>
) : (
<Loading label="Preparing scene…" />
)}
</CardContent>
</Card>
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
// MyServers is the User-Side fleet: list / wake / stop / claim the servers the
// caller owns or may claim. Creation lives on Admin-Side now (POST /servers is
// admin-tier) — the platform provisions servers; users claim and operate them.
export function MyServers() {
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const servers = data ?? [];
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">My servers</h1>
<p className="text-sm text-muted-foreground">
Wake, stop, or claim the servers you can manage.
</p>
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading label="Loading config…" />
) : servers.length === 0 ? (
<EmptyState
title="No servers linked to you"
hint="Claim an unowned server you have access to, or ask an admin to provision one."
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => (
<ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} />
))}
</div>
)}
<Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground">
Servers and game types are provisioned and managed by the platform. You
claim a node to operate it; raw cluster config is never exposed here
(spec §15).
</CardContent>
</Card>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
import { LogConsole } from "@/components/LogConsole";
import { Loading, ErrorState } from "@/components/States";
import { api, consoleStreamURL } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { hostFor } from "@/lib/config";
import type { Phase } from "@/lib/types";
// notStreamingCopy explains why there is no live feed for a phase that has no
// streamable pod. The read path only has something to relay once a pod is up, so
// Stopped/Failed/Stopping each get their own honest line rather than an empty
// console. `default` covers Unknown plus any future phase the backend may emit
// that the panel hasn't modelled yet — the screen stays informative regardless.
function notStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } {
switch (phase) {
case "Stopped":
return {
icon: Moon,
title: "Server is asleep",
body: "Wake it to boot a pod — the live console attaches automatically the moment it starts up.",
};
case "Stopping":
return {
icon: Moon,
title: "Server is shutting down",
body: "The pod is terminating, so the live console has detached. It will be asleep in a moment.",
};
case "Failed":
return {
icon: ShieldAlert,
title: "Server failed to start",
body: "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.",
};
default:
return {
icon: HelpCircle,
title: "Console is offline",
body: "The live console attaches automatically as soon as the server is running.",
};
}
}
function NotStreaming({ phase }: { phase: Phase }) {
const { icon: Icon, title, body } = notStreamingCopy(phase);
return (
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" />
<div className="space-y-1">
<p className="font-medium text-foreground">{title}</p>
<p>{body}</p>
</div>
</div>
);
}
export function ServerConsole() {
const { name = "" } = useParams();
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(
() => api.status(name),
[name],
);
// The read-side stream (spec §8) follows the running pod's log. A pod exists
// during BOTH Starting and Running — the operator marks Starting once the pod
// is up but RCON is not yet reachable (it only flips to Running after RCON
// readiness). Boot logs flow precisely in that Starting window, which is when a
// read most wants them, so the gate streams for both, not Running alone.
const streamable = data?.phase === "Running" || data?.phase === "Starting";
return (
<div className="mx-auto max-w-4xl space-y-6">
<Link
to="/servers"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> My servers
</Link>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : data ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{data.displayName || data.name}
</h1>
{cfg && (
<p className="text-sm text-muted-foreground">
{hostFor(data.subdomain, cfg)}
</p>
)}
</div>
<div className="flex items-center gap-2">
<PhaseBadge phase={data.phase} />
{streamable ? (
<Button
variant="outline"
size="sm"
onClick={() => api.stop(name).then(reload)}
>
Stop
</Button>
) : (
<Button size="sm" onClick={() => api.wake(name).then(reload)}>
Wake
</Button>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Terminal className="h-4 w-4" /> Console
</CardTitle>
<p className="text-sm text-muted-foreground">
Live, read-only output streamed from the running pod over SSE
(spec §8). Commands run through a separate path — the panel never
holds an RCON password.
</p>
</CardHeader>
<CardContent>
{!streamable ? (
<NotStreaming phase={data.phase} />
) : cfg ? (
// key on name so navigating between servers remounts the viewport
// (fresh stream + scroll state). useLogStream also resets its
// controller when the url changes, so this is belt-and-suspenders.
<LogConsole key={name} url={consoleStreamURL(cfg.apiBase, name)} />
) : (
<Loading />
)}
</CardContent>
</Card>
</>
) : null}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Link } from "react-router-dom";
import { ServerCog, Boxes, ShieldCheck } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useTier } from "@/lib/tier";
// AdminHome is the Admin-Side landing: a short orientation plus links into the
// content/server-admin surfaces. It deliberately states the four-power boundary
// so an admin never expects cluster-lifecycle levers here — those stay kubectl/CRD.
const LINKS = [
{
to: "/admin/servers",
icon: ServerCog,
title: "Servers",
body: "Create platform servers from the structured §15 form and manage the ones you operate.",
},
{
to: "/admin/images",
icon: Boxes,
title: "Images",
body: "The platform image whitelist — the value space the create form draws from.",
},
];
export function AdminHome() {
const { identity } = useTier();
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<ShieldCheck className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Admin</h1>
<p className="text-sm text-muted-foreground">
Server &amp; content administration
{identity?.email ? ` · signed in as ${identity.email}` : ""}.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{LINKS.map((l) => (
<Link key={l.to} to={l.to}>
<Card className="h-full transition-colors hover:border-primary/40">
<CardContent className="space-y-2 p-4">
<l.icon className="h-5 w-5 text-primary" />
<div className="font-medium">{l.title}</div>
<p className="text-sm text-muted-foreground">{l.body}</p>
</CardContent>
</Card>
</Link>
))}
</div>
<Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground">
Cluster scaling, RBAC, Secrets and control-plane lifecycle are{" "}
<span className="font-medium">kubectl / CRD operations</span> and are
intentionally not available from the panel — the four-power separation
(build / runtime / operator / app) is preserved. This is a window onto the
platform, not a lever for operator power.
</CardContent>
</Card>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Boxes, CheckCircle2, CircleSlash } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { api } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
// ImageAdmin is the Admin-Side view of the platform image whitelist (spec §14 —
// "镜像白名单(Postgres, Web 动态改可审计)"). The READ is real (GET /images); the
// add / enable / disable WRITE routes are an admin-tier mutation slice still to be
// added, so this page surfaces the whitelist honestly and labels the missing
// mutation rather than shipping dead buttons.
export function ImageAdmin() {
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
const images = data ?? [];
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Images</h1>
<p className="text-sm text-muted-foreground">
The platform image whitelist. Only enabled images can back a new server.
Editing the whitelist from the Web is auditable (spec §14) — the mutation
routes are a pending admin-tier slice, so this view is read-only for now.
</p>
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : images.length === 0 ? (
<EmptyState
title="No images whitelisted"
hint="The whitelist is empty — a platform admin must add one (CLI for now)."
/>
) : (
<div className="space-y-2">
{images.map((img) => (
<Card key={img.image_ref}>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div className="flex min-w-0 items-center gap-3">
<Boxes className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-sm">{img.image_ref}</span>
{img.source && (
<Badge variant="outline" className="shrink-0">
{img.source}
</Badge>
)}
</div>
{img.enabled ? (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary">
<CheckCircle2 className="h-3.5 w-3.5" /> enabled
</span>
) : (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<CircleSlash className="h-3.5 w-3.5" /> disabled
</span>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard";
import { CreateServerDialog } from "@/components/CreateServerDialog";
import { Loading, ErrorState, EmptyState } from "@/components/States";
import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
// ServerAdmin is the Admin-Side create surface. CreateServerDialog lives here (it
// calls the admin-tier POST /servers, spec §15) rather than on User-Side, where it
// previously rendered for everyone and relied on a 403 — moving it removes that
// UX/tier mismatch.
//
// The list below currently uses the app-tier GET /me/servers, i.e. the servers
// THIS admin owns or may manage. A fleet-wide GET /admin/servers (every server,
// not just mine) is a separate Oracle-verifiable backend slice that powers the
// SysAdmin-Side FleetTable; it is intentionally not faked in here.
export function ServerAdmin() {
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const servers = data ?? [];
return (
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Servers</h1>
<p className="text-sm text-muted-foreground">
Create platform servers from the structured form and manage the ones
you operate.
</p>
</div>
{cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
</div>
{loading && !data ? (
<Loading />
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading label="Loading config…" />
) : servers.length === 0 ? (
<EmptyState
title="No servers under your management yet"
hint="Use Create server to provision one from the vetted spec."
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{servers.map((s) => (
<ServerCard key={s.name} server={s} cfg={cfg} onChanged={reload} />
))}
</div>
)}
<Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground">
Server creation goes through the structured form only — the platform maps
your choices onto a vetted Kubernetes spec. Raw cluster config (host
networking, host paths, arbitrary images, privileged pods) is never
expressible here (spec §15).
</CardContent>
</Card>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Network } from "lucide-react";
import { PendingBackend } from "@/components/States";
// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with
// phase + capacity, distinct from the User-Side "My servers". It depends on the
// fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend
// slice. Honest placeholder until that lands — no fabricated rows.
export function FleetTable() {
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Fleet</h1>
<p className="text-sm text-muted-foreground">
Every server on the platform — phase, owner, capacity.
</p>
</div>
</div>
<PendingBackend
endpoint="GET /api/v1/admin/servers"
note="The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships."
/>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Link } from "react-router-dom";
import { Gauge, Network, ServerOff } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { PendingBackend } from "@/components/States";
// OpsOverview is the SysAdmin-Side landing: a platform-observability cockpit. It is
// admin-tier (same gate as Admin-Side) but a separate *concern* — read-mostly
// visibility across the whole platform, not content/server administration.
//
// The cluster-wide fleet rollup needs a fleet-wide read (GET /admin/servers) that
// does not exist yet — that is a separate Oracle-verifiable backend slice. Until
// then this surface is honest about the gap rather than charting fake data.
export function OpsOverview() {
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Gauge className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">SysAdmin</h1>
<p className="text-sm text-muted-foreground">
Platform observability cockpit — a window, not a lever.
</p>
</div>
</div>
<Card>
<CardContent className="space-y-3 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Network className="h-4 w-4 text-primary" /> Cluster-wide fleet
</div>
<PendingBackend
endpoint="GET /api/v1/admin/servers"
note="A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers."
/>
<Link
to="/ops/fleet"
className="inline-block text-sm font-medium text-primary hover:underline"
>
Open fleet table →
</Link>
</CardContent>
</Card>
<Card className="border-dashed bg-transparent">
<CardContent className="flex items-start gap-3 p-4 text-xs text-muted-foreground">
<ServerOff className="mt-0.5 h-4 w-4 shrink-0" />
<span>
Control-plane scaling, RBAC, Secrets and cluster lifecycle are{" "}
<span className="font-medium">kubectl / CRD operations</span> and are not
reachable from here. SysAdmin-Side is observability only — the
four-power separation (build / runtime / operator / app) is preserved.
</span>
</CardContent>
</Card>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE?: string;
readonly VITE_ROOT_DOMAIN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+52
View File
@@ -0,0 +1,52 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
},
animation: { "fade-in": "fade-in 0.2s ease-out" },
},
},
plugins: [tailwindcssAnimate],
};
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
// The panel is served by felis-api behind Zero-Trust; the API lives under the
// same origin in production, so /api proxies there in dev.
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
server: {
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true },
},
},
});