From dd2fc6fa82e6d966f91f4b84e96ed2ad5d6e43b4 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sat, 27 Jun 2026 18:31:26 +0800 Subject: [PATCH] feat(panel): i18n --- panel/package-lock.json | 105 +++++++++++++++++- panel/package.json | 3 + panel/src/components/AppShell.tsx | 48 ++++++-- panel/src/components/AuthLayout.tsx | 9 +- panel/src/components/CreateServerDialog.tsx | 60 +++++----- panel/src/components/LogConsole.tsx | 23 ++-- panel/src/components/PhaseBadge.tsx | 13 ++- panel/src/components/RequireAdmin.tsx | 4 +- panel/src/components/RequireAuth.tsx | 4 +- panel/src/components/ServerCard.tsx | 12 +- panel/src/components/States.tsx | 23 ++-- panel/src/components/ui/dialog.tsx | 10 +- panel/src/i18n/index.ts | 63 +++++++++++ panel/src/i18n/resources/en-US/account.json | 21 ++++ panel/src/i18n/resources/en-US/admin.json | 19 ++++ panel/src/i18n/resources/en-US/auth.json | 18 +++ panel/src/i18n/resources/en-US/common.json | 22 ++++ panel/src/i18n/resources/en-US/dashboard.json | 14 +++ panel/src/i18n/resources/en-US/errors.json | 18 +++ .../src/i18n/resources/en-US/navigation.json | 12 ++ panel/src/i18n/resources/en-US/ops.json | 13 +++ panel/src/i18n/resources/en-US/servers.json | 68 ++++++++++++ panel/src/i18n/resources/zh-CN/account.json | 21 ++++ panel/src/i18n/resources/zh-CN/admin.json | 19 ++++ panel/src/i18n/resources/zh-CN/auth.json | 18 +++ panel/src/i18n/resources/zh-CN/common.json | 22 ++++ panel/src/i18n/resources/zh-CN/dashboard.json | 14 +++ panel/src/i18n/resources/zh-CN/errors.json | 18 +++ .../src/i18n/resources/zh-CN/navigation.json | 12 ++ panel/src/i18n/resources/zh-CN/ops.json | 13 +++ panel/src/i18n/resources/zh-CN/servers.json | 68 ++++++++++++ panel/src/lib/api.ts | 34 +++--- panel/src/lib/nav.ts | 28 ++--- panel/src/main.tsx | 1 + panel/src/pages/Account.tsx | 48 ++++---- panel/src/pages/ChangePassword.tsx | 24 ++-- panel/src/pages/Dashboard.tsx | 27 +++-- panel/src/pages/Login.tsx | 14 ++- panel/src/pages/MyServers.tsx | 16 +-- panel/src/pages/ServerConsole.tsx | 35 +++--- panel/src/pages/admin/AdminHome.tsx | 46 ++++---- panel/src/pages/admin/ImageAdmin.tsx | 16 +-- panel/src/pages/admin/ServerAdmin.tsx | 19 ++-- panel/src/pages/ops/FleetTable.tsx | 8 +- panel/src/pages/ops/OpsOverview.tsx | 19 ++-- panel/vite.config.ts | 3 + panel/vitest.setup.ts | 4 + 47 files changed, 898 insertions(+), 231 deletions(-) create mode 100644 panel/src/i18n/index.ts create mode 100644 panel/src/i18n/resources/en-US/account.json create mode 100644 panel/src/i18n/resources/en-US/admin.json create mode 100644 panel/src/i18n/resources/en-US/auth.json create mode 100644 panel/src/i18n/resources/en-US/common.json create mode 100644 panel/src/i18n/resources/en-US/dashboard.json create mode 100644 panel/src/i18n/resources/en-US/errors.json create mode 100644 panel/src/i18n/resources/en-US/navigation.json create mode 100644 panel/src/i18n/resources/en-US/ops.json create mode 100644 panel/src/i18n/resources/en-US/servers.json create mode 100644 panel/src/i18n/resources/zh-CN/account.json create mode 100644 panel/src/i18n/resources/zh-CN/admin.json create mode 100644 panel/src/i18n/resources/zh-CN/auth.json create mode 100644 panel/src/i18n/resources/zh-CN/common.json create mode 100644 panel/src/i18n/resources/zh-CN/dashboard.json create mode 100644 panel/src/i18n/resources/zh-CN/errors.json create mode 100644 panel/src/i18n/resources/zh-CN/navigation.json create mode 100644 panel/src/i18n/resources/zh-CN/ops.json create mode 100644 panel/src/i18n/resources/zh-CN/servers.json create mode 100644 panel/vitest.setup.ts diff --git a/panel/package-lock.json b/panel/package-lock.json index f9bd340..db7f3c1 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -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", diff --git a/panel/package.json b/panel/package.json index e7ee901..3d94e7f 100644 --- a/panel/package.json +++ b/panel/package.json @@ -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" diff --git a/panel/src/components/AppShell.tsx b/panel/src/components/AppShell.tsx index 83c5a1c..46c6c07 100644 --- a/panel/src/components/AppShell.tsx +++ b/panel/src/components/AppShell.tsx @@ -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 ( + + ); +} 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 (
- Felis + {t("common:brand_name")}
diff --git a/panel/src/components/AuthLayout.tsx b/panel/src/components/AuthLayout.tsx index 0b900b5..1a21758 100644 --- a/panel/src/components/AuthLayout.tsx +++ b/panel/src/components/AuthLayout.tsx @@ -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 (
diff --git a/panel/src/components/CreateServerDialog.tsx b/panel/src/components/CreateServerDialog.tsx index 175a99e..439d0fb 100644 --- a/panel/src/components/CreateServerDialog.tsx +++ b/panel/src/components/CreateServerDialog.tsx @@ -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 ( - Create a server + {t("create_server_title")} - Pick from whitelisted images and sizes — the platform provisions the - rest. No raw cluster config is exposed here. + {t("create_server_desc")}
- + set("name", e.target.value)} />
- + set("subdomain", e.target.value.toLowerCase())} />

{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) })}

- + set("displayName", e.target.value)} />
- + set("memory", v)}> @@ -187,7 +193,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
- + set("autostartPolicy", v as AutostartPolicy)} @@ -213,7 +219,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { - {POLICIES.map((p) => ( + {policies.map((p) => ( {p.label} @@ -227,10 +233,10 @@ export function CreateServerDialog({ cfg, onCreated }: Props) { diff --git a/panel/src/components/LogConsole.tsx b/panel/src/components/LogConsole.tsx index 738284f..fa28e56 100644 --- a/panel/src/components/LogConsole.tsx +++ b/panel/src/components/LogConsole.tsx @@ -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 = { const PIN_THRESHOLD_PX = 24; function StatusIndicator({ status }: { status: StreamStatus }) { + const { t } = useTranslation("servers"); const map: Record = { - 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(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" > - Reconnect + {t("log_reconnect_btn")} )}
@@ -106,13 +109,13 @@ export function LogConsole({ url }: { url: string }) { {lines.length === 0 ? (

{status === "ended" - ? "Stream ended — reconnect to resume following the log." - : "Waiting for output…"} + ? t("log_ended_empty") + : t("log_waiting")}

) : ( lines.map((line) => (
- {line.text || " "} + {line.text || "\u00A0"}
)) )} @@ -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" > - Jump to latest + {t("log_jump_latest")} )}
diff --git a/panel/src/components/PhaseBadge.tsx b/panel/src/components/PhaseBadge.tsx index 62f2efc..15c1b1e 100644 --- a/panel/src/components/PhaseBadge.tsx +++ b/panel/src/components/PhaseBadge.tsx @@ -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 = { Unknown: "#94a3b8", }; +export const PHASE_KEY: Record = { + 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 = { Running: "default", Starting: "outline", @@ -43,6 +53,7 @@ export function phaseVariant(phase: Phase): BadgeVariant { } export function PhaseBadge({ phase }: { phase: Phase }) { + const { t } = useTranslation(); return ( - {phase} + {t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)} ); } diff --git a/panel/src/components/RequireAdmin.tsx b/panel/src/components/RequireAdmin.tsx index 1eb9ddd..9e4d4c3 100644 --- a/panel/src/components/RequireAdmin.tsx +++ b/panel/src/components/RequireAdmin.tsx @@ -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 ; + const { t } = useTranslation("common"); + if (loading) return ; return isAdmin ? : ; } diff --git a/panel/src/components/RequireAuth.tsx b/panel/src/components/RequireAuth.tsx index 63b39c4..c6c4de2 100644 --- a/panel/src/components/RequireAuth.tsx +++ b/panel/src/components/RequireAuth.tsx @@ -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 (
- Loading… + {t("loading")}
); } diff --git a/panel/src/components/ServerCard.tsx b/panel/src/components/ServerCard.tsx index ffa2187..4d6114a 100644 --- a/panel/src/components/ServerCard.tsx +++ b/panel/src/components/ServerCard.tsx @@ -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); const [error, setError] = useState(null); const host = hostFor(server.subdomain, cfg); @@ -65,7 +67,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) { {running ? ( {server.players ?? 0} - {server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online + {server.maxPlayers ? ` / ${server.maxPlayers}` : ""} {t("online")} ) : ( — @@ -78,7 +80,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) { {server.owned && ( )} @@ -89,7 +91,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) { disabled={busy !== null} onClick={() => act("claim", () => api.claim(server.name))} > - {busy === "claim" ? "Claiming…" : "Claim"} + {busy === "claim" ? t("claiming") : t("claim")} )} {server.owned && @@ -100,7 +102,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) { disabled={busy !== null} onClick={() => act("stop", () => api.stop(server.name))} > - {busy === "stop" ? "Stopping…" : "Stop"} + {busy === "stop" ? t("stopping") : t("stop")} ) : ( ))} diff --git a/panel/src/components/States.tsx b/panel/src/components/States.tsx index 9fe26c7..4a20197 100644 --- a/panel/src/components/States.tsx +++ b/panel/src/components/States.tsx @@ -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 (
- {label} + {label ?? t("loading")}
); } export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) { + const { t } = useTranslation("common"); return (
@@ -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")} )}
@@ -50,19 +53,18 @@ export function EmptyState({ } export function NotAuthorized() { + const { t } = useTranslation("common"); return (
-

Not authorized

+

{t("not_authorized_title")}

- 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")}

- Back to your dashboard + {t("back_to_dashboard")}
); @@ -76,14 +78,15 @@ export function NotAuthorized() { * data. */ export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) { + const { t } = useTranslation("common"); return (
-

Not wired yet

+

{t("not_wired_yet")}

{note}

- pending backend: {endpoint} + {t("pending_backend_prefix")}{endpoint}

diff --git a/panel/src/components/ui/dialog.tsx b/panel/src/components/ui/dialog.tsx index 7937fcd..d9ea74f 100644 --- a/panel/src/components/ui/dialog.tsx +++ b/panel/src/components/ui/dialog.tsx @@ -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, React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +>(({ className, children, ...props }, ref) => { + const { t } = useTranslation("common"); + return ( - Close + {t("close_sr")} -)); + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; export function DialogHeader({ diff --git a/panel/src/i18n/index.ts b/panel/src/i18n/index.ts new file mode 100644 index 0000000..0b60d97 --- /dev/null +++ b/panel/src/i18n/index.ts @@ -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; diff --git a/panel/src/i18n/resources/en-US/account.json b/panel/src/i18n/resources/en-US/account.json new file mode 100644 index 0000000..cd7fc06 --- /dev/null +++ b/panel/src/i18n/resources/en-US/account.json @@ -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…" +} diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json new file mode 100644 index 0000000..38feee1 --- /dev/null +++ b/panel/src/i18n/resources/en-US/admin.json @@ -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." +} diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json new file mode 100644 index 0000000..38a688b --- /dev/null +++ b/panel/src/i18n/resources/en-US/auth.json @@ -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…" +} diff --git a/panel/src/i18n/resources/en-US/common.json b/panel/src/i18n/resources/en-US/common.json new file mode 100644 index 0000000..6de3d5f --- /dev/null +++ b/panel/src/i18n/resources/en-US/common.json @@ -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}}" +} diff --git a/panel/src/i18n/resources/en-US/dashboard.json b/panel/src/i18n/resources/en-US/dashboard.json new file mode 100644 index 0000000..d7dd7fd --- /dev/null +++ b/panel/src/i18n/resources/en-US/dashboard.json @@ -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…" +} diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json new file mode 100644 index 0000000..0d20c02 --- /dev/null +++ b/panel/src/i18n/resources/en-US/errors.json @@ -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." +} diff --git a/panel/src/i18n/resources/en-US/navigation.json b/panel/src/i18n/resources/en-US/navigation.json new file mode 100644 index 0000000..f31ea9e --- /dev/null +++ b/panel/src/i18n/resources/en-US/navigation.json @@ -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" +} diff --git a/panel/src/i18n/resources/en-US/ops.json b/panel/src/i18n/resources/en-US/ops.json new file mode 100644 index 0000000..17a5d01 --- /dev/null +++ b/panel/src/i18n/resources/en-US/ops.json @@ -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." +} diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json new file mode 100644 index 0000000..2237c85 --- /dev/null +++ b/panel/src/i18n/resources/en-US/servers.json @@ -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)." +} diff --git a/panel/src/i18n/resources/zh-CN/account.json b/panel/src/i18n/resources/zh-CN/account.json new file mode 100644 index 0000000..a825b57 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/account.json @@ -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": "验证中…" +} diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json new file mode 100644 index 0000000..97e5599 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -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": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。" +} diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json new file mode 100644 index 0000000..b8912bd --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -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": "保存中…" +} diff --git a/panel/src/i18n/resources/zh-CN/common.json b/panel/src/i18n/resources/zh-CN/common.json new file mode 100644 index 0000000..3f5fd48 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/common.json @@ -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}}" +} diff --git a/panel/src/i18n/resources/zh-CN/dashboard.json b/panel/src/i18n/resources/zh-CN/dashboard.json new file mode 100644 index 0000000..b119375 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/dashboard.json @@ -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": "正在准备场景…" +} diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json new file mode 100644 index 0000000..d8bf1d1 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -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": "出了点问题,请稍后重试。" +} diff --git a/panel/src/i18n/resources/zh-CN/navigation.json b/panel/src/i18n/resources/zh-CN/navigation.json new file mode 100644 index 0000000..55bb605 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/navigation.json @@ -0,0 +1,12 @@ +{ + "dashboard": "仪表盘", + "my_servers": "我的服务器", + "account": "账户", + "admin_section": "管理", + "admin_overview": "概览", + "admin_servers": "服务器", + "admin_images": "镜像", + "sysadmin_section": "系统管理", + "sysadmin_overview": "概览", + "sysadmin_fleet": "全平台服务器" +} diff --git a/panel/src/i18n/resources/zh-CN/ops.json b/panel/src/i18n/resources/zh-CN/ops.json new file mode 100644 index 0000000..915f892 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/ops.json @@ -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 不同。" +} diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json new file mode 100644 index 0000000..f41ca41 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -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)。" +} diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index d519397..05a4d8e 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -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; + 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"); } } diff --git a/panel/src/lib/nav.ts b/panel/src/lib/nav.ts index 009487f..f015a7d 100644 --- a/panel/src/lib/nav.ts +++ b/panel/src/lib/nav.ts @@ -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 }, ], }, ]; diff --git a/panel/src/main.tsx b/panel/src/main.tsx index 27481e0..25d005a 100644 --- a/panel/src/main.tsx +++ b/panel/src/main.tsx @@ -1,5 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import "./i18n"; import App from "./App"; import "./index.css"; diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index f42de07..39283be 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -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 (
-

Account

+

{t("title")}

- Identity and Minecraft linking. + {t("subtitle")}

- Session + {t("session")} -

- 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). -

+

{t("session_desc")}

@@ -100,14 +98,14 @@ export function Account() { - Minecraft link + {t("minecraft_link")} {linked ? ( ) : status.loading && !status.data ? ( - + ) : status.error ? ( ) : ( @@ -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 (
- Your Minecraft account is linked. + {t("linked_title")}
-

- Linked sessions can claim and own servers — ownership operations (claim, - wake, stop) are unblocked. -

+

{t("linked_desc")}

{uuid && (
- UUID + {t("uuid_label")} {uuid} @@ -167,34 +163,34 @@ function LinkForm({ error: string | null; onSubmit: (e: FormEvent) => void; }) { + const { t } = useTranslation("account"); return (
  1. -

    Get a code in-game

    +

    {t("step1_title")}

    - Join any server and run{" "} + {t("step1_desc_prefix")} /link - {" "} - in chat. The server already knows your verified identity, so it hands - you a one-time code (valid about 10 minutes). + + {t("step1_desc_suffix")}

  2. -

    Enter it here

    +

    {t("step2_title")}

    - +
    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")}
    {error &&

    {error}

    } diff --git a/panel/src/pages/ChangePassword.tsx b/panel/src/pages/ChangePassword.tsx index 6376b9f..42ba047 100644 --- a/panel/src/pages/ChangePassword.tsx +++ b/panel/src/pages/ChangePassword.tsx @@ -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 ( - +
    - Loading… + {t("common:loading")}
    ); @@ -69,18 +71,18 @@ export function ChangePassword() { return (
    - +
    - + {tooShort && (

    - At least {MIN_PASSWORD} characters. + {t("password_min_length", { min: MIN_PASSWORD })}

    )}
    - + {mismatch && ( -

    Passwords don't match.

    +

    {t("password_mismatch")}

    )}
    {error &&

    {error}

    }
    diff --git a/panel/src/pages/Dashboard.tsx b/panel/src/pages/Dashboard.tsx index 495a577..f3c1fa7 100644 --- a/panel/src/pages/Dashboard.tsx +++ b/panel/src/pages/Dashboard.tsx @@ -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 (
    -

    Dashboard

    -

    Your fleet at a glance.

    +

    {t("title")}

    +

    {t("subtitle")}

    {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 ( - + ); @@ -95,29 +98,29 @@ function FleetView({ return ( <>
    - - - + + +
    - Fleet + {t("fleet")} {hasConfig ? (
    - }> + }>
    ) : ( - + )}
    diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index 69d2e6b..7adfa0c 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -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 ( - +
    - Loading… + {t("common:loading")}
    ); @@ -59,12 +61,12 @@ export function Login() { } return ( - +
    - +
    - + - {submitting ? "Signing in…" : "Sign in"} + {submitting ? t("signing_in") : t("sign_in")} diff --git a/panel/src/pages/MyServers.tsx b/panel/src/pages/MyServers.tsx index b51243e..2cbb78e 100644 --- a/panel/src/pages/MyServers.tsx +++ b/panel/src/pages/MyServers.tsx @@ -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 (
    -

    My servers

    +

    {t("my_servers_title")}

    - Wake, stop, or claim the servers you can manage. + {t("my_servers_subtitle")}

    @@ -26,11 +28,11 @@ export function MyServers() { ) : error ? ( ) : !cfg ? ( - + ) : servers.length === 0 ? ( ) : (
    @@ -42,9 +44,7 @@ export function MyServers() { - 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")}
    diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index a2c5b7b..bcf4265 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -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 (
    @@ -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" > - My servers + {t("my_servers_breadcrumb")} {loading && !data ? ( @@ -106,11 +109,11 @@ export function ServerConsole() { size="sm" onClick={() => api.stop(name).then(reload)} > - Stop + {t("stop")} ) : ( )}
    @@ -119,12 +122,10 @@ export function ServerConsole() { - Console + {t("console_card_title")}

    - 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")}

    diff --git a/panel/src/pages/admin/AdminHome.tsx b/panel/src/pages/admin/AdminHome.tsx index 632aefb..f2dffce 100644 --- a/panel/src/pages/admin/AdminHome.tsx +++ b/panel/src/pages/admin/AdminHome.tsx @@ -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 = [ - { - 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(); + const { t } = useTranslation("admin"); + + const LINKS = [ + { + to: "/admin/servers", + icon: ServerCog, + title: t("servers"), + body: t("servers_desc"), + }, + { + to: "/admin/images", + icon: Boxes, + title: t("images"), + body: t("images_desc"), + }, + ]; return (
    -

    Admin

    +

    {t("title")}

    - Server & content administration - {identity?.email ? ` · signed in as ${identity.email}` : ""}. + {t("subtitle")} + {identity?.email ? t("signed_in_as", { email: identity.email }) : ""}.

    @@ -54,11 +56,9 @@ export function AdminHome() { - Cluster scaling, RBAC, Secrets and control-plane lifecycle are{" "} - kubectl / CRD operations 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")} + {t("footer_kubectl")} + {t("footer_post")}
    diff --git a/panel/src/pages/admin/ImageAdmin.tsx b/panel/src/pages/admin/ImageAdmin.tsx index f216e89..81a9adc 100644 --- a/panel/src/pages/admin/ImageAdmin.tsx +++ b/panel/src/pages/admin/ImageAdmin.tsx @@ -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 (
    -

    Images

    +

    {t("images_title")}

    - 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")}

    @@ -31,8 +31,8 @@ export function ImageAdmin() { ) : images.length === 0 ? ( ) : (
    @@ -50,11 +50,11 @@ export function ImageAdmin() {
    {img.enabled ? ( - enabled + {t("enabled")} ) : ( - disabled + {t("disabled")} )} diff --git a/panel/src/pages/admin/ServerAdmin.tsx b/panel/src/pages/admin/ServerAdmin.tsx index 21e81ff..37306e2 100644 --- a/panel/src/pages/admin/ServerAdmin.tsx +++ b/panel/src/pages/admin/ServerAdmin.tsx @@ -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 (
    -

    Servers

    +

    {t("servers")}

    - Create platform servers from the structured form and manage the ones - you operate. + {t("server_admin_subtitle")}

    {cfg && } @@ -37,11 +39,11 @@ export function ServerAdmin() { ) : error ? ( ) : !cfg ? ( - + ) : servers.length === 0 ? ( ) : (
    @@ -53,10 +55,7 @@ export function ServerAdmin() { - 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")}
    diff --git a/panel/src/pages/ops/FleetTable.tsx b/panel/src/pages/ops/FleetTable.tsx index 71f4d85..52abdeb 100644 --- a/panel/src/pages/ops/FleetTable.tsx +++ b/panel/src/pages/ops/FleetTable.tsx @@ -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 (
    -

    Fleet

    +

    {t("fleet_title")}

    - Every server on the platform — phase, owner, capacity. + {t("fleet_subtitle")}

    ); diff --git a/panel/src/pages/ops/OpsOverview.tsx b/panel/src/pages/ops/OpsOverview.tsx index 90091b4..862cc86 100644 --- a/panel/src/pages/ops/OpsOverview.tsx +++ b/panel/src/pages/ops/OpsOverview.tsx @@ -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 (
    -

    SysAdmin

    +

    {t("title")}

    - Platform observability cockpit — a window, not a lever. + {t("subtitle")}

    @@ -26,17 +28,17 @@ export function OpsOverview() {
    - Cluster-wide fleet + {t("cluster_wide_fleet")}
    - Open fleet table → + {t("open_fleet_table")}
    @@ -45,10 +47,9 @@ export function OpsOverview() { - Control-plane scaling, RBAC, Secrets and cluster lifecycle are{" "} - kubectl / CRD operations and are not - reachable from here. SysAdmin-Side is observability only — the - four-power separation (build / runtime / operator / app) is preserved. + {t("footer_pre")} + {t("footer_kubectl")} + {t("footer_post")} diff --git a/panel/vite.config.ts b/panel/vite.config.ts index a7516f9..b43ecbc 100644 --- a/panel/vite.config.ts +++ b/panel/vite.config.ts @@ -21,5 +21,8 @@ export default defineConfig(async ({ mode }) => { "/api": { target: "http://localhost:8080", changeOrigin: true }, }, }, + test: { + setupFiles: ["./vitest.setup.ts"], + }, }; }); diff --git a/panel/vitest.setup.ts b/panel/vitest.setup.ts new file mode 100644 index 0000000..22f56de --- /dev/null +++ b/panel/vitest.setup.ts @@ -0,0 +1,4 @@ +import i18next from "i18next"; +import "./src/i18n"; + +i18next.changeLanguage("en-US");