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:
52 files changed
+7626
No files matched your search
@@ -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*
|
||||||
@@ -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>
|
||||||
Generated
+4150
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"apiBase": "/api/v1",
|
||||||
|
"rootDomain": "example.net"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("|");
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+10
@@ -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;
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user