feat(panel): i18n

This commit is contained in:
Lemon-miaow committed 2026-06-27 18:31:26 +08:00
1 parent a5a64821d8
commit dd2fc6fa82
47 files changed
+892 -225

No files matched your search

+104 -1
View File
@@ -14,9 +14,12 @@
"@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^2.6.0",
"three": "^0.172.0"
@@ -283,6 +286,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2750,6 +2762,52 @@
"node": ">= 0.4"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
"integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -3301,6 +3359,33 @@
"react": "^18.3.1"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3815,7 +3900,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3906,6 +3991,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4122,6 +4216,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+3
View File
@@ -19,9 +19,12 @@
"@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^2.6.0",
"three": "^0.172.0"
+40 -8
View File
@@ -1,30 +1,57 @@
import { useEffect } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { Cat } from "lucide-react";
import { Cat, Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
import { useTier } from "@/lib/tier";
import { visibleSections } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
function LangToggle() {
const { i18n } = useTranslation();
return (
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
<SelectPrimitive.Trigger className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-zinc-900/50 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-colors shrink-0">
<Globe className="h-4 w-4" />
</SelectPrimitive.Trigger>
<SelectContent align="start" className="min-w-[6rem]">
<SelectItem value="en-US">English</SelectItem>
<SelectItem value="zh-CN">中文</SelectItem>
</SelectContent>
</Select>
);
}
export function AppShell() {
const { isAdmin } = useTier();
const { t, i18n } = useTranslation("navigation");
// 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);
// Sync document metadata with the active language.
useEffect(() => {
document.documentElement.lang = i18n.language;
document.title = t("common:page_title");
}, [i18n.language, t]);
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>
<span className="text-lg font-semibold tracking-tight">{t("common:brand_name")}</span>
</div>
<nav className="flex flex-col gap-4">
{sections.map((section) => (
<div key={section.id} className="flex flex-col gap-1">
{section.title && (
{section.titleKey && (
<div className="px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.title}
{t(section.titleKey)}
</div>
)}
{section.items.map((item) => (
@@ -42,22 +69,27 @@ export function AppShell() {
}
>
<item.icon className="h-4 w-4" />
{item.label}
{t(item.key)}
</NavLink>
))}
</div>
))}
</nav>
<div className="mt-auto px-3 pt-4 text-xs text-muted-foreground">
K8s-native Minecraft orchestration
<div className="mt-auto px-3 pt-4 border-t border-border/60">
<div className="flex items-start gap-2.5">
<LangToggle />
<span className="text-[10px] text-muted-foreground/60 leading-tight pt-1">
{t("common:brand_tagline")}
</span>
</div>
</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>
<span className="font-semibold">{t("common:brand_name")}</span>
</header>
<main className="flex-1 p-6">
<Outlet />
+8 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { type ReactNode, useEffect } from "react";
import { Cat } from "lucide-react";
import { useTranslation } from "react-i18next";
// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
@@ -13,6 +14,12 @@ export function AuthLayout({
subtitle?: string;
children: ReactNode;
}) {
const { i18n } = useTranslation("common");
useEffect(() => {
document.documentElement.lang = i18n.language;
document.title = title;
}, [i18n.language, title]);
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
<div className="w-full max-w-sm space-y-6">
+33 -27
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
Dialog,
DialogContent,
@@ -32,20 +33,24 @@ import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
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])?$/;
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
return [
{ value: "ownerOnly", label: t("create_server_policy_owner") },
{ value: "public", label: t("create_server_policy_public") },
{ value: "allowlist", label: t("create_server_policy_allowlist") },
];
}
interface Props {
cfg: RuntimeConfig;
onCreated: () => void;
}
export function CreateServerDialog({ cfg, onCreated }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false);
const images = useAsync(() => api.listImages(), []);
@@ -94,69 +99,70 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
}
}
const policies = policyOptions(t);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus /> New server
<Plus /> {t("create_server_btn")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create a server</DialogTitle>
<DialogTitle>{t("create_server_title")}</DialogTitle>
<DialogDescription>
Pick from whitelisted images and sizes — the platform provisions the
rest. No raw cluster config is exposed here.
{t("create_server_desc")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cs-name">Name</Label>
<Label htmlFor="cs-name">{t("create_server_name")}</Label>
<Input
id="cs-name"
placeholder="survival"
placeholder={t("create_server_name_placeholder")}
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cs-sub">Subdomain</Label>
<Label htmlFor="cs-sub">{t("create_server_subdomain")}</Label>
<Input
id="cs-sub"
placeholder="survival"
placeholder={t("create_server_subdomain_placeholder")}
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)}`}
? t("create_server_subdomain_invalid")
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="cs-display">Display name (optional)</Label>
<Label htmlFor="cs-display">{t("create_server_display_name")}</Label>
<Input
id="cs-display"
placeholder="Survival World"
placeholder={t("create_server_display_name_placeholder")}
value={form.displayName ?? ""}
onChange={(e) => set("displayName", e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>Image</Label>
<Label>{t("create_server_image")}</Label>
<Select value={form.image} onValueChange={(v) => set("image", v)}>
<SelectTrigger>
<SelectValue
placeholder={
images.loading
? "Loading images…"
? t("create_server_image_loading")
: enabledImages.length
? "Choose an image"
: "No whitelisted images"
? t("create_server_image_choose")
: t("create_server_image_none")
}
/>
</SelectTrigger>
@@ -172,7 +178,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Memory</Label>
<Label>{t("create_server_memory")}</Label>
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
<SelectTrigger>
<SelectValue />
@@ -187,7 +193,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</Select>
</div>
<div className="grid gap-2">
<Label>Storage</Label>
<Label>{t("create_server_storage")}</Label>
<Select value={form.storage} onValueChange={(v) => set("storage", v)}>
<SelectTrigger>
<SelectValue />
@@ -204,7 +210,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</div>
<div className="grid gap-2">
<Label>Autostart policy</Label>
<Label>{t("create_server_policy")}</Label>
<Select
value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
@@ -213,7 +219,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{POLICIES.map((p) => (
{policies.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
@@ -227,10 +233,10 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
{t("create_server_cancel")}
</Button>
<Button onClick={submit} disabled={!canSubmit}>
{submitting ? "Creating…" : "Create"}
{submitting ? t("create_server_creating") : t("create_server_submit")}
</Button>
</DialogFooter>
</DialogContent>
+13 -10
View File
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useLogStream } from "@/lib/useLogStream";
@@ -19,11 +20,12 @@ const LEVEL_CLASS: Record<LogLevel, string> = {
const PIN_THRESHOLD_PX = 24;
function StatusIndicator({ status }: { status: StreamStatus }) {
const { t } = useTranslation("servers");
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" },
connecting: { dot: "bg-amber-400 animate-pulse", label: t("log_connecting"), text: "text-amber-300" },
open: { dot: "bg-emerald-400", label: t("log_live"), text: "text-emerald-300" },
reconnecting: { dot: "bg-amber-400 animate-pulse", label: t("log_reconnecting"), text: "text-amber-300" },
ended: { dot: "bg-zinc-500", label: t("log_ended"), text: "text-zinc-400" },
};
const s = map[status];
return (
@@ -46,6 +48,7 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
* scrolling back to the bottom re-pins. The buffer is bounded by the controller.
*/
export function LogConsole({ url }: { url: string }) {
const { t } = useTranslation("servers");
const { lines, status, clear, reconnect } = useLogStream(url);
const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true);
@@ -81,7 +84,7 @@ export function LogConsole({ url }: { url: string }) {
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
<RotateCw className="h-3.5 w-3.5" /> {t("log_reconnect_btn")}
</Button>
)}
<Button
@@ -91,7 +94,7 @@ export function LogConsole({ url }: { url: string }) {
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
<Trash2 className="h-3.5 w-3.5" /> {t("log_clear_btn")}
</Button>
</div>
</div>
@@ -106,13 +109,13 @@ export function LogConsole({ url }: { url: string }) {
{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…"}
? t("log_ended_empty")
: t("log_waiting")}
</p>
) : (
lines.map((line) => (
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
{line.text || " "}
{line.text || "\u00A0"}
</div>
))
)}
@@ -126,7 +129,7 @@ export function LogConsole({ url }: { url: string }) {
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
<ArrowDown className="h-3.5 w-3.5" /> {t("log_jump_latest")}
</button>
)}
</div>
+12 -1
View File
@@ -1,6 +1,7 @@
import { Badge } from "@/components/ui/badge";
import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type BadgeVariant = "default" | "muted" | "destructive" | "outline";
@@ -17,6 +18,15 @@ export const PHASE_COLOR: Record<Phase, string> = {
Unknown: "#94a3b8",
};
export const PHASE_KEY: Record<Phase, string> = {
Running: "servers:phase_running",
Starting: "servers:phase_starting",
Stopping: "servers:phase_stopping",
Stopped: "servers:phase_stopped",
Failed: "servers:phase_failed",
Unknown: "servers:phase_unknown",
};
const VARIANT: Record<Phase, BadgeVariant> = {
Running: "default",
Starting: "outline",
@@ -43,6 +53,7 @@ export function phaseVariant(phase: Phase): BadgeVariant {
}
export function PhaseBadge({ phase }: { phase: Phase }) {
const { t } = useTranslation();
return (
<Badge variant={phaseVariant(phase)} className="gap-1.5">
<span
@@ -52,7 +63,7 @@ export function PhaseBadge({ phase }: { phase: Phase }) {
)}
style={{ backgroundColor: phaseColor(phase) }}
/>
{phase}
{t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}
</Badge>
);
}
+3 -1
View File
@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier";
import { Loading, NotAuthorized } from "@/components/States";
@@ -11,6 +12,7 @@ import { Loading, NotAuthorized } from "@/components/States";
// 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…" />;
const { t } = useTranslation("common");
if (loading) return <Loading label={t("checking_access")} />;
return isAdmin ? <Outlet /> : <NotAuthorized />;
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { Navigate, Outlet } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier";
// RequireAuth is the layout gate in front of the whole authenticated app (AppShell
@@ -16,12 +17,13 @@ import { useTier } from "@/lib/tier";
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
const { loading, unauthenticated, mustChangePassword } = useTier();
const { t } = useTranslation("common");
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
{t("loading")}
</div>
);
}
+7 -5
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
@@ -16,6 +17,7 @@ interface Props {
}
export function ServerCard({ server, cfg, onChanged }: Props) {
const { t } = useTranslation("servers");
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
const [error, setError] = useState<string | null>(null);
const host = hostFor(server.subdomain, cfg);
@@ -65,7 +67,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{running ? (
<span>
{server.players ?? 0}
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} {t("online")}
</span>
) : (
<span>—</span>
@@ -78,7 +80,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{server.owned && (
<Link to={`/servers/${server.name}`}>
<Button variant="ghost" size="sm">
<Terminal /> Console
<Terminal /> {t("console")}
</Button>
</Link>
)}
@@ -89,7 +91,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null}
onClick={() => act("claim", () => api.claim(server.name))}
>
<Hand /> {busy === "claim" ? "Claiming…" : "Claim"}
<Hand /> {busy === "claim" ? t("claiming") : t("claim")}
</Button>
)}
{server.owned &&
@@ -100,7 +102,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))}
>
<Square /> {busy === "stop" ? "Stopping…" : "Stop"}
<Square /> {busy === "stop" ? t("stopping") : t("stop")}
</Button>
) : (
<Button
@@ -108,7 +110,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))}
>
<Play /> {busy === "wake" ? "Waking…" : "Wake"}
<Play /> {busy === "wake" ? t("waking") : t("wake")}
</Button>
))}
</CardFooter>
+13 -10
View File
@@ -1,17 +1,20 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { humanizeError } from "@/lib/api";
export function Loading({ label = "Loading…" }: { label?: string }) {
export function Loading({ label }: { label?: string }) {
const { t } = useTranslation("common");
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}
{label ?? t("loading")}
</div>
);
}
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const { t } = useTranslation("common");
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<AlertTriangle className="h-6 w-6 text-destructive" />
@@ -21,7 +24,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
onClick={onRetry}
className="text-sm font-medium text-primary hover:underline"
>
Try again
{t("try_again")}
</button>
)}
</div>
@@ -50,19 +53,18 @@ export function EmptyState({
}
export function NotAuthorized() {
const { t } = useTranslation("common");
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="font-medium">{t("not_authorized_title")}</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.
{t("not_authorized_body")}
</p>
</div>
<Link to="/" className="text-sm font-medium text-primary hover:underline">
Back to your dashboard
{t("back_to_dashboard")}
</Link>
</div>
);
@@ -76,14 +78,15 @@ export function NotAuthorized() {
* data.
*/
export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) {
const { t } = useTranslation("common");
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="font-medium">{t("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}
{t("pending_backend_prefix")}{endpoint}
</p>
</div>
</div>
+7 -3
View File
@@ -1,6 +1,7 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root;
@@ -25,7 +26,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
>(({ className, children, ...props }, ref) => {
const { t } = useTranslation("common");
return (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -39,11 +42,12 @@ export const DialogContent = React.forwardRef<
{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>
<span className="sr-only">{t("close_sr")}</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogHeader({
+63
View File
@@ -0,0 +1,63 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enCommon from "./resources/en-US/common.json";
import enAuth from "./resources/en-US/auth.json";
import enDashboard from "./resources/en-US/dashboard.json";
import enServers from "./resources/en-US/servers.json";
import enAccount from "./resources/en-US/account.json";
import enAdmin from "./resources/en-US/admin.json";
import enOps from "./resources/en-US/ops.json";
import enErrors from "./resources/en-US/errors.json";
import enNavigation from "./resources/en-US/navigation.json";
import zhCommon from "./resources/zh-CN/common.json";
import zhAuth from "./resources/zh-CN/auth.json";
import zhDashboard from "./resources/zh-CN/dashboard.json";
import zhServers from "./resources/zh-CN/servers.json";
import zhAccount from "./resources/zh-CN/account.json";
import zhAdmin from "./resources/zh-CN/admin.json";
import zhOps from "./resources/zh-CN/ops.json";
import zhErrors from "./resources/zh-CN/errors.json";
import zhNavigation from "./resources/zh-CN/navigation.json";
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
"en-US": {
common: enCommon,
auth: enAuth,
dashboard: enDashboard,
servers: enServers,
account: enAccount,
admin: enAdmin,
ops: enOps,
errors: enErrors,
navigation: enNavigation,
},
"zh-CN": {
common: zhCommon,
auth: zhAuth,
dashboard: zhDashboard,
servers: zhServers,
account: zhAccount,
admin: zhAdmin,
ops: zhOps,
errors: zhErrors,
navigation: zhNavigation,
},
},
fallbackLng: "en-US",
defaultNS: "common",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "felis-lang",
},
});
export default i18next;
@@ -0,0 +1,21 @@
{
"title": "Account",
"subtitle": "Identity and Minecraft linking.",
"session": "Session",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by local password sign-in or the platform's identity proxy (Zero-Trust / Access) (spec §7/§8).",
"sign_out": "Sign out",
"signing_out": "Signing out…",
"minecraft_link": "Minecraft link",
"checking_link": "Checking link status…",
"linked_title": "Your Minecraft account is linked.",
"linked_desc": "Linked sessions can claim and own servers — ownership operations (claim, wake, stop) are unblocked.",
"uuid_label": "UUID",
"step1_title": "Get a code in-game",
"step1_desc_prefix": "Join any server and run ",
"step1_desc_suffix": " in chat. The server already knows your verified identity, so it hands you a one-time code (valid about 10 minutes).",
"step2_title": "Enter it here",
"link_code": "Link code",
"link_code_placeholder": "ABCD2345",
"verify_btn": "Link",
"verifying": "Verifying…"
}
+19
View File
@@ -0,0 +1,19 @@
{
"title": "Admin",
"subtitle": "Server & content administration",
"signed_in_as": " · signed in as {{email}}",
"servers": "Servers",
"servers_desc": "Create platform servers from the structured §15 form and manage the ones you operate.",
"images": "Images",
"images_desc": "The platform image whitelist — the value space the create form draws from.",
"server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
"images_title": "Images",
"images_subtitle": "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.",
"no_images_title": "No images whitelisted",
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
"enabled": "enabled",
"disabled": "disabled",
"footer_kubectl": "kubectl / CRD operations",
"footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
"footer_post": " 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."
}
+18
View File
@@ -0,0 +1,18 @@
{
"login_title": "Sign in to Felis",
"login_subtitle": "Operator console",
"username": "Username",
"password": "Password",
"sign_in": "Sign in",
"signing_in": "Signing in…",
"change_password_title": "Set a new password",
"change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.",
"change_password_subtitle_voluntary": "Update your console password.",
"current_password": "Current password",
"new_password": "New password",
"confirm_new_password": "Confirm new password",
"password_mismatch": "Passwords don't match.",
"password_min_length": "At least {{min}} characters.",
"change_password_btn": "Change password",
"saving": "Saving…"
}
@@ -0,0 +1,22 @@
{
"loading": "Loading…",
"try_again": "Try again",
"cancel": "Cancel",
"save": "Save",
"create": "Create",
"creating": "Creating…",
"close_sr": "Close",
"not_authorized_title": "Not authorized",
"not_authorized_body": "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.",
"back_to_dashboard": "Back to your dashboard",
"not_wired_yet": "Not wired yet",
"pending_backend_prefix": "pending backend: ",
"checking_access": "Checking access…",
"loading_config": "Loading config…",
"brand_name": "Felis",
"brand_tagline": "K8s-native Minecraft orchestration",
"page_title": "Felis · Control Panel",
"lang_en": "EN",
"lang_zh": "中文",
"lang_toggle_hint": "Switch to {{lang}}"
}
@@ -0,0 +1,14 @@
{
"title": "Dashboard",
"subtitle": "Your fleet at a glance.",
"no_servers_title": "No servers yet",
"no_servers_hint": "Create your first server or claim an unowned one.",
"go_to_my_servers": "Go to My servers",
"stat_servers": "Servers",
"stat_running": "Running",
"stat_players_online": "Players online",
"fleet": "Fleet",
"manage": "Manage",
"loading_scene": "Loading scene…",
"preparing_scene": "Preparing scene…"
}
@@ -0,0 +1,18 @@
{
"local_auth_disabled": "Password sign-in is turned off here — reach this console through your organization's secure access.",
"invalid_credentials": "Incorrect username or password.",
"weak_password": "Pick a password between 8 and 72 characters.",
"password_unchanged": "Your new password must differ from the current one.",
"not_linked": "Link your Minecraft account before claiming (Account → Link).",
"invalid_code": "That link code is invalid or expired — run /link again in-game for a fresh code.",
"already_linked": "That Minecraft account is already linked to another user.",
"quota_exceeded": "You have reached your server quota.",
"already_claimed": "Someone else just claimed this server.",
"image_not_whitelisted": "That image is not on the whitelist.",
"subdomain_taken": "That subdomain is already in use.",
"already_exists": "A server with that name already exists.",
"cooldown": "Wake is cooling down — try again shortly.",
"session_expired": "Your session expired — please sign in again.",
"forbidden": "You are not allowed to do that.",
"generic": "Something went wrong."
}
@@ -0,0 +1,12 @@
{
"dashboard": "Dashboard",
"my_servers": "My servers",
"account": "Account",
"admin_section": "Admin",
"admin_overview": "Overview",
"admin_servers": "Servers",
"admin_images": "Images",
"sysadmin_section": "SysAdmin",
"sysadmin_overview": "Overview",
"sysadmin_fleet": "Fleet"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "SysAdmin",
"subtitle": "Platform observability cockpit — a window, not a lever.",
"cluster_wide_fleet": "Cluster-wide fleet",
"open_fleet_table": "Open fleet table →",
"footer_pre": "Control-plane scaling, RBAC, Secrets and cluster lifecycle are ",
"footer_kubectl": "kubectl / CRD operations",
"footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.",
"fleet_title": "Fleet",
"fleet_subtitle": "Every server on the platform — phase, owner, capacity.",
"fleet_table_pending_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.",
"ops_overview_pending_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."
}
@@ -0,0 +1,68 @@
{
"my_servers_title": "My servers",
"my_servers_subtitle": "Wake, stop, or claim the servers you can manage.",
"no_servers_linked": "No servers linked to you",
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
"my_servers_footer": "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).",
"phase_running": "Running",
"phase_starting": "Starting",
"phase_stopping": "Stopping",
"phase_stopped": "Stopped",
"phase_failed": "Failed",
"phase_unknown": "Unknown",
"online": "online",
"console": "Console",
"claim": "Claim",
"claiming": "Claiming…",
"stop": "Stop",
"stopping": "Stopping…",
"wake": "Wake",
"waking": "Waking…",
"server_asleep_title": "Server is asleep",
"server_asleep_body": "Wake it to boot a pod — the live console attaches automatically the moment it starts up.",
"server_shutting_down_title": "Server is shutting down",
"server_shutting_down_body": "The pod is terminating, so the live console has detached. It will be asleep in a moment.",
"server_failed_title": "Server failed to start",
"server_failed_body": "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.",
"console_offline_title": "Console is offline",
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
"my_servers_breadcrumb": "My servers",
"console_card_title": "Console",
"console_card_desc": "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.",
"log_connecting": "Connecting…",
"log_live": "Live",
"log_reconnecting": "Reconnecting…",
"log_ended": "Stream ended",
"log_reconnect_btn": "Reconnect",
"log_clear_btn": "Clear",
"log_ended_empty": "Stream ended — reconnect to resume following the log.",
"log_waiting": "Waiting for output…",
"log_jump_latest": "Jump to latest",
"create_server_btn": "New server",
"create_server_title": "Create a server",
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
"create_server_name": "Name",
"create_server_name_placeholder": "survival",
"create_server_subdomain": "Subdomain",
"create_server_subdomain_placeholder": "survival",
"create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.",
"create_server_subdomain_hint": "Will be reachable at {{host}}",
"create_server_display_name": "Display name (optional)",
"create_server_display_name_placeholder": "Survival World",
"create_server_image": "Image",
"create_server_image_loading": "Loading images…",
"create_server_image_choose": "Choose an image",
"create_server_image_none": "No whitelisted images",
"create_server_memory": "Memory",
"create_server_storage": "Storage",
"create_server_policy": "Autostart policy",
"create_server_policy_owner": "Owner only — wake from the panel",
"create_server_policy_public": "Public — any player join wakes it",
"create_server_policy_allowlist": "Allowlist — listed players wake it",
"create_server_cancel": "Cancel",
"create_server_submit": "Create",
"create_server_creating": "Creating…",
"no_servers_managed": "No servers under your management yet",
"no_servers_managed_hint": "Use \"New server\" to provision one from the vetted spec.",
"server_admin_footer": "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)."
}
@@ -0,0 +1,21 @@
{
"title": "账户",
"subtitle": "身份验证与 Minecraft 关联。",
"session": "会话",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由本地密码登录还是平台身份代理(Zero-Trust / Access)签发(spec §7/§8)。",
"sign_out": "退出登录",
"signing_out": "退出中…",
"minecraft_link": "Minecraft 关联",
"checking_link": "正在检查关联状态…",
"linked_title": "Minecraft 账户已关联。",
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、唤醒、停止)已解锁。",
"uuid_label": "UUID",
"step1_title": "在游戏中获取验证码",
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性验证码(约 10 分钟内有效)。",
"step2_title": "在此输入",
"link_code": "关联码",
"link_code_placeholder": "ABCD2345",
"verify_btn": "关联",
"verifying": "验证中…"
}
+19
View File
@@ -0,0 +1,19 @@
{
"title": "管理",
"subtitle": "服务器与内容管理",
"signed_in_as": " · 当前登录:{{email}}",
"servers": "服务器",
"servers_desc": "通过结构化表单(§15)创建并管理平台服务器。",
"images": "镜像",
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
"images_title": "镜像",
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。Web 端编辑白名单可审计(spec §14)——变更接口待后续上线,当前仅可查看。",
"no_images_title": "无白名单镜像",
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
"enabled": "已启用",
"disabled": "已禁用",
"footer_kubectl": "kubectl / CRD",
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
"footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。"
}
+18
View File
@@ -0,0 +1,18 @@
{
"login_title": "登录 Felis",
"login_subtitle": "运维控制台",
"username": "用户名",
"password": "密码",
"sign_in": "登录",
"signing_in": "登录中…",
"change_password_title": "设置新密码",
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
"change_password_subtitle_voluntary": "修改控制台登录密码。",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_new_password": "确认新密码",
"password_mismatch": "两次输入的密码不一致。",
"password_min_length": "密码至少 {{min}} 个字符。",
"change_password_btn": "修改密码",
"saving": "保存中…"
}
@@ -0,0 +1,22 @@
{
"loading": "加载中…",
"try_again": "重试",
"cancel": "取消",
"save": "保存",
"create": "创建",
"creating": "创建中…",
"close_sr": "关闭",
"not_authorized_title": "无权访问",
"not_authorized_body": "此区域仅供平台管理员使用。若通过直接输入 URL 到达此页面,说明你的账号无管理员权限,API 侧同样会拒绝该请求。",
"back_to_dashboard": "返回首页",
"not_wired_yet": "功能尚未接入",
"pending_backend_prefix": "待接入接口:",
"checking_access": "正在验证权限…",
"loading_config": "正在加载配置…",
"brand_name": "Felis",
"brand_tagline": "Kubernetes 原生的 Minecraft 管理平台",
"page_title": "Felis · 控制面板",
"lang_en": "EN",
"lang_zh": "中文",
"lang_toggle_hint": "切换至 {{lang}}"
}
@@ -0,0 +1,14 @@
{
"title": "仪表盘",
"subtitle": "服务器运行状态一览。",
"no_servers_title": "暂无服务器",
"no_servers_hint": "创建一个新服务器或认领一台现有服务器即可开始。",
"go_to_my_servers": "前往我的服务器",
"stat_servers": "服务器",
"stat_running": "运行中",
"stat_players_online": "在线玩家",
"fleet": "服务器概览",
"manage": "管理",
"loading_scene": "正在加载 3D 场景…",
"preparing_scene": "正在准备场景…"
}
@@ -0,0 +1,18 @@
{
"local_auth_disabled": "当前部署已禁用本地密码登录,请通过组织的安全入口访问控制台。",
"invalid_credentials": "用户名或密码错误。",
"weak_password": "密码长度需在 8 到 72 个字符之间。",
"password_unchanged": "新密码不可与当前密码相同。",
"not_linked": "请先关联 Minecraft 账户(账户页 → 关联)。",
"invalid_code": "关联码无效或已过期——请在游戏中重新输入 /link 获取新码。",
"already_linked": "该 Minecraft 账户已关联至其他用户。",
"quota_exceeded": "服务器数量已达配额上限。",
"already_claimed": "该服务器已被他人抢先认领。",
"image_not_whitelisted": "该镜像未在白名单中。",
"subdomain_taken": "该子域名已被占用。",
"already_exists": "同名服务器已存在。",
"cooldown": "启动冷却中——请稍后再试。",
"session_expired": "会话已过期——请重新登录。",
"forbidden": "你无权执行此操作。",
"generic": "出了点问题,请稍后重试。"
}
@@ -0,0 +1,12 @@
{
"dashboard": "仪表盘",
"my_servers": "我的服务器",
"account": "账户",
"admin_section": "管理",
"admin_overview": "概览",
"admin_servers": "服务器",
"admin_images": "镜像",
"sysadmin_section": "系统管理",
"sysadmin_overview": "概览",
"sysadmin_fleet": "全平台服务器"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "系统管理",
"subtitle": "平台可观测性看板——观察视角,非管理入口。",
"cluster_wide_fleet": "全平台服务器",
"open_fleet_table": "查看全平台服务器列表 →",
"footer_pre": "控制面扩缩、RBAC、Secrets 及集群生命周期属于 ",
"footer_kubectl": "kubectl / CRD",
"footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。",
"fleet_title": "全平台服务器",
"fleet_subtitle": "平台所有服务器——运行状态、所有者、资源用量。",
"fleet_table_pending_note": "全平台服务器列表会读取所有服务器的状态(不限于你拥有的)。该管理员层接口为下一个待开发的后端模块,上线后数据将在此呈现。",
"ops_overview_pending_note": "全平台服务器读取(所有服务器,非仅个人拥有)支撑集群汇总及 3D 视图。该管理员层接口尚待开发,与用户层 GET /me/servers 不同。"
}
@@ -0,0 +1,68 @@
{
"my_servers_title": "我的服务器",
"my_servers_subtitle": "管理你拥有或可用的服务器。",
"no_servers_linked": "暂无关联的服务器",
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
"my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此(spec §15)。",
"phase_running": "运行中",
"phase_starting": "启动中",
"phase_stopping": "停止中",
"phase_stopped": "已停止",
"phase_failed": "启动失败",
"phase_unknown": "未知",
"online": "人在线",
"console": "控制台",
"claim": "认领",
"claiming": "认领中…",
"stop": "停止",
"stopping": "停止中…",
"wake": "唤醒",
"waking": "唤醒中…",
"server_asleep_title": "服务器已休眠",
"server_asleep_body": "唤醒后将拉起 Pod,实时控制台会自动连接。",
"server_shutting_down_title": "服务器正在关闭",
"server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。",
"server_failed_title": "服务器启动失败",
"server_failed_body": "当前无运行中的 Pod,无法获取日志流。重新唤醒后控制台将自动连接。",
"console_offline_title": "控制台未连接",
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
"my_servers_breadcrumb": "我的服务器",
"console_card_title": "控制台",
"console_card_desc": "通过 SSE 从 Pod 实时流式输出的只读日志(spec §8)。命令执行走独立通道——面板不持有 RCON 密码。",
"log_connecting": "连接中…",
"log_live": "实时",
"log_reconnecting": "重连中…",
"log_ended": "连接已断开",
"log_reconnect_btn": "重连",
"log_clear_btn": "清屏",
"log_ended_empty": "连接已断开——重连后可恢复日志输出。",
"log_waiting": "等待输出…",
"log_jump_latest": "滚动到最新",
"create_server_btn": "新建服务器",
"create_server_title": "创建服务器",
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
"create_server_name": "名称",
"create_server_name_placeholder": "survival",
"create_server_subdomain": "子域名",
"create_server_subdomain_placeholder": "survival",
"create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。",
"create_server_subdomain_hint": "访问地址:{{host}}",
"create_server_display_name": "显示名称(选填)",
"create_server_display_name_placeholder": "生存世界",
"create_server_image": "镜像",
"create_server_image_loading": "加载镜像列表中…",
"create_server_image_choose": "选择一个镜像",
"create_server_image_none": "暂无白名单镜像",
"create_server_memory": "内存",
"create_server_storage": "存储",
"create_server_policy": "自动启动策略",
"create_server_policy_owner": "仅所有者——通过面板唤醒",
"create_server_policy_public": "公开——任意玩家加入即唤醒",
"create_server_policy_allowlist": "白名单——仅名单内玩家可唤醒",
"create_server_cancel": "取消",
"create_server_submit": "创建",
"create_server_creating": "创建中…",
"no_servers_managed": "你还没有管理的服务器",
"no_servers_managed_hint": "使用「新建服务器」在线分配。",
"server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达(spec §15)。"
}
+18 -16
View File
@@ -9,6 +9,7 @@ import type {
WhitelistImage,
} from "./types";
import { loadConfig } from "./config";
import i18next from "i18next";
// 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
@@ -126,37 +127,38 @@ export function consoleStreamURL(apiBase: string, name: string): string {
/** humanizeError turns the stable error code into a user-facing line. */
export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>;
const t = i18next.getFixedT(null, "errors");
switch (err.code) {
// Local-password auth (spec §B1).
case "local_auth_disabled":
return "Password sign-in is turned off here — reach this console through your organization's secure access.";
return t("local_auth_disabled");
case "invalid_credentials":
return "Incorrect username or password.";
return t("invalid_credentials");
case "weak_password":
return "Pick a password between 8 and 72 characters.";
return t("weak_password");
case "password_unchanged":
return "Your new password must differ from the current one.";
return t("password_unchanged");
case "not_linked":
return "Link your Minecraft account before claiming (Account → Link).";
return t("not_linked");
case "invalid_code":
return "That link code is invalid or expired — run /link again in-game for a fresh code.";
return t("invalid_code");
case "already_linked":
return "That Minecraft account is already linked to another user.";
return t("already_linked");
case "quota_exceeded":
return "You have reached your server quota.";
return t("quota_exceeded");
case "already_claimed":
return "Someone else just claimed this server.";
return t("already_claimed");
case "image_not_whitelisted":
return "That image is not on the whitelist.";
return t("image_not_whitelisted");
case "subdomain_taken":
return "That subdomain is already in use.";
return t("subdomain_taken");
case "already_exists":
return "A server with that name already exists.";
return t("already_exists");
case "cooldown":
return "Wake is cooling down — try again shortly.";
return t("cooldown");
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.";
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
return err.message ?? t("generic");
}
}
+14 -14
View File
@@ -23,7 +23,7 @@ import {
export interface NavItem {
to: string;
label: string;
key: string;
icon: LucideIcon;
/** react-router NavLink `end` — exact-match active styling for index routes. */
end?: boolean;
@@ -31,8 +31,8 @@ export interface NavItem {
export interface NavSection {
id: "user" | "admin" | "ops";
/** Section heading shown in the sidebar; null renders no heading (User-Side). */
title: string | null;
/** Section heading key; null renders no heading (User-Side). */
titleKey: string | null;
/** When true the section is shown only to admins (is_admin === true). */
adminOnly: boolean;
items: NavItem[];
@@ -41,31 +41,31 @@ export interface NavSection {
export const NAV_SECTIONS: NavSection[] = [
{
id: "user",
title: null,
titleKey: null,
adminOnly: false,
items: [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/servers", label: "My servers", icon: Server },
{ to: "/account", label: "Account", icon: UserRound },
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
{ to: "/servers", key: "my_servers", icon: Server },
{ to: "/account", key: "account", icon: UserRound },
],
},
{
id: "admin",
title: "Admin",
titleKey: "admin_section",
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 },
{ to: "/admin", key: "admin_overview", icon: ShieldCheck, end: true },
{ to: "/admin/servers", key: "admin_servers", icon: ServerCog },
{ to: "/admin/images", key: "admin_images", icon: Boxes },
],
},
{
id: "ops",
title: "SysAdmin",
titleKey: "sysadmin_section",
adminOnly: true,
items: [
{ to: "/ops", label: "Overview", icon: Gauge, end: true },
{ to: "/ops/fleet", label: "Fleet", icon: Network },
{ to: "/ops", key: "sysadmin_overview", icon: Gauge, end: true },
{ to: "/ops/fleet", key: "sysadmin_fleet", icon: Network },
],
},
];
+1
View File
@@ -1,5 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./i18n";
import App from "./App";
import "./index.css";
+22 -26
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -18,6 +19,7 @@ import { useTier } from "@/lib/tier";
export function Account() {
const status = useAsync(() => api.linkStatus(), []);
const { refresh } = useTier();
const { t } = useTranslation("account");
// Sign-out ends a local-password session: clear it server-side, then refresh /me.
// For a local session that read now 401s → the tier model flips to
@@ -67,24 +69,20 @@ export function Account() {
return (
<div className="mx-auto max-w-4xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Account</h1>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">
Identity and Minecraft linking.
{t("subtitle")}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4 text-primary" /> Session
<ShieldCheck className="h-4 w-4 text-primary" /> {t("session")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground">
<p>
The panel itself holds no credentials — every request rides your existing
session cookie, whether issued by local password sign-in or the platform's
identity proxy (Zero-Trust / Access) (spec §7/§8).
</p>
<p>{t("session_desc")}</p>
<Button
variant="outline"
size="sm"
@@ -92,7 +90,7 @@ export function Account() {
disabled={signingOut}
>
<LogOut className="mr-2 h-4 w-4" />
{signingOut ? "Signing out…" : "Sign out"}
{signingOut ? t("signing_out") : t("sign_out")}
</Button>
</CardContent>
</Card>
@@ -100,14 +98,14 @@ export function Account() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-primary" /> Minecraft link
<Link2 className="h-4 w-4 text-primary" /> {t("minecraft_link")}
</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{linked ? (
<LinkedState uuid={verifiedUUID} />
) : status.loading && !status.data ? (
<Loading label="Checking link status…" />
<Loading label={t("checking_link")} />
) : status.error ? (
<ErrorState error={status.error} onRetry={status.reload} />
) : (
@@ -129,19 +127,17 @@ export function Account() {
* 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 }) {
const { t } = useTranslation("account");
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.
{t("linked_title")}
</div>
<p className="text-muted-foreground">
Linked sessions can claim and own servers — ownership operations (claim,
wake, stop) are unblocked.
</p>
<p className="text-muted-foreground">{t("linked_desc")}</p>
{uuid && (
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs uppercase tracking-wide">UUID</span>
<span className="text-xs uppercase tracking-wide">{t("uuid_label")}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{uuid}
</code>
@@ -167,34 +163,34 @@ function LinkForm({
error: string | null;
onSubmit: (e: FormEvent) => void;
}) {
const { t } = useTranslation("account");
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="font-medium text-foreground">{t("step1_title")}</p>
<p className="text-muted-foreground">
Join any server and run{" "}
{t("step1_desc_prefix")}
<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).
</code>
{t("step1_desc_suffix")}
</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>
<p className="font-medium text-foreground">{t("step2_title")}</p>
<form onSubmit={onSubmit} className="space-y-2">
<Label htmlFor="link-code">Link code</Label>
<Label htmlFor="link-code">{t("link_code")}</Label>
<div className="flex gap-2">
<Input
id="link-code"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="ABCD2345"
placeholder={t("link_code_placeholder")}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
@@ -206,7 +202,7 @@ function LinkForm({
type="submit"
disabled={submitting || code.trim().length === 0}
>
{submitting ? "Verifying…" : "Link"}
{submitting ? t("verifying") : t("verify_btn")}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
+13 -11
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ const MIN_PASSWORD = 8;
export function ChangePassword() {
const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
@@ -34,10 +36,10 @@ export function ChangePassword() {
if (loading) {
return (
<AuthLayout title="Felis">
<AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
{t("common:loading")}
</div>
</AuthLayout>
);
@@ -69,18 +71,18 @@ export function ChangePassword() {
return (
<AuthLayout
title="Set a new password"
title={t("change_password_title")}
subtitle={
mustChangePassword
? "Your account was issued a one-time password. Choose a new one to continue."
: "Update your console password."
? t("change_password_subtitle_forced")
: t("change_password_subtitle_voluntary")
}
>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current">Current password</Label>
<Label htmlFor="current">{t("current_password")}</Label>
<Input
id="current"
type="password"
@@ -92,7 +94,7 @@ export function ChangePassword() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New password</Label>
<Label htmlFor="new-password">{t("new_password")}</Label>
<Input
id="new-password"
type="password"
@@ -103,12 +105,12 @@ export function ChangePassword() {
/>
{tooShort && (
<p className="text-xs text-muted-foreground">
At least {MIN_PASSWORD} characters.
{t("password_min_length", { min: MIN_PASSWORD })}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label>
<Label htmlFor="confirm-password">{t("confirm_new_password")}</Label>
<Input
id="confirm-password"
type="password"
@@ -118,12 +120,12 @@ export function ChangePassword() {
aria-invalid={mismatch ? true : undefined}
/>
{mismatch && (
<p className="text-xs text-destructive">Passwords don't match.</p>
<p className="text-xs text-destructive">{t("password_mismatch")}</p>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canSubmit}>
{submitting ? "Saving…" : "Change password"}
{submitting ? t("saving") : t("change_password_btn")}
</Button>
</form>
</CardContent>
+15 -12
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useMemo } from "react";
import { Link } from "react-router-dom";
import { Activity, Server, Play, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -41,6 +42,7 @@ function Stat({
export function Dashboard() {
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("dashboard");
const counts = useMemo(() => {
const servers = data ?? [];
@@ -55,8 +57,8 @@ export function Dashboard() {
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
{loading && !data ? (
@@ -79,14 +81,15 @@ function FleetView({
counts: { total: number; running: number; players: number };
hasConfig: boolean;
}) {
const { t } = useTranslation("dashboard");
if (servers.length === 0) {
return (
<EmptyState
title="No servers yet"
hint="Create your first server or claim an unowned one."
title={t("no_servers_title")}
hint={t("no_servers_hint")}
>
<Link to="/servers">
<Button>Go to My servers</Button>
<Button>{t("go_to_my_servers")}</Button>
</Link>
</EmptyState>
);
@@ -95,29 +98,29 @@ function FleetView({
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} />
<Stat icon={Server} label={t("stat_servers")} value={counts.total} />
<Stat icon={Play} label={t("stat_running")} value={counts.running} />
<Stat icon={Activity} label={t("stat_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>
<CardTitle className="text-base">{t("fleet")}</CardTitle>
<Link to="/servers">
<Button variant="ghost" size="sm">
Manage <Hand className="h-3.5 w-3.5" />
{t("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…" />}>
<Suspense fallback={<Loading label={t("loading_scene")} />}>
<VoxelFleet servers={servers} />
</Suspense>
</div>
) : (
<Loading label="Preparing scene…" />
<Loading label={t("preparing_scene")} />
)}
</CardContent>
</Card>
+8 -6
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import { api, humanizeError } from "@/lib/api";
export function Login() {
const { loading, identity, mustChangePassword, refresh } = useTier();
const navigate = useNavigate();
const { t } = useTranslation("auth");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
@@ -30,10 +32,10 @@ export function Login() {
// would briefly see a login form before being redirected away.
if (loading) {
return (
<AuthLayout title="Felis">
<AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
{t("common:loading")}
</div>
</AuthLayout>
);
@@ -59,12 +61,12 @@ export function Login() {
}
return (
<AuthLayout title="Sign in to Felis" subtitle="Operator console">
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<Card>
<CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
value={username}
@@ -78,7 +80,7 @@ export function Login() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
@@ -94,7 +96,7 @@ export function Login() {
className="w-full"
disabled={submitting || !username.trim() || !password}
>
{submitting ? "Signing in…" : "Sign in"}
{submitting ? t("signing_in") : t("sign_in")}
</Button>
</form>
</CardContent>
+8 -8
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard";
import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -10,14 +11,15 @@ import { useAsync, useConfig } from "@/lib/hooks";
export function MyServers() {
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("servers");
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("my_servers_title")}</h1>
<p className="text-sm text-muted-foreground">
Wake, stop, or claim the servers you can manage.
{t("my_servers_subtitle")}
</p>
</div>
@@ -26,11 +28,11 @@ export function MyServers() {
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading label="Loading config…" />
<Loading label={t("common: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."
title={t("no_servers_linked")}
hint={t("no_servers_hint")}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -42,9 +44,7 @@ export function MyServers() {
<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).
{t("my_servers_footer")}
</CardContent>
</Card>
</div>
+18 -17
View File
@@ -1,5 +1,6 @@
import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge";
@@ -15,37 +16,38 @@ import type { Phase } from "@/lib/types";
// 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 } {
function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } {
const { t } = useTranslation("servers");
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.",
title: t("server_asleep_title"),
body: t("server_asleep_body"),
};
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.",
title: t("server_shutting_down_title"),
body: t("server_shutting_down_body"),
};
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.",
title: t("server_failed_title"),
body: t("server_failed_body"),
};
default:
return {
icon: HelpCircle,
title: "Console is offline",
body: "The live console attaches automatically as soon as the server is running.",
title: t("console_offline_title"),
body: t("console_offline_body"),
};
}
}
function NotStreaming({ phase }: { phase: Phase }) {
const { icon: Icon, title, body } = notStreamingCopy(phase);
const { icon: Icon, title, body } = useNotStreamingCopy(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" />
@@ -60,6 +62,7 @@ function NotStreaming({ phase }: { phase: Phase }) {
export function ServerConsole() {
const { name = "" } = useParams();
const cfg = useConfig();
const { t } = useTranslation("servers");
const { data, error, loading, reload } = useAsync(
() => api.status(name),
[name],
@@ -78,7 +81,7 @@ export function ServerConsole() {
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
<ArrowLeft className="h-4 w-4" /> {t("my_servers_breadcrumb")}
</Link>
{loading && !data ? (
@@ -106,11 +109,11 @@ export function ServerConsole() {
size="sm"
onClick={() => api.stop(name).then(reload)}
>
Stop
{t("stop")}
</Button>
) : (
<Button size="sm" onClick={() => api.wake(name).then(reload)}>
Wake
{t("wake")}
</Button>
)}
</div>
@@ -119,12 +122,10 @@ export function ServerConsole() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Terminal className="h-4 w-4" /> Console
<Terminal className="h-4 w-4" /> {t("console_card_title")}
</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.
{t("console_card_desc")}
</p>
</CardHeader>
<CardContent>
+17 -17
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { ServerCog, Boxes, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { useTier } from "@/lib/tier";
@@ -7,33 +8,34 @@ import { useTier } from "@/lib/tier";
// 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 = [
export function AdminHome() {
const { identity } = useTier();
const { t } = useTranslation("admin");
const LINKS = [
{
to: "/admin/servers",
icon: ServerCog,
title: "Servers",
body: "Create platform servers from the structured §15 form and manage the ones you operate.",
title: t("servers"),
body: t("servers_desc"),
},
{
to: "/admin/images",
icon: Boxes,
title: "Images",
body: "The platform image whitelist — the value space the create form draws from.",
title: t("images"),
body: t("images_desc"),
},
];
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">
Server &amp; content administration
{identity?.email ? ` · signed in as ${identity.email}` : ""}.
{t("subtitle")}
{identity?.email ? t("signed_in_as", { email: identity.email }) : ""}.
</p>
</div>
</div>
@@ -54,11 +56,9 @@ export function AdminHome() {
<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.
{t("footer_pre")}
<span className="font-medium">{t("footer_kubectl")}</span>
{t("footer_post")}
</CardContent>
</Card>
</div>
+8 -8
View File
@@ -1,4 +1,5 @@
import { Boxes, CheckCircle2, CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -11,17 +12,16 @@ import { useAsync } from "@/lib/hooks";
// added, so this page surfaces the whitelist honestly and labels the missing
// mutation rather than shipping dead buttons.
export function ImageAdmin() {
const { t } = useTranslation("admin");
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("images_title")}</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.
{t("images_subtitle")}
</p>
</div>
@@ -31,8 +31,8 @@ export function ImageAdmin() {
<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)."
title={t("no_images_title")}
hint={t("no_images_hint")}
/>
) : (
<div className="space-y-2">
@@ -50,11 +50,11 @@ export function ImageAdmin() {
</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
<CheckCircle2 className="h-3.5 w-3.5" /> {t("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
<CircleSlash className="h-3.5 w-3.5" /> {t("disabled")}
</span>
)}
</CardContent>
+9 -10
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard";
import { CreateServerDialog } from "@/components/CreateServerDialog";
@@ -17,16 +18,17 @@ import { useAsync, useConfig } from "@/lib/hooks";
export function ServerAdmin() {
const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("admin");
const { t: ts } = useTranslation("servers");
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("servers")}</h1>
<p className="text-sm text-muted-foreground">
Create platform servers from the structured form and manage the ones
you operate.
{t("server_admin_subtitle")}
</p>
</div>
{cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
@@ -37,11 +39,11 @@ export function ServerAdmin() {
) : error ? (
<ErrorState error={error} onRetry={reload} />
) : !cfg ? (
<Loading label="Loading config…" />
<Loading label={ts("common:loading_config")} />
) : servers.length === 0 ? (
<EmptyState
title="No servers under your management yet"
hint="Use Create server to provision one from the vetted spec."
title={ts("no_servers_managed")}
hint={ts("no_servers_managed_hint")}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -53,10 +55,7 @@ export function ServerAdmin() {
<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).
{ts("server_admin_footer")}
</CardContent>
</Card>
</div>
+5 -3
View File
@@ -1,4 +1,5 @@
import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PendingBackend } from "@/components/States";
// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with
@@ -6,21 +7,22 @@ import { PendingBackend } from "@/components/States";
// 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() {
const { t } = useTranslation("ops");
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
<p className="text-sm text-muted-foreground">
Every server on the platform — phase, owner, capacity.
{t("fleet_subtitle")}
</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."
note={t("fleet_table_pending_note")}
/>
</div>
);
+10 -9
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom";
import { Gauge, Network, ServerOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { PendingBackend } from "@/components/States";
@@ -11,14 +12,15 @@ import { PendingBackend } from "@/components/States";
// 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() {
const { t } = useTranslation("ops");
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>
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">
Platform observability cockpit — a window, not a lever.
{t("subtitle")}
</p>
</div>
</div>
@@ -26,17 +28,17 @@ export function OpsOverview() {
<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
<Network className="h-4 w-4 text-primary" /> {t("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."
note={t("ops_overview_pending_note")}
/>
<Link
to="/ops/fleet"
className="inline-block text-sm font-medium text-primary hover:underline"
>
Open fleet table →
{t("open_fleet_table")}
</Link>
</CardContent>
</Card>
@@ -45,10 +47,9 @@ export function OpsOverview() {
<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.
{t("footer_pre")}
<span className="font-medium">{t("footer_kubectl")}</span>
{t("footer_post")}
</span>
</CardContent>
</Card>
+3
View File
@@ -21,5 +21,8 @@ export default defineConfig(async ({ mode }) => {
"/api": { target: "http://localhost:8080", changeOrigin: true },
},
},
test: {
setupFiles: ["./vitest.setup.ts"],
},
};
});
+4
View File
@@ -0,0 +1,4 @@
import i18next from "i18next";
import "./src/i18n";
i18next.changeLanguage("en-US");