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