feat(panel): i18n
This commit is contained in:
47 files changed
+892
-225
No files matched your search
Generated
+104
-1
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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…"
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "验证中…"
|
||||||
|
}
|
||||||
@@ -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": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。"
|
||||||
|
}
|
||||||
@@ -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": "全平台服务器"
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,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
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import i18next from "i18next";
|
||||||
|
import "./src/i18n";
|
||||||
|
|
||||||
|
i18next.changeLanguage("en-US");
|
||||||
Reference in new issue
Block a user