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", "@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.3.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"three": "^0.172.0" "three": "^0.172.0"
@@ -283,6 +286,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2750,6 +2762,52 @@
"node": ">= 0.4" "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": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -3301,6 +3359,33 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3815,7 +3900,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4122,6 +4216,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "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", "@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.3.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"three": "^0.172.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 { 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 { cn } from "@/lib/utils";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { visibleSections } from "@/lib/nav"; 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() { export function AppShell() {
const { isAdmin } = useTier(); const { isAdmin } = useTier();
const { t, i18n } = useTranslation("navigation");
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin // 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), // only for admins. isAdmin is fail-closed (false while /me loads or on failure),
// so admin sections appear only once identity is confirmed. // so admin sections appear only once identity is confirmed.
const sections = visibleSections(isAdmin); 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 ( return (
<div className="flex min-h-screen"> <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"> <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"> <div className="mb-6 flex items-center gap-2 px-2">
<Cat className="h-6 w-6 text-primary" /> <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> </div>
<nav className="flex flex-col gap-4"> <nav className="flex flex-col gap-4">
{sections.map((section) => ( {sections.map((section) => (
<div key={section.id} className="flex flex-col gap-1"> <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"> <div className="px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
{section.title} {t(section.titleKey)}
</div> </div>
)} )}
{section.items.map((item) => ( {section.items.map((item) => (
@@ -42,22 +69,27 @@ export function AppShell() {
} }
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
{item.label} {t(item.key)}
</NavLink> </NavLink>
))} ))}
</div> </div>
))} ))}
</nav> </nav>
<div className="mt-auto px-3 pt-4 text-xs text-muted-foreground"> <div className="mt-auto px-3 pt-4 border-t border-border/60">
K8s-native Minecraft orchestration <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> </div>
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col"> <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"> <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" /> <Cat className="h-5 w-5 text-primary" />
<span className="font-semibold">Felis</span> <span className="font-semibold">{t("common:brand_name")}</span>
</header> </header>
<main className="flex-1 p-6"> <main className="flex-1 p-6">
<Outlet /> <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 { Cat } from "lucide-react";
import { useTranslation } from "react-i18next";
// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change). // 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 — // These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
@@ -13,6 +14,12 @@ export function AuthLayout({
subtitle?: string; subtitle?: string;
children: ReactNode; children: ReactNode;
}) { }) {
const { i18n } = useTranslation("common");
useEffect(() => {
document.documentElement.lang = i18n.language;
document.title = title;
}, [i18n.language, title]);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12"> <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"> <div className="w-full max-w-sm space-y-6">
+33 -27
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -32,20 +33,24 @@ import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"]; 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])?$/; 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 { interface Props {
cfg: RuntimeConfig; cfg: RuntimeConfig;
onCreated: () => void; onCreated: () => void;
} }
export function CreateServerDialog({ cfg, onCreated }: Props) { export function CreateServerDialog({ cfg, onCreated }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const images = useAsync(() => api.listImages(), []); const images = useAsync(() => api.listImages(), []);
@@ -94,69 +99,70 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
} }
} }
const policies = policyOptions(t);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<Plus /> New server <Plus /> {t("create_server_btn")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create a server</DialogTitle> <DialogTitle>{t("create_server_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Pick from whitelisted images and sizes — the platform provisions the {t("create_server_desc")}
rest. No raw cluster config is exposed here.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cs-name">Name</Label> <Label htmlFor="cs-name">{t("create_server_name")}</Label>
<Input <Input
id="cs-name" id="cs-name"
placeholder="survival" placeholder={t("create_server_name_placeholder")}
value={form.name} value={form.name}
onChange={(e) => set("name", e.target.value)} onChange={(e) => set("name", e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cs-sub">Subdomain</Label> <Label htmlFor="cs-sub">{t("create_server_subdomain")}</Label>
<Input <Input
id="cs-sub" id="cs-sub"
placeholder="survival" placeholder={t("create_server_subdomain_placeholder")}
value={form.subdomain} value={form.subdomain}
onChange={(e) => set("subdomain", e.target.value.toLowerCase())} onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{form.subdomain && !subdomainValid {form.subdomain && !subdomainValid
? "Lowercase letters, digits and hyphens only." ? t("create_server_subdomain_invalid")
: `Will be reachable at ${hostFor(form.subdomain || "name", cfg)}`} : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
</p> </p>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cs-display">Display name (optional)</Label> <Label htmlFor="cs-display">{t("create_server_display_name")}</Label>
<Input <Input
id="cs-display" id="cs-display"
placeholder="Survival World" placeholder={t("create_server_display_name_placeholder")}
value={form.displayName ?? ""} value={form.displayName ?? ""}
onChange={(e) => set("displayName", e.target.value)} onChange={(e) => set("displayName", e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Image</Label> <Label>{t("create_server_image")}</Label>
<Select value={form.image} onValueChange={(v) => set("image", v)}> <Select value={form.image} onValueChange={(v) => set("image", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
placeholder={ placeholder={
images.loading images.loading
? "Loading images…" ? t("create_server_image_loading")
: enabledImages.length : enabledImages.length
? "Choose an image" ? t("create_server_image_choose")
: "No whitelisted images" : t("create_server_image_none")
} }
/> />
</SelectTrigger> </SelectTrigger>
@@ -172,7 +178,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Memory</Label> <Label>{t("create_server_memory")}</Label>
<Select value={form.memory} onValueChange={(v) => set("memory", v)}> <Select value={form.memory} onValueChange={(v) => set("memory", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -187,7 +193,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</Select> </Select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Storage</Label> <Label>{t("create_server_storage")}</Label>
<Select value={form.storage} onValueChange={(v) => set("storage", v)}> <Select value={form.storage} onValueChange={(v) => set("storage", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -204,7 +210,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Autostart policy</Label> <Label>{t("create_server_policy")}</Label>
<Select <Select
value={form.autostartPolicy} value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)} onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
@@ -213,7 +219,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{POLICIES.map((p) => ( {policies.map((p) => (
<SelectItem key={p.value} value={p.value}> <SelectItem key={p.value} value={p.value}>
{p.label} {p.label}
</SelectItem> </SelectItem>
@@ -227,10 +233,10 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}> <Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
Cancel {t("create_server_cancel")}
</Button> </Button>
<Button onClick={submit} disabled={!canSubmit}> <Button onClick={submit} disabled={!canSubmit}>
{submitting ? "Creating…" : "Create"} {submitting ? t("create_server_creating") : t("create_server_submit")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+13 -10
View File
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { ArrowDown, RotateCw, Trash2 } from "lucide-react"; import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useLogStream } from "@/lib/useLogStream"; import { useLogStream } from "@/lib/useLogStream";
@@ -19,11 +20,12 @@ const LEVEL_CLASS: Record<LogLevel, string> = {
const PIN_THRESHOLD_PX = 24; const PIN_THRESHOLD_PX = 24;
function StatusIndicator({ status }: { status: StreamStatus }) { function StatusIndicator({ status }: { status: StreamStatus }) {
const { t } = useTranslation("servers");
const map: Record<StreamStatus, { dot: string; label: string; text: string }> = { const map: Record<StreamStatus, { dot: string; label: string; text: string }> = {
connecting: { dot: "bg-amber-400 animate-pulse", label: "Connecting…", text: "text-amber-300" }, connecting: { dot: "bg-amber-400 animate-pulse", label: t("log_connecting"), text: "text-amber-300" },
open: { dot: "bg-emerald-400", label: "Live", text: "text-emerald-300" }, open: { dot: "bg-emerald-400", label: t("log_live"), text: "text-emerald-300" },
reconnecting: { dot: "bg-amber-400 animate-pulse", label: "Reconnecting…", text: "text-amber-300" }, reconnecting: { dot: "bg-amber-400 animate-pulse", label: t("log_reconnecting"), text: "text-amber-300" },
ended: { dot: "bg-zinc-500", label: "Stream ended", text: "text-zinc-400" }, ended: { dot: "bg-zinc-500", label: t("log_ended"), text: "text-zinc-400" },
}; };
const s = map[status]; const s = map[status];
return ( return (
@@ -46,6 +48,7 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
* scrolling back to the bottom re-pins. The buffer is bounded by the controller. * scrolling back to the bottom re-pins. The buffer is bounded by the controller.
*/ */
export function LogConsole({ url }: { url: string }) { export function LogConsole({ url }: { url: string }) {
const { t } = useTranslation("servers");
const { lines, status, clear, reconnect } = useLogStream(url); const { lines, status, clear, reconnect } = useLogStream(url);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true); const [pinned, setPinned] = useState(true);
@@ -81,7 +84,7 @@ export function LogConsole({ url }: { url: string }) {
onClick={reconnect} onClick={reconnect}
className="border-zinc-700 bg-transparent text-zinc-200 hover:bg-zinc-800 hover:text-zinc-50" 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>
)} )}
<Button <Button
@@ -91,7 +94,7 @@ export function LogConsole({ url }: { url: string }) {
disabled={lines.length === 0} disabled={lines.length === 0}
className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50" 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> </Button>
</div> </div>
</div> </div>
@@ -106,13 +109,13 @@ export function LogConsole({ url }: { url: string }) {
{lines.length === 0 ? ( {lines.length === 0 ? (
<p className="select-none py-8 text-center text-zinc-600"> <p className="select-none py-8 text-center text-zinc-600">
{status === "ended" {status === "ended"
? "Stream ended — reconnect to resume following the log." ? t("log_ended_empty")
: "Waiting for output…"} : t("log_waiting")}
</p> </p>
) : ( ) : (
lines.map((line) => ( lines.map((line) => (
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}> <div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
{line.text || " "} {line.text || "\u00A0"}
</div> </div>
)) ))
)} )}
@@ -126,7 +129,7 @@ export function LogConsole({ url }: { url: string }) {
onClick={jumpToLatest} 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" 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> </button>
)} )}
</div> </div>
+12 -1
View File
@@ -1,6 +1,7 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { Phase } from "@/lib/types"; import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type BadgeVariant = "default" | "muted" | "destructive" | "outline"; export type BadgeVariant = "default" | "muted" | "destructive" | "outline";
@@ -17,6 +18,15 @@ export const PHASE_COLOR: Record<Phase, string> = {
Unknown: "#94a3b8", 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> = { const VARIANT: Record<Phase, BadgeVariant> = {
Running: "default", Running: "default",
Starting: "outline", Starting: "outline",
@@ -43,6 +53,7 @@ export function phaseVariant(phase: Phase): BadgeVariant {
} }
export function PhaseBadge({ phase }: { phase: Phase }) { export function PhaseBadge({ phase }: { phase: Phase }) {
const { t } = useTranslation();
return ( return (
<Badge variant={phaseVariant(phase)} className="gap-1.5"> <Badge variant={phaseVariant(phase)} className="gap-1.5">
<span <span
@@ -52,7 +63,7 @@ export function PhaseBadge({ phase }: { phase: Phase }) {
)} )}
style={{ backgroundColor: phaseColor(phase) }} style={{ backgroundColor: phaseColor(phase) }}
/> />
{phase} {t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}
</Badge> </Badge>
); );
} }
+3 -1
View File
@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { Loading, NotAuthorized } from "@/components/States"; 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). // settled, isAdmin is the fail-closed verdict (false on any /me failure).
export function RequireAdmin() { export function RequireAdmin() {
const { isAdmin, loading } = useTier(); 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 />; return isAdmin ? <Outlet /> : <NotAuthorized />;
} }
+3 -1
View File
@@ -1,5 +1,6 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
// RequireAuth is the layout gate in front of the whole authenticated app (AppShell // 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. // 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() { export function RequireAuth() {
const { loading, unauthenticated, mustChangePassword } = useTier(); const { loading, unauthenticated, mustChangePassword } = useTier();
const { t } = useTranslation("common");
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted-foreground"> <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" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading… {t("loading")}
</div> </div>
); );
} }
+7 -5
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react"; 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 { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge } from "@/components/PhaseBadge";
@@ -16,6 +17,7 @@ interface Props {
} }
export function ServerCard({ server, cfg, onChanged }: Props) { export function ServerCard({ server, cfg, onChanged }: Props) {
const { t } = useTranslation("servers");
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null); const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const host = hostFor(server.subdomain, cfg); const host = hostFor(server.subdomain, cfg);
@@ -65,7 +67,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{running ? ( {running ? (
<span> <span>
{server.players ?? 0} {server.players ?? 0}
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online {server.maxPlayers ? ` / ${server.maxPlayers}` : ""} {t("online")}
</span> </span>
) : ( ) : (
<span>—</span> <span>—</span>
@@ -78,7 +80,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{server.owned && ( {server.owned && (
<Link to={`/servers/${server.name}`}> <Link to={`/servers/${server.name}`}>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Terminal /> Console <Terminal /> {t("console")}
</Button> </Button>
</Link> </Link>
)} )}
@@ -89,7 +91,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null} disabled={busy !== null}
onClick={() => act("claim", () => api.claim(server.name))} onClick={() => act("claim", () => api.claim(server.name))}
> >
<Hand /> {busy === "claim" ? "Claiming…" : "Claim"} <Hand /> {busy === "claim" ? t("claiming") : t("claim")}
</Button> </Button>
)} )}
{server.owned && {server.owned &&
@@ -100,7 +102,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null} disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))} onClick={() => act("stop", () => api.stop(server.name))}
> >
<Square /> {busy === "stop" ? "Stopping…" : "Stop"} <Square /> {busy === "stop" ? t("stopping") : t("stop")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -108,7 +110,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null} disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))} onClick={() => act("wake", () => api.wake(server.name))}
> >
<Play /> {busy === "wake" ? "Waking…" : "Wake"} <Play /> {busy === "wake" ? t("waking") : t("wake")}
</Button> </Button>
))} ))}
</CardFooter> </CardFooter>
+13 -10
View File
@@ -1,17 +1,20 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react"; import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { humanizeError } from "@/lib/api"; import { humanizeError } from "@/lib/api";
export function Loading({ label = "Loading…" }: { label?: string }) { export function Loading({ label }: { label?: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{label} {label ?? t("loading")}
</div> </div>
); );
} }
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) { export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const { t } = useTranslation("common");
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<AlertTriangle className="h-6 w-6 text-destructive" /> <AlertTriangle className="h-6 w-6 text-destructive" />
@@ -21,7 +24,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
onClick={onRetry} onClick={onRetry}
className="text-sm font-medium text-primary hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
Try again {t("try_again")}
</button> </button>
)} )}
</div> </div>
@@ -50,19 +53,18 @@ export function EmptyState({
} }
export function NotAuthorized() { export function NotAuthorized() {
const { t } = useTranslation("common");
return ( return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center"> <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" /> <ShieldX className="h-8 w-8 text-destructive" />
<div> <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"> <p className="mt-1 text-sm text-muted-foreground">
This area is for platform administrators. If you reached it by typing the {t("not_authorized_body")}
URL, your account does not have admin access — and the API would refuse
the request anyway.
</p> </p>
</div> </div>
<Link to="/" className="text-sm font-medium text-primary hover:underline"> <Link to="/" className="text-sm font-medium text-primary hover:underline">
Back to your dashboard {t("back_to_dashboard")}
</Link> </Link>
</div> </div>
); );
@@ -76,14 +78,15 @@ export function NotAuthorized() {
* data. * data.
*/ */
export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) { export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-16 text-center"> <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" /> <Construction className="h-7 w-7 text-muted-foreground" />
<div> <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-1 max-w-md text-sm text-muted-foreground">{note}</p>
<p className="mt-2 font-mono text-xs text-muted-foreground/80"> <p className="mt-2 font-mono text-xs text-muted-foreground/80">
pending backend: {endpoint} {t("pending_backend_prefix")}{endpoint}
</p> </p>
</div> </div>
</div> </div>
+7 -3
View File
@@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root; export const Dialog = DialogPrimitive.Root;
@@ -25,7 +26,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef< export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
const { t } = useTranslation("common");
return (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -39,11 +42,12 @@ export const DialogContent = React.forwardRef<
{children} {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"> <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" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">{t("close_sr")}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
)); );
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogHeader({ 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, WhitelistImage,
} from "./types"; } from "./types";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
import i18next from "i18next";
// Typed client for the felis-api external face (spec §7). Credentials are sent so // 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 // 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. */ /** humanizeError turns the stable error code into a user-facing line. */
export function humanizeError(e: unknown): string { export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>; const err = e as Partial<ApiError>;
const t = i18next.getFixedT(null, "errors");
switch (err.code) { switch (err.code) {
// Local-password auth (spec §B1). // Local-password auth (spec §B1).
case "local_auth_disabled": 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": case "invalid_credentials":
return "Incorrect username or password."; return t("invalid_credentials");
case "weak_password": case "weak_password":
return "Pick a password between 8 and 72 characters."; return t("weak_password");
case "password_unchanged": case "password_unchanged":
return "Your new password must differ from the current one."; return t("password_unchanged");
case "not_linked": case "not_linked":
return "Link your Minecraft account before claiming (Account → Link)."; return t("not_linked");
case "invalid_code": 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": case "already_linked":
return "That Minecraft account is already linked to another user."; return t("already_linked");
case "quota_exceeded": case "quota_exceeded":
return "You have reached your server quota."; return t("quota_exceeded");
case "already_claimed": case "already_claimed":
return "Someone else just claimed this server."; return t("already_claimed");
case "image_not_whitelisted": case "image_not_whitelisted":
return "That image is not on the whitelist."; return t("image_not_whitelisted");
case "subdomain_taken": case "subdomain_taken":
return "That subdomain is already in use."; return t("subdomain_taken");
case "already_exists": case "already_exists":
return "A server with that name already exists."; return t("already_exists");
case "cooldown": case "cooldown":
return "Wake is cooling down — try again shortly."; return t("cooldown");
default: default:
if (err.status === 401) return "Your session expired — please sign in again."; if (err.status === 401) return t("session_expired");
if (err.status === 403) return "You are not allowed to do that."; if (err.status === 403) return t("forbidden");
return err.message ?? "Something went wrong."; return err.message ?? t("generic");
} }
} }
+14 -14
View File
@@ -23,7 +23,7 @@ import {
export interface NavItem { export interface NavItem {
to: string; to: string;
label: string; key: string;
icon: LucideIcon; icon: LucideIcon;
/** react-router NavLink `end` — exact-match active styling for index routes. */ /** react-router NavLink `end` — exact-match active styling for index routes. */
end?: boolean; end?: boolean;
@@ -31,8 +31,8 @@ export interface NavItem {
export interface NavSection { export interface NavSection {
id: "user" | "admin" | "ops"; id: "user" | "admin" | "ops";
/** Section heading shown in the sidebar; null renders no heading (User-Side). */ /** Section heading key; null renders no heading (User-Side). */
title: string | null; titleKey: string | null;
/** When true the section is shown only to admins (is_admin === true). */ /** When true the section is shown only to admins (is_admin === true). */
adminOnly: boolean; adminOnly: boolean;
items: NavItem[]; items: NavItem[];
@@ -41,31 +41,31 @@ export interface NavSection {
export const NAV_SECTIONS: NavSection[] = [ export const NAV_SECTIONS: NavSection[] = [
{ {
id: "user", id: "user",
title: null, titleKey: null,
adminOnly: false, adminOnly: false,
items: [ items: [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, { to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
{ to: "/servers", label: "My servers", icon: Server }, { to: "/servers", key: "my_servers", icon: Server },
{ to: "/account", label: "Account", icon: UserRound }, { to: "/account", key: "account", icon: UserRound },
], ],
}, },
{ {
id: "admin", id: "admin",
title: "Admin", titleKey: "admin_section",
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: "/admin", label: "Overview", icon: ShieldCheck, end: true }, { to: "/admin", key: "admin_overview", icon: ShieldCheck, end: true },
{ to: "/admin/servers", label: "Servers", icon: ServerCog }, { to: "/admin/servers", key: "admin_servers", icon: ServerCog },
{ to: "/admin/images", label: "Images", icon: Boxes }, { to: "/admin/images", key: "admin_images", icon: Boxes },
], ],
}, },
{ {
id: "ops", id: "ops",
title: "SysAdmin", titleKey: "sysadmin_section",
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: "/ops", label: "Overview", icon: Gauge, end: true }, { to: "/ops", key: "sysadmin_overview", icon: Gauge, end: true },
{ to: "/ops/fleet", label: "Fleet", icon: Network }, { to: "/ops/fleet", key: "sysadmin_fleet", icon: Network },
], ],
}, },
]; ];
+1
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "./i18n";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
+22 -26
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react"; import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -18,6 +19,7 @@ import { useTier } from "@/lib/tier";
export function Account() { export function Account() {
const status = useAsync(() => api.linkStatus(), []); const status = useAsync(() => api.linkStatus(), []);
const { refresh } = useTier(); const { refresh } = useTier();
const { t } = useTranslation("account");
// Sign-out ends a local-password session: clear it server-side, then refresh /me. // 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 // For a local session that read now 401s → the tier model flips to
@@ -67,24 +69,20 @@ export function Account() {
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div> <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"> <p className="text-sm text-muted-foreground">
Identity and Minecraft linking. {t("subtitle")}
</p> </p>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground"> <CardContent className="space-y-4 text-sm text-muted-foreground">
<p> <p>{t("session_desc")}</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>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -92,7 +90,7 @@ export function Account() {
disabled={signingOut} disabled={signingOut}
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
{signingOut ? "Signing out…" : "Sign out"} {signingOut ? t("signing_out") : t("sign_out")}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -100,14 +98,14 @@ export function Account() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <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> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm"> <CardContent className="text-sm">
{linked ? ( {linked ? (
<LinkedState uuid={verifiedUUID} /> <LinkedState uuid={verifiedUUID} />
) : status.loading && !status.data ? ( ) : status.loading && !status.data ? (
<Loading label="Checking link status…" /> <Loading label={t("checking_link")} />
) : status.error ? ( ) : status.error ? (
<ErrorState error={status.error} onRetry={status.reload} /> <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 * just verified it (start does not return it), so a pre-existing link renders
* the confirmation without a UUID rather than inventing one. */ * the confirmation without a UUID rather than inventing one. */
function LinkedState({ uuid }: { uuid: string | null }) { function LinkedState({ uuid }: { uuid: string | null }) {
const { t } = useTranslation("account");
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 font-medium text-foreground"> <div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> <CheckCircle2 className="h-4 w-4 text-emerald-500" />
Your Minecraft account is linked. {t("linked_title")}
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t("linked_desc")}</p>
Linked sessions can claim and own servers — ownership operations (claim,
wake, stop) are unblocked.
</p>
{uuid && ( {uuid && (
<div className="flex items-center gap-2 text-muted-foreground"> <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"> <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{uuid} {uuid}
</code> </code>
@@ -167,34 +163,34 @@ function LinkForm({
error: string | null; error: string | null;
onSubmit: (e: FormEvent) => void; onSubmit: (e: FormEvent) => void;
}) { }) {
const { t } = useTranslation("account");
return ( return (
<ol className="space-y-4"> <ol className="space-y-4">
<li className="flex gap-3"> <li className="flex gap-3">
<StepBadge n={1} /> <StepBadge n={1} />
<div className="space-y-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"> <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"> <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
/link /link
</code>{" "} </code>
in chat. The server already knows your verified identity, so it hands {t("step1_desc_suffix")}
you a one-time code (valid about 10 minutes).
</p> </p>
</div> </div>
</li> </li>
<li className="flex gap-3"> <li className="flex gap-3">
<StepBadge n={2} /> <StepBadge n={2} />
<div className="w-full space-y-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"> <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"> <div className="flex gap-2">
<Input <Input
id="link-code" id="link-code"
value={code} value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())} onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="ABCD2345" placeholder={t("link_code_placeholder")}
autoComplete="off" autoComplete="off"
autoCapitalize="characters" autoCapitalize="characters"
spellCheck={false} spellCheck={false}
@@ -206,7 +202,7 @@ function LinkForm({
type="submit" type="submit"
disabled={submitting || code.trim().length === 0} disabled={submitting || code.trim().length === 0}
> >
{submitting ? "Verifying…" : "Link"} {submitting ? t("verifying") : t("verify_btn")}
</Button> </Button>
</div> </div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
+13 -11
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout"; import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ const MIN_PASSWORD = 8;
export function ChangePassword() { export function ChangePassword() {
const { loading, unauthenticated, mustChangePassword, refresh } = useTier(); const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("auth");
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
@@ -34,10 +36,10 @@ export function ChangePassword() {
if (loading) { if (loading) {
return ( 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"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading… {t("common:loading")}
</div> </div>
</AuthLayout> </AuthLayout>
); );
@@ -69,18 +71,18 @@ export function ChangePassword() {
return ( return (
<AuthLayout <AuthLayout
title="Set a new password" title={t("change_password_title")}
subtitle={ subtitle={
mustChangePassword mustChangePassword
? "Your account was issued a one-time password. Choose a new one to continue." ? t("change_password_subtitle_forced")
: "Update your console password." : t("change_password_subtitle_voluntary")
} }
> >
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current">Current password</Label> <Label htmlFor="current">{t("current_password")}</Label>
<Input <Input
id="current" id="current"
type="password" type="password"
@@ -92,7 +94,7 @@ export function ChangePassword() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-password">New password</Label> <Label htmlFor="new-password">{t("new_password")}</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
@@ -103,12 +105,12 @@ export function ChangePassword() {
/> />
{tooShort && ( {tooShort && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
At least {MIN_PASSWORD} characters. {t("password_min_length", { min: MIN_PASSWORD })}
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label> <Label htmlFor="confirm-password">{t("confirm_new_password")}</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
@@ -118,12 +120,12 @@ export function ChangePassword() {
aria-invalid={mismatch ? true : undefined} aria-invalid={mismatch ? true : undefined}
/> />
{mismatch && ( {mismatch && (
<p className="text-xs text-destructive">Passwords don't match.</p> <p className="text-xs text-destructive">{t("password_mismatch")}</p>
)} )}
</div> </div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canSubmit}> <Button type="submit" className="w-full" disabled={!canSubmit}>
{submitting ? "Saving…" : "Change password"} {submitting ? t("saving") : t("change_password_btn")}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
+15 -12
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useMemo } from "react"; import { lazy, Suspense, useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Activity, Server, Play, Hand } from "lucide-react"; import { Activity, Server, Play, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -41,6 +42,7 @@ function Stat({
export function Dashboard() { export function Dashboard() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("dashboard");
const counts = useMemo(() => { const counts = useMemo(() => {
const servers = data ?? []; const servers = data ?? [];
@@ -55,8 +57,8 @@ export function Dashboard() {
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">Your fleet at a glance.</p> <p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
{loading && !data ? ( {loading && !data ? (
@@ -79,14 +81,15 @@ function FleetView({
counts: { total: number; running: number; players: number }; counts: { total: number; running: number; players: number };
hasConfig: boolean; hasConfig: boolean;
}) { }) {
const { t } = useTranslation("dashboard");
if (servers.length === 0) { if (servers.length === 0) {
return ( return (
<EmptyState <EmptyState
title="No servers yet" title={t("no_servers_title")}
hint="Create your first server or claim an unowned one." hint={t("no_servers_hint")}
> >
<Link to="/servers"> <Link to="/servers">
<Button>Go to My servers</Button> <Button>{t("go_to_my_servers")}</Button>
</Link> </Link>
</EmptyState> </EmptyState>
); );
@@ -95,29 +98,29 @@ function FleetView({
return ( return (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Stat icon={Server} label="Servers" value={counts.total} /> <Stat icon={Server} label={t("stat_servers")} value={counts.total} />
<Stat icon={Play} label="Running" value={counts.running} /> <Stat icon={Play} label={t("stat_running")} value={counts.running} />
<Stat icon={Activity} label="Players online" value={counts.players} /> <Stat icon={Activity} label={t("stat_players_online")} value={counts.players} />
</div> </div>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader className="flex-row items-center justify-between space-y-0"> <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"> <Link to="/servers">
<Button variant="ghost" size="sm"> <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> </Button>
</Link> </Link>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{hasConfig ? ( {hasConfig ? (
<div className="h-[340px] w-full bg-gradient-to-b from-transparent to-primary/5"> <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} /> <VoxelFleet servers={servers} />
</Suspense> </Suspense>
</div> </div>
) : ( ) : (
<Loading label="Preparing scene…" /> <Loading label={t("preparing_scene")} />
)} )}
</CardContent> </CardContent>
</Card> </Card>
+8 -6
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout"; import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import { api, humanizeError } from "@/lib/api";
export function Login() { export function Login() {
const { loading, identity, mustChangePassword, refresh } = useTier(); const { loading, identity, mustChangePassword, refresh } = useTier();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("auth");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -30,10 +32,10 @@ export function Login() {
// would briefly see a login form before being redirected away. // would briefly see a login form before being redirected away.
if (loading) { if (loading) {
return ( 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"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading… {t("common:loading")}
</div> </div>
</AuthLayout> </AuthLayout>
); );
@@ -59,12 +61,12 @@ export function Login() {
} }
return ( return (
<AuthLayout title="Sign in to Felis" subtitle="Operator console"> <AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">{t("username")}</Label>
<Input <Input
id="username" id="username"
value={username} value={username}
@@ -78,7 +80,7 @@ export function Login() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t("password")}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@@ -94,7 +96,7 @@ export function Login() {
className="w-full" className="w-full"
disabled={submitting || !username.trim() || !password} disabled={submitting || !username.trim() || !password}
> >
{submitting ? "Signing in…" : "Sign in"} {submitting ? t("signing_in") : t("sign_in")}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
+8 -8
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -10,14 +11,15 @@ import { useAsync, useConfig } from "@/lib/hooks";
export function MyServers() { export function MyServers() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<div> <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"> <p className="text-sm text-muted-foreground">
Wake, stop, or claim the servers you can manage. {t("my_servers_subtitle")}
</p> </p>
</div> </div>
@@ -26,11 +28,11 @@ export function MyServers() {
) : error ? ( ) : error ? (
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : !cfg ? ( ) : !cfg ? (
<Loading label="Loading config…" /> <Loading label={t("common:loading_config")} />
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<EmptyState <EmptyState
title="No servers linked to you" title={t("no_servers_linked")}
hint="Claim an unowned server you have access to, or ask an admin to provision one." hint={t("no_servers_hint")}
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <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"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="p-4 text-xs text-muted-foreground">
Servers and game types are provisioned and managed by the platform. You {t("my_servers_footer")}
claim a node to operate it; raw cluster config is never exposed here
(spec §15).
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+18 -17
View File
@@ -1,5 +1,6 @@
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge"; 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 // 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 // console. `default` covers Unknown plus any future phase the backend may emit
// that the panel hasn't modelled yet — the screen stays informative regardless. // 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) { switch (phase) {
case "Stopped": case "Stopped":
return { return {
icon: Moon, icon: Moon,
title: "Server is asleep", title: t("server_asleep_title"),
body: "Wake it to boot a pod — the live console attaches automatically the moment it starts up.", body: t("server_asleep_body"),
}; };
case "Stopping": case "Stopping":
return { return {
icon: Moon, icon: Moon,
title: "Server is shutting down", title: t("server_shutting_down_title"),
body: "The pod is terminating, so the live console has detached. It will be asleep in a moment.", body: t("server_shutting_down_body"),
}; };
case "Failed": case "Failed":
return { return {
icon: ShieldAlert, icon: ShieldAlert,
title: "Server failed to start", title: t("server_failed_title"),
body: "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.", body: t("server_failed_body"),
}; };
default: default:
return { return {
icon: HelpCircle, icon: HelpCircle,
title: "Console is offline", title: t("console_offline_title"),
body: "The live console attaches automatically as soon as the server is running.", body: t("console_offline_body"),
}; };
} }
} }
function NotStreaming({ phase }: { phase: Phase }) { function NotStreaming({ phase }: { phase: Phase }) {
const { icon: Icon, title, body } = notStreamingCopy(phase); const { icon: Icon, title, body } = useNotStreamingCopy(phase);
return ( 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"> <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" /> <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() { export function ServerConsole() {
const { name = "" } = useParams(); const { name = "" } = useParams();
const cfg = useConfig(); const cfg = useConfig();
const { t } = useTranslation("servers");
const { data, error, loading, reload } = useAsync( const { data, error, loading, reload } = useAsync(
() => api.status(name), () => api.status(name),
[name], [name],
@@ -78,7 +81,7 @@ export function ServerConsole() {
to="/servers" to="/servers"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" 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> </Link>
{loading && !data ? ( {loading && !data ? (
@@ -106,11 +109,11 @@ export function ServerConsole() {
size="sm" size="sm"
onClick={() => api.stop(name).then(reload)} onClick={() => api.stop(name).then(reload)}
> >
Stop {t("stop")}
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={() => api.wake(name).then(reload)}> <Button size="sm" onClick={() => api.wake(name).then(reload)}>
Wake {t("wake")}
</Button> </Button>
)} )}
</div> </div>
@@ -119,12 +122,10 @@ export function ServerConsole() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <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> </CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Live, read-only output streamed from the running pod over SSE {t("console_card_desc")}
(spec §8). Commands run through a separate path — the panel never
holds an RCON password.
</p> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+17 -17
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ServerCog, Boxes, ShieldCheck } from "lucide-react"; import { ServerCog, Boxes, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { useTier } from "@/lib/tier"; 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 // content/server-admin surfaces. It deliberately states the four-power boundary
// so an admin never expects cluster-lifecycle levers here — those stay kubectl/CRD. // 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", to: "/admin/servers",
icon: ServerCog, icon: ServerCog,
title: "Servers", title: t("servers"),
body: "Create platform servers from the structured §15 form and manage the ones you operate.", body: t("servers_desc"),
}, },
{ {
to: "/admin/images", to: "/admin/images",
icon: Boxes, icon: Boxes,
title: "Images", title: t("images"),
body: "The platform image whitelist — the value space the create form draws from.", body: t("images_desc"),
}, },
]; ];
export function AdminHome() {
const { identity } = useTier();
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ShieldCheck className="h-6 w-6 text-primary" /> <ShieldCheck className="h-6 w-6 text-primary" />
<div> <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"> <p className="text-sm text-muted-foreground">
Server &amp; content administration {t("subtitle")}
{identity?.email ? ` · signed in as ${identity.email}` : ""}. {identity?.email ? t("signed_in_as", { email: identity.email }) : ""}.
</p> </p>
</div> </div>
</div> </div>
@@ -54,11 +56,9 @@ export function AdminHome() {
<Card className="border-dashed bg-transparent"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="p-4 text-xs text-muted-foreground">
Cluster scaling, RBAC, Secrets and control-plane lifecycle are{" "} {t("footer_pre")}
<span className="font-medium">kubectl / CRD operations</span> and are <span className="font-medium">{t("footer_kubectl")}</span>
intentionally not available from the panel — the four-power separation {t("footer_post")}
(build / runtime / operator / app) is preserved. This is a window onto the
platform, not a lever for operator power.
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+8 -8
View File
@@ -1,4 +1,5 @@
import { Boxes, CheckCircle2, CircleSlash } from "lucide-react"; import { Boxes, CheckCircle2, CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Loading, ErrorState, EmptyState } from "@/components/States"; 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 // added, so this page surfaces the whitelist honestly and labels the missing
// mutation rather than shipping dead buttons. // mutation rather than shipping dead buttons.
export function ImageAdmin() { export function ImageAdmin() {
const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []); const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
const images = data ?? []; const images = data ?? [];
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div> <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"> <p className="text-sm text-muted-foreground">
The platform image whitelist. Only enabled images can back a new server. {t("images_subtitle")}
Editing the whitelist from the Web is auditable (spec §14) — the mutation
routes are a pending admin-tier slice, so this view is read-only for now.
</p> </p>
</div> </div>
@@ -31,8 +31,8 @@ export function ImageAdmin() {
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : images.length === 0 ? ( ) : images.length === 0 ? (
<EmptyState <EmptyState
title="No images whitelisted" title={t("no_images_title")}
hint="The whitelist is empty — a platform admin must add one (CLI for now)." hint={t("no_images_hint")}
/> />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -50,11 +50,11 @@ export function ImageAdmin() {
</div> </div>
{img.enabled ? ( {img.enabled ? (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary"> <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>
) : ( ) : (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"> <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> </span>
)} )}
</CardContent> </CardContent>
+9 -10
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
@@ -17,16 +18,17 @@ import { useAsync, useConfig } from "@/lib/hooks";
export function ServerAdmin() { export function ServerAdmin() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("admin");
const { t: ts } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <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"> <p className="text-sm text-muted-foreground">
Create platform servers from the structured form and manage the ones {t("server_admin_subtitle")}
you operate.
</p> </p>
</div> </div>
{cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />} {cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
@@ -37,11 +39,11 @@ export function ServerAdmin() {
) : error ? ( ) : error ? (
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : !cfg ? ( ) : !cfg ? (
<Loading label="Loading config…" /> <Loading label={ts("common:loading_config")} />
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<EmptyState <EmptyState
title="No servers under your management yet" title={ts("no_servers_managed")}
hint="Use Create server to provision one from the vetted spec." hint={ts("no_servers_managed_hint")}
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <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"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="p-4 text-xs text-muted-foreground">
Server creation goes through the structured form only — the platform maps {ts("server_admin_footer")}
your choices onto a vetted Kubernetes spec. Raw cluster config (host
networking, host paths, arbitrary images, privileged pods) is never
expressible here (spec §15).
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+5 -3
View File
@@ -1,4 +1,5 @@
import { Network } from "lucide-react"; import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PendingBackend } from "@/components/States"; import { PendingBackend } from "@/components/States";
// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with // 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 // fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend
// slice. Honest placeholder until that lands — no fabricated rows. // slice. Honest placeholder until that lands — no fabricated rows.
export function FleetTable() { export function FleetTable() {
const { t } = useTranslation("ops");
return ( return (
<div className="mx-auto max-w-5xl space-y-6"> <div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" /> <Network className="h-6 w-6 text-primary" />
<div> <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"> <p className="text-sm text-muted-foreground">
Every server on the platform — phase, owner, capacity. {t("fleet_subtitle")}
</p> </p>
</div> </div>
</div> </div>
<PendingBackend <PendingBackend
endpoint="GET /api/v1/admin/servers" 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> </div>
); );
+10 -9
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Gauge, Network, ServerOff } from "lucide-react"; import { Gauge, Network, ServerOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { PendingBackend } from "@/components/States"; 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 // 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. // then this surface is honest about the gap rather than charting fake data.
export function OpsOverview() { export function OpsOverview() {
const { t } = useTranslation("ops");
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Gauge className="h-6 w-6 text-primary" /> <Gauge className="h-6 w-6 text-primary" />
<div> <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"> <p className="text-sm text-muted-foreground">
Platform observability cockpit — a window, not a lever. {t("subtitle")}
</p> </p>
</div> </div>
</div> </div>
@@ -26,17 +28,17 @@ export function OpsOverview() {
<Card> <Card>
<CardContent className="space-y-3 p-4"> <CardContent className="space-y-3 p-4">
<div className="flex items-center gap-2 text-sm font-medium"> <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> </div>
<PendingBackend <PendingBackend
endpoint="GET /api/v1/admin/servers" 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 <Link
to="/ops/fleet" to="/ops/fleet"
className="inline-block text-sm font-medium text-primary hover:underline" className="inline-block text-sm font-medium text-primary hover:underline"
> >
Open fleet table → {t("open_fleet_table")}
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -45,10 +47,9 @@ export function OpsOverview() {
<CardContent className="flex items-start gap-3 p-4 text-xs text-muted-foreground"> <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" /> <ServerOff className="mt-0.5 h-4 w-4 shrink-0" />
<span> <span>
Control-plane scaling, RBAC, Secrets and cluster lifecycle are{" "} {t("footer_pre")}
<span className="font-medium">kubectl / CRD operations</span> and are not <span className="font-medium">{t("footer_kubectl")}</span>
reachable from here. SysAdmin-Side is observability only — the {t("footer_post")}
four-power separation (build / runtime / operator / app) is preserved.
</span> </span>
</CardContent> </CardContent>
</Card> </Card>
+3
View File
@@ -21,5 +21,8 @@ export default defineConfig(async ({ mode }) => {
"/api": { target: "http://localhost:8080", changeOrigin: true }, "/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");