This commit is contained in:
flyemoji committed 2026-06-27 20:19:06 +09:00
commit b81b33453a
54 files changed
+1968 -247

No files matched your search

+325
View File
@@ -0,0 +1,325 @@
# Felis Contributor Guide
This document is the practical entry point for contributors. It focuses on how to
run, test, and reason about the project while keeping changes small and aligned
with the current codebase.
## Project Shape
Felis is a Kubernetes-native Minecraft server control plane. The repository has
four main areas:
- `cmd/felis/`: the single Go CLI binary. It dispatches subcommands such as
`api`, `operator`, `migrate`, `reaper`, `restore`, `manifests`, and
`breakGlass`.
- `internal/`: backend packages for API handlers, store migrations, Kubernetes
rendering, operator reconciliation, build, backup, restore, and related domain
logic.
- `panel/`: the React/Vite web control panel.
- `plugins/`: Minecraft-side plugins and mods for Velocity, Paper, Fabric,
Forge, and NeoForge.
The codebase is intentionally split by responsibility. Prefer changing the
smallest owning module instead of adding broad abstractions or rebuilding nearby
code.
## Local Development
You can do most day-to-day development on macOS or Linux without a full cluster.
The full product needs Postgres and Kubernetes, but unit tests and frontend work
run locally.
Recommended local tools:
- Go matching `go.mod`
- Node.js and npm for `panel/`
- Optional: JDK/Gradle for plugin work
- Optional integration environment: a clean Linux VM or server with Docker,
k3s, and Postgres
Check tool versions:
```bash
go version
node --version
npm --version
java -version
```
## Backend Commands
Run these from the repository root:
```bash
cd /path/to/Felis
```
Run all Go tests:
```bash
go test ./...
```
Run a focused package:
```bash
go test ./internal/api
go test ./cmd/felis
```
Build the CLI:
```bash
go build -o /tmp/felis-dev ./cmd/felis
/tmp/felis-dev help
```
Render Kubernetes manifests without contacting a cluster:
```bash
/tmp/felis-dev manifests \
--felis-image registry.felis.svc:5000/felis:dev \
--velocity-cidr 10.0.0.5/32
```
`felis api`, `felis operator`, `felis migrate up`, and `felis reaper` are real
runtime commands. They need external services such as Postgres and/or a
Kubernetes config, so they are not the first choice for quick local iteration.
## Frontend Commands
Run these from the frontend workspace:
```bash
cd /path/to/Felis/panel
```
Install dependencies:
```bash
npm ci
```
Run against a real backend at `http://localhost:8080`:
```bash
npm run dev
```
Run with the local mock API:
```bash
npm run dev:mock
```
The mock dev server prints its accounts, link code, and reset command when it
starts. Use it for frontend work when you do not have the Go API and cluster
running.
Common frontend checks:
```bash
npm run typecheck
npm test
npm run build
```
## Mock API
The frontend mock API lives under `panel/dev/` and is loaded only by
`npm run dev:mock`. It must not leak into production code or business
components.
Run mock commands from the frontend workspace:
```bash
cd /path/to/Felis/panel
npm run dev:mock
```
Current mock accounts:
| Username | Password | Scenario |
| --- | --- | --- |
| `owner` | `devpassword` | admin, linked |
| `user` | `devpassword` | normal user, not linked |
| `linked` | `devpassword` | normal user, linked |
| `setup` | `devpassword` | admin, first-login password change |
Mock Minecraft link code:
```text
LINK1234
```
Reset mock state:
```bash
curl -X POST http://127.0.0.1:5173/api/v1/__mock/reset
```
Mock rules:
- Keep mock-only logic in `panel/dev/`.
- Do not import mock code from `panel/src/`.
- Keep response shapes aligned with `panel/src/lib/types.ts` and the Go API
handlers.
- Prefer realistic error codes over happy-path-only mocks.
- Do not present mock data as live production data.
## Full Integration Environment
Run integration/deploy commands from the repository root on the Linux host:
```bash
cd /path/to/Felis
```
The one-line bootstrap script is intended for a clean Linux host, not a typical
macOS development machine:
```bash
sudo -E bash deploy/bootstrap.sh
```
Useful overrides:
```bash
export FELIS_REPO_URL=<your fork url>
export FELIS_REF=<your branch>
export FELIS_IMAGE=felis:dev
export FELIS_ROOT_DOMAIN=<node-ip>.nip.io
```
The bootstrap flow installs/configures system services, builds the Felis image,
imports it into k3s, runs migrations, applies CRDs/manifests, and deploys the
control plane.
After bootstrap, use the break-glass console to create or recover the Owner
account:
```bash
sudo felis breakGlass
```
Use a VM or disposable Linux server for this. Treat it as an integration and
acceptance environment, while keeping normal coding and quick tests local.
## Plugin Development
Plugin docs live in `plugins/README.md`.
The plugin modules intentionally use separate Gradle builds:
```bash
# cwd: repository root
cd /path/to/Felis
gradle -p plugins/velocity build
gradle -p plugins/paper build
bash plugins/fabric/gradlew -p plugins/fabric build
bash plugins/forge/gradlew -p plugins/forge build
bash plugins/neoforge/gradlew -p plugins/neoforge build
```
Notes:
- Fabric/Forge/NeoForge use module wrappers.
- Velocity/Paper use system Gradle.
- Most modules target Java 17.
- Paper needs a Java 21 toolchain.
- First builds may be slow because Minecraft dependencies are downloaded and
remapped.
## Frontend Status
The panel is currently an early control-panel implementation, not a complete
product.
Reasonably usable today:
- Local-password login
- First-login password change
- User/admin route gates
- Dashboard shell
- My servers list
- Wake, stop, claim actions
- Read-only console log stream
- Account linking flow
- Admin create-server form
- Read-only image whitelist
- Mock API for local frontend work
Still shallow or missing:
- Server detail depth
- Log search/filter/download
- RCON command UX, if/when the backend path is ready
- Admin edit/delete/transfer flows
- Image whitelist mutations
- Backups and restore UI
- Fleet-wide ops data
- Stronger component and browser-level tests
When adding frontend features, keep UI state honest. If the backend endpoint does
not exist yet, use an explicit placeholder instead of fake live data.
## i18n Notes
i18n is not yet established as a full system. User-facing strings are currently
mostly inline in TSX and helper functions.
Good first targets:
- Error messages mapped from stable API error codes
- Navigation labels
- Page titles and primary actions
- Phase/status labels
- Empty/loading/error states
Recommended initial approach:
```text
panel/src/i18n/
index.ts
en.ts
zh-CN.ts
```
Use a small typed dictionary first. Add a larger library such as
`i18next/react-i18next` only when the project needs runtime language switching,
pluralization rules, external translation workflows, or more complex
localization behavior.
Do not translate code comments, internal logs, or mock-only terminal messages
unless there is a clear contributor need.
## Contribution Style
Follow the existing code. Keep changes narrow and easy to review.
Guidelines:
- Prefer minimal changes over rewrites.
- Do not add a new abstraction unless it removes real duplication or names a
strong local concept.
- Keep frontend mock code out of business components.
- Keep backend tests close to the package that owns the behavior.
- Preserve public API and persistence shapes unless the change explicitly needs a
contract update.
- Do not commit generated build output such as `panel/dist/`, `node_modules/`,
Gradle build directories, or local binaries.
AI-assisted work is welcome, but the contributor is responsible for the result.
Do not submit a PR that is entirely AI-generated and not personally reviewed.
Low-quality AI dumps, broad rewrites that ignore the current design, unverified
changes, or code the author cannot explain will not be accepted.
Before handing off a change, run the smallest meaningful checks:
```bash
go test ./...
cd panel && npm run typecheck && npm test && npm run build
```
If you cannot run a relevant check, say so explicitly in the handoff.
+570
View File
@@ -0,0 +1,570 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
import type {
AutostartPolicy,
CreateServerRequest,
Identity,
LoginResult,
Phase,
ServerInfo,
WhitelistImage,
} from "../src/lib/types";
const ACCOUNT_IDS = ["owner", "user", "linked", "setup"] as const;
type AccountID = (typeof ACCOUNT_IDS)[number];
type Role = "admin" | "user";
type Method = "GET" | "POST";
type CreateError =
| "bad_request"
| "already_exists"
| "subdomain_taken"
| "image_not_whitelisted";
interface MockAccount {
id: AccountID;
role: Role;
email: string;
linked: boolean;
mustChangePassword: boolean;
}
interface MockServer extends ServerInfo {
owner: AccountID | null;
}
interface MockState {
accounts: Record<AccountID, MockAccount>;
servers: MockServer[];
images: WhitelistImage[];
}
interface RequestContext {
req: IncomingMessage;
res: ServerResponse;
state: MockState;
method: string;
parts: string[];
}
interface SessionContext extends RequestContext {
account: MockAccount;
}
const SESSION_COOKIE = "felis_mock_session";
const ROOT_DOMAIN = "dev.felis.localhost";
const API_BASE = "/api/v1";
const MOCK_PASSWORD = "devpassword";
const MOCK_LINK_CODE = "LINK1234";
const MC_UUID = "00000000-0000-4000-8000-000000000001";
const RESET_ROUTE = `${API_BASE}/__mock/reset`;
const LOGIN_HINT_STYLE = `
#felis-mock-login-hint {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483647;
max-width: min(360px, calc(100vw - 40px));
border: 1px dashed hsl(217 33% 22%);
border-radius: 8px;
background: hsl(222 47% 8% / 0.96);
color: hsl(215 20% 70%);
box-shadow: 0 18px 60px hsl(222 47% 3% / 0.45);
padding: 12px;
font: 12px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#felis-mock-login-hint strong {
display: block;
color: hsl(210 40% 98%);
font-size: 13px;
margin-bottom: 4px;
}
#felis-mock-login-hint code {
color: hsl(210 40% 98%);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
`;
const LOGIN_HINT_SCRIPT = `
(() => {
const id = "felis-mock-login-hint";
const html = '<aside id="' + id + '" aria-label="Mock sign-in credentials"><strong>Mock sign-in</strong><div>Admin: <code>owner</code> / <code>${MOCK_PASSWORD}</code></div><div>User: <code>user</code> / <code>${MOCK_PASSWORD}</code> (not linked)</div><div>User: <code>linked</code> / <code>${MOCK_PASSWORD}</code> (linked)</div><div>First login: <code>setup</code> / <code>${MOCK_PASSWORD}</code></div><div>Link code: <code>${MOCK_LINK_CODE}</code></div></aside>';
const sync = () => {
const existing = document.getElementById(id);
if (location.pathname === "/login") {
if (!existing) document.body.insertAdjacentHTML("beforeend", html);
return;
}
existing?.remove();
};
const notify = () => setTimeout(sync, 0);
for (const key of ["pushState", "replaceState"]) {
const original = history[key];
history[key] = function (...args) {
const out = original.apply(this, args);
notify();
return out;
};
}
addEventListener("popstate", notify);
addEventListener("DOMContentLoaded", sync);
sync();
})();
`;
function initialState(): MockState {
return {
accounts: {
owner: account("owner", "admin", true, false),
user: account("user", "user", false, false),
linked: account("linked", "user", true, false),
setup: account("setup", "admin", true, true),
},
images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
{ image_ref: "registry.felis.svc:5000/fabric-1.20.1:demo", enabled: true, source: "demo" },
{
image_ref: "registry.felis.svc:5000/forge-1.20.1:disabled",
enabled: false,
source: "demo",
},
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
players: 7,
maxPlayers: 20,
}),
server("creative", "Creative Lab", "Stopped", "user", {
autostartPolicy: "public",
maxPlayers: 16,
}),
server("modded", "Modded Testbed", "Starting", "owner", {
autostartPolicy: "allowlist",
maxPlayers: 12,
}),
server("claim-me", "Claimable Node", "Stopped", null, {
maxPlayers: 10,
}),
],
};
}
function mockStartupMessage(): string {
return [
"",
" Felis mock API",
` API base: ${API_BASE}`,
` Root domain: ${ROOT_DOMAIN}`,
"",
" Accounts:",
` owner / ${MOCK_PASSWORD} admin, linked`,
` user / ${MOCK_PASSWORD} user, not linked`,
` linked / ${MOCK_PASSWORD} user, linked`,
` setup / ${MOCK_PASSWORD} admin, first-login password change`,
"",
` Link code: ${MOCK_LINK_CODE}`,
` Reset state: curl -X POST http://127.0.0.1:5173${RESET_ROUTE}`,
"",
].join("\n");
}
function account(
id: AccountID,
role: Role,
linked: boolean,
mustChangePassword: boolean,
): MockAccount {
return {
id,
role,
linked,
mustChangePassword,
email: `${id}@mock.felis.local`,
};
}
function server(
name: string,
displayName: string,
phase: Phase,
owner: AccountID | null,
overrides: Partial<ServerInfo> = {},
): MockServer {
return {
name,
subdomain: name,
displayName,
phase,
desiredState: phase === "Stopped" ? "Stopped" : "Running",
players: phase === "Running" ? 1 : 0,
maxPlayers: 20,
autostartPolicy: "ownerOnly",
owned: false,
claimable: false,
owner,
...overrides,
};
}
function sendJSON(res: ServerResponse, status: number, value: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(value));
}
function sendError(
res: ServerResponse,
status: number,
code: string,
message: string,
): void {
sendJSON(res, status, { error: { code, message } });
}
function route(ctx: RequestContext): string {
return `${ctx.method} ${ctx.parts.slice(2).join("/")}`;
}
function is(method: Method, ctx: RequestContext): boolean {
return ctx.method === method;
}
async function readJSON<T>(req: IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const text = Buffer.concat(chunks).toString("utf8");
return (text ? JSON.parse(text) : {}) as T;
}
function readAccount(req: IncomingMessage, state: MockState): MockAccount | null {
const ids = ACCOUNT_IDS.join("|");
const m = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=(${ids})(?:;|$)`).exec(
req.headers.cookie ?? "",
);
return m ? state.accounts[m[1] as AccountID] : null;
}
function setSessionCookie(res: ServerResponse, accountID: AccountID): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=${accountID}; Path=/; SameSite=Lax`);
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax`);
}
function loginAccount(username: string): AccountID | null {
const normalized = username.toLowerCase();
return ACCOUNT_IDS.includes(normalized as AccountID) ? (normalized as AccountID) : null;
}
function identity(accountInfo: MockAccount): Identity {
return {
user_id: `mock-${accountInfo.id}`,
email: accountInfo.email,
role: accountInfo.role,
is_admin: accountInfo.role === "admin",
must_change_password: accountInfo.mustChangePassword,
};
}
function findServer(state: MockState, name: string): MockServer | null {
return state.servers.find((s) => s.name === name) ?? null;
}
function canSee(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id || serverInfo.owner === null;
}
function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return accountInfo.role === "admin" || serverInfo.owner === accountInfo.id;
}
function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
return state.servers
.filter((serverInfo) => canSee(accountInfo, serverInfo))
.map((serverInfo) => projectServer(serverInfo, accountInfo));
}
function projectServer(serverInfo: MockServer, accountInfo: MockAccount): ServerInfo {
const { owner: _owner, ...wire } = serverInfo;
const owned = canManage(accountInfo, serverInfo);
return {
...wire,
owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned,
};
}
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.players = phase === "Running" ? Math.max(serverInfo.players ?? 0, 1) : 0;
}
function policy(value: unknown): AutostartPolicy {
return value === "public" || value === "allowlist" ? value : "ownerOnly";
}
function createServer(
state: MockState,
owner: AccountID,
req: Partial<CreateServerRequest>,
): MockServer | CreateError {
const name = req.name?.trim();
const subdomain = req.subdomain?.trim();
if (!name || !subdomain || !req.image) return "bad_request";
if (findServer(state, name)) return "already_exists";
if (state.servers.some((s) => s.subdomain === subdomain)) return "subdomain_taken";
if (!state.images.some((i) => i.enabled && i.image_ref === req.image)) {
return "image_not_whitelisted";
}
const created = server(name, req.displayName?.trim() || name, "Stopped", owner, {
subdomain,
players: 0,
maxPlayers: 20,
autostartPolicy: policy(req.autostartPolicy),
});
state.servers.unshift(created);
return created;
}
function sendCreateError(res: ServerResponse, code: CreateError): void {
const status = code === "already_exists" || code === "subdomain_taken" ? 409 : 400;
sendError(res, status, code, code);
}
async function handlePublic(ctx: RequestContext): Promise<boolean> {
switch (route(ctx)) {
case "POST auth/login": {
const body = await readJSON<{ username?: string; password?: string }>(ctx.req);
const accountID = body.username ? loginAccount(body.username.trim()) : null;
if (!accountID || body.password !== MOCK_PASSWORD) {
sendError(ctx.res, 403, "invalid_credentials", "invalid mock credentials");
return true;
}
const accountInfo = ctx.state.accounts[accountID];
setSessionCookie(ctx.res, accountID);
const out: LoginResult = {
user_id: `mock-${accountInfo.id}`,
role: accountInfo.role,
must_change_password: accountInfo.mustChangePassword,
};
sendJSON(ctx.res, 200, out);
return true;
}
case "POST auth/logout":
clearSessionCookie(ctx.res);
sendJSON(ctx.res, 200, { ok: true });
return true;
default:
return false;
}
}
async function handleSession(ctx: SessionContext): Promise<boolean> {
switch (route(ctx)) {
case "GET me/servers":
sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) });
return true;
case "GET me":
sendJSON(ctx.res, 200, identity(ctx.account));
return true;
case "POST auth/change-password":
ctx.account.mustChangePassword = false;
sendJSON(ctx.res, 200, { ok: true });
return true;
case "GET images":
sendJSON(ctx.res, 200, { images: ctx.state.images });
return true;
case "POST servers":
await createServerRoute(ctx);
return true;
case "POST account/link/start":
sendJSON(ctx.res, 200, { linked: ctx.account.linked });
return true;
case "POST account/link/verify":
await verifyLinkRoute(ctx);
return true;
default:
return handleServerRoute(ctx);
}
}
async function createServerRoute(ctx: SessionContext): Promise<void> {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return;
}
const body = await readJSON<Partial<CreateServerRequest>>(ctx.req);
const created = createServer(ctx.state, ctx.account.id, body);
if (typeof created === "string") {
sendCreateError(ctx.res, created);
return;
}
sendJSON(ctx.res, 201, {
name: created.name,
subdomain: created.subdomain,
desiredState: created.desiredState,
});
}
async function verifyLinkRoute(ctx: SessionContext): Promise<void> {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim().toUpperCase() !== MOCK_LINK_CODE) {
sendError(ctx.res, 400, "invalid_code", "invalid mock link code");
return;
}
ctx.account.linked = true;
sendJSON(ctx.res, 200, { linked: true, mc_uuid: MC_UUID });
}
function handleServerRoute(ctx: SessionContext): boolean {
if (ctx.parts[2] !== "servers" || !ctx.parts[3]) return false;
const serverInfo = findServer(ctx.state, decodeURIComponent(ctx.parts[3]));
if (!serverInfo) {
sendError(ctx.res, 404, "not_found", "server not found");
return true;
}
if (!canSee(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not visible to this account");
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "status") {
sendJSON(ctx.res, 200, projectServer(serverInfo, ctx.account));
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "console") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
streamConsole(ctx.req, ctx.res, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "wake") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "stop") {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
setPhase(serverInfo, "Stopped");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Stopped" });
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo);
return true;
}
return false;
}
function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed");
return;
}
if (!ctx.account.linked) {
sendError(ctx.res, 412, "not_linked", "link a Minecraft account first");
return;
}
serverInfo.owner = ctx.account.id;
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, claimed: true });
}
function streamConsole(
req: IncomingMessage,
res: ServerResponse,
serverInfo: MockServer,
): void {
const lines = [
`[12:00:00] [Server thread/INFO]: Starting ${serverInfo.displayName ?? serverInfo.name}`,
"[12:00:01] [Server thread/INFO]: Loading properties",
"[12:00:02] [Server thread/WARN]: Mock world uses in-memory state only",
"[12:00:03] [Server thread/INFO]: Preparing spawn area: 100%",
"[12:00:04] [Server thread/INFO]: Done (4.123s)! For help, type \"help\"",
];
let i = 0;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.write(": connected\n\n");
const timer = setInterval(() => {
res.write(`data: ${lines[i % lines.length]}\n\n`);
i++;
}, 900);
req.on("close", () => clearInterval(timer));
}
export function mockApiPlugin(): Plugin {
let state = initialState();
return {
name: "felis-mock-api",
transformIndexHtml() {
return [
{ tag: "style", attrs: { id: "felis-mock-login-style" }, children: LOGIN_HINT_STYLE },
{ tag: "script", children: LOGIN_HINT_SCRIPT },
];
},
configureServer(server) {
server.config.logger.info(mockStartupMessage());
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url ?? "/", "http://localhost");
const method = req.method ?? "GET";
const parts = url.pathname.split("/").filter(Boolean);
if (method === "GET" && url.pathname === "/config.json") {
sendJSON(res, 200, { apiBase: API_BASE, rootDomain: ROOT_DOMAIN });
return;
}
if (parts[0] !== "api" || parts[1] !== "v1") {
next();
return;
}
if (method === "POST" && parts[2] === "__mock" && parts[3] === "reset") {
state = initialState();
server.config.logger.info(" Felis mock API: state reset");
sendJSON(res, 200, { ok: true });
return;
}
const ctx: RequestContext = { req, res, state, method, parts };
try {
if (await handlePublic(ctx)) return;
const accountInfo = readAccount(req, state);
if (!accountInfo) {
sendError(res, 401, "unauthenticated", "mock session required");
return;
}
if (await handleSession({ ...ctx, account: accountInfo })) return;
sendError(res, 404, "not_found", "mock route not found");
} catch (err) {
sendError(res, 500, "mock_error", err instanceof Error ? err.message : "mock error");
}
});
},
};
}
+12 -1
View File
@@ -1,9 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en" class="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Felis · Control Panel</title> <title>Felis · Control Panel</title>
<script>
try {
const theme = localStorage.getItem("felis-theme") || "system";
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
} catch (_) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+104 -1
View File
@@ -14,9 +14,12 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.3.3",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
"react-router-dom": "^6.28.2", "react-router-dom": "^6.28.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"three": "^0.172.0" "three": "^0.172.0"
@@ -283,6 +286,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -2750,6 +2762,52 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
"integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -3301,6 +3359,33 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3815,7 +3900,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -3906,6 +3991,15 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4122,6 +4216,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+4
View File
@@ -6,6 +6,7 @@
"description": "Felis control panel — external face of felis-api (spec §7)", "description": "Felis control panel — external face of felis-api (spec §7)",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:mock": "vite --mode mock",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -18,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"
+5 -2
View File
@@ -1,4 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier"; import { TierProvider } from "@/lib/tier";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin"; import { RequireAdmin } from "@/components/RequireAdmin";
@@ -23,8 +24,9 @@ import { FleetTable } from "@/pages/ops/FleetTable";
// the split is by concern, not by tier. TierProvider fetches /me once at boot. // the split is by concern, not by tier. TierProvider fetches /me once at boot.
export default function App() { export default function App() {
return ( return (
<TierProvider> <ThemeProvider storageKey="felis-theme">
<BrowserRouter> <TierProvider>
<BrowserRouter>
<Routes> <Routes>
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE {/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
RequireAuth — RequireAuth redirects here — and outside AppShell, so RequireAuth — RequireAuth redirects here — and outside AppShell, so
@@ -62,5 +64,6 @@ export default function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</TierProvider> </TierProvider>
</ThemeProvider>
); );
} }
+72 -10
View File
@@ -1,30 +1,78 @@
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, Sun, Moon } 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";
import { useTheme } from "@/lib/theme";
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-muted/40 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>
);
}
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation("common");
return (
<button
onClick={toggleTheme}
className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-all duration-200 active:scale-95 shrink-0"
aria-label={t("toggle_theme")}
title={t("toggle_theme")}
>
{theme === "light" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
);
}
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 +90,36 @@ 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">
<div className="flex items-center gap-1.5 shrink-0">
<LangToggle />
<ThemeToggle />
</div>
<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 justify-between border-b border-border px-4 md:hidden">
<Cat className="h-5 w-5 text-primary" /> <div className="flex items-center gap-2">
<span className="font-semibold">Felis</span> <Cat className="h-5 w-5 text-primary" />
<span className="font-semibold">{t("common:brand_name")}</span>
</div>
<div className="flex items-center gap-2">
<LangToggle />
<ThemeToggle />
</div>
</header> </header>
<main className="flex-1 p-6"> <main className="flex-1 p-6">
<Outlet /> <Outlet />
+8 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import { type ReactNode, useEffect } from "react";
import { Cat } from "lucide-react"; import { Cat } from "lucide-react";
import { useTranslation } from "react-i18next";
// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change). // AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet — // These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
@@ -13,6 +14,12 @@ export function AuthLayout({
subtitle?: string; subtitle?: string;
children: ReactNode; children: ReactNode;
}) { }) {
const { i18n } = useTranslation("common");
useEffect(() => {
document.documentElement.lang = i18n.language;
document.title = title;
}, [i18n.language, title]);
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12"> <div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
<div className="w-full max-w-sm space-y-6"> <div className="w-full max-w-sm space-y-6">
+33 -27
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -32,20 +33,24 @@ import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"]; const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"]; const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
const POLICIES: { value: AutostartPolicy; label: string }[] = [
{ value: "ownerOnly", label: "Owner only — wake from the panel" },
{ value: "public", label: "Public — any player join wakes it" },
{ value: "allowlist", label: "Allowlist — listed players wake it" },
];
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/; const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
return [
{ value: "ownerOnly", label: t("create_server_policy_owner") },
{ value: "public", label: t("create_server_policy_public") },
{ value: "allowlist", label: t("create_server_policy_allowlist") },
];
}
interface Props { interface Props {
cfg: RuntimeConfig; cfg: RuntimeConfig;
onCreated: () => void; onCreated: () => void;
} }
export function CreateServerDialog({ cfg, onCreated }: Props) { export function CreateServerDialog({ cfg, onCreated }: Props) {
const { t } = useTranslation("servers");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const images = useAsync(() => api.listImages(), []); const images = useAsync(() => api.listImages(), []);
@@ -94,69 +99,70 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
} }
} }
const policies = policyOptions(t);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<Plus /> New server <Plus /> {t("create_server_btn")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create a server</DialogTitle> <DialogTitle>{t("create_server_title")}</DialogTitle>
<DialogDescription> <DialogDescription>
Pick from whitelisted images and sizes — the platform provisions the {t("create_server_desc")}
rest. No raw cluster config is exposed here.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cs-name">Name</Label> <Label htmlFor="cs-name">{t("create_server_name")}</Label>
<Input <Input
id="cs-name" id="cs-name"
placeholder="survival" placeholder={t("create_server_name_placeholder")}
value={form.name} value={form.name}
onChange={(e) => set("name", e.target.value)} onChange={(e) => set("name", e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cs-sub">Subdomain</Label> <Label htmlFor="cs-sub">{t("create_server_subdomain")}</Label>
<Input <Input
id="cs-sub" id="cs-sub"
placeholder="survival" placeholder={t("create_server_subdomain_placeholder")}
value={form.subdomain} value={form.subdomain}
onChange={(e) => set("subdomain", e.target.value.toLowerCase())} onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{form.subdomain && !subdomainValid {form.subdomain && !subdomainValid
? "Lowercase letters, digits and hyphens only." ? t("create_server_subdomain_invalid")
: `Will be reachable at ${hostFor(form.subdomain || "name", cfg)}`} : t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
</p> </p>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="cs-display">Display name (optional)</Label> <Label htmlFor="cs-display">{t("create_server_display_name")}</Label>
<Input <Input
id="cs-display" id="cs-display"
placeholder="Survival World" placeholder={t("create_server_display_name_placeholder")}
value={form.displayName ?? ""} value={form.displayName ?? ""}
onChange={(e) => set("displayName", e.target.value)} onChange={(e) => set("displayName", e.target.value)}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Image</Label> <Label>{t("create_server_image")}</Label>
<Select value={form.image} onValueChange={(v) => set("image", v)}> <Select value={form.image} onValueChange={(v) => set("image", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue
placeholder={ placeholder={
images.loading images.loading
? "Loading images…" ? t("create_server_image_loading")
: enabledImages.length : enabledImages.length
? "Choose an image" ? t("create_server_image_choose")
: "No whitelisted images" : t("create_server_image_none")
} }
/> />
</SelectTrigger> </SelectTrigger>
@@ -172,7 +178,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Memory</Label> <Label>{t("create_server_memory")}</Label>
<Select value={form.memory} onValueChange={(v) => set("memory", v)}> <Select value={form.memory} onValueChange={(v) => set("memory", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -187,7 +193,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</Select> </Select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Storage</Label> <Label>{t("create_server_storage")}</Label>
<Select value={form.storage} onValueChange={(v) => set("storage", v)}> <Select value={form.storage} onValueChange={(v) => set("storage", v)}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -204,7 +210,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Autostart policy</Label> <Label>{t("create_server_policy")}</Label>
<Select <Select
value={form.autostartPolicy} value={form.autostartPolicy}
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)} onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
@@ -213,7 +219,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{POLICIES.map((p) => ( {policies.map((p) => (
<SelectItem key={p.value} value={p.value}> <SelectItem key={p.value} value={p.value}>
{p.label} {p.label}
</SelectItem> </SelectItem>
@@ -227,10 +233,10 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}> <Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
Cancel {t("create_server_cancel")}
</Button> </Button>
<Button onClick={submit} disabled={!canSubmit}> <Button onClick={submit} disabled={!canSubmit}>
{submitting ? "Creating…" : "Create"} {submitting ? t("create_server_creating") : t("create_server_submit")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+13 -10
View File
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { ArrowDown, RotateCw, Trash2 } from "lucide-react"; import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useLogStream } from "@/lib/useLogStream"; import { useLogStream } from "@/lib/useLogStream";
@@ -19,11 +20,12 @@ const LEVEL_CLASS: Record<LogLevel, string> = {
const PIN_THRESHOLD_PX = 24; const PIN_THRESHOLD_PX = 24;
function StatusIndicator({ status }: { status: StreamStatus }) { function StatusIndicator({ status }: { status: StreamStatus }) {
const { t } = useTranslation("servers");
const map: Record<StreamStatus, { dot: string; label: string; text: string }> = { const map: Record<StreamStatus, { dot: string; label: string; text: string }> = {
connecting: { dot: "bg-amber-400 animate-pulse", label: "Connecting…", text: "text-amber-300" }, connecting: { dot: "bg-amber-400 animate-pulse", label: t("log_connecting"), text: "text-amber-300" },
open: { dot: "bg-emerald-400", label: "Live", text: "text-emerald-300" }, open: { dot: "bg-emerald-400", label: t("log_live"), text: "text-emerald-300" },
reconnecting: { dot: "bg-amber-400 animate-pulse", label: "Reconnecting…", text: "text-amber-300" }, reconnecting: { dot: "bg-amber-400 animate-pulse", label: t("log_reconnecting"), text: "text-amber-300" },
ended: { dot: "bg-zinc-500", label: "Stream ended", text: "text-zinc-400" }, ended: { dot: "bg-zinc-500", label: t("log_ended"), text: "text-zinc-400" },
}; };
const s = map[status]; const s = map[status];
return ( return (
@@ -46,6 +48,7 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
* scrolling back to the bottom re-pins. The buffer is bounded by the controller. * scrolling back to the bottom re-pins. The buffer is bounded by the controller.
*/ */
export function LogConsole({ url }: { url: string }) { export function LogConsole({ url }: { url: string }) {
const { t } = useTranslation("servers");
const { lines, status, clear, reconnect } = useLogStream(url); const { lines, status, clear, reconnect } = useLogStream(url);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true); const [pinned, setPinned] = useState(true);
@@ -81,7 +84,7 @@ export function LogConsole({ url }: { url: string }) {
onClick={reconnect} onClick={reconnect}
className="border-zinc-700 bg-transparent text-zinc-200 hover:bg-zinc-800 hover:text-zinc-50" className="border-zinc-700 bg-transparent text-zinc-200 hover:bg-zinc-800 hover:text-zinc-50"
> >
<RotateCw className="h-3.5 w-3.5" /> Reconnect <RotateCw className="h-3.5 w-3.5" /> {t("log_reconnect_btn")}
</Button> </Button>
)} )}
<Button <Button
@@ -91,7 +94,7 @@ export function LogConsole({ url }: { url: string }) {
disabled={lines.length === 0} disabled={lines.length === 0}
className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50" className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50"
> >
<Trash2 className="h-3.5 w-3.5" /> Clear <Trash2 className="h-3.5 w-3.5" /> {t("log_clear_btn")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -106,13 +109,13 @@ export function LogConsole({ url }: { url: string }) {
{lines.length === 0 ? ( {lines.length === 0 ? (
<p className="select-none py-8 text-center text-zinc-600"> <p className="select-none py-8 text-center text-zinc-600">
{status === "ended" {status === "ended"
? "Stream ended — reconnect to resume following the log." ? t("log_ended_empty")
: "Waiting for output…"} : t("log_waiting")}
</p> </p>
) : ( ) : (
lines.map((line) => ( lines.map((line) => (
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}> <div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
{line.text || " "} {line.text || "\u00A0"}
</div> </div>
)) ))
)} )}
@@ -126,7 +129,7 @@ export function LogConsole({ url }: { url: string }) {
onClick={jumpToLatest} onClick={jumpToLatest}
className="absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg transition-colors hover:bg-primary/90" className="absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg transition-colors hover:bg-primary/90"
> >
<ArrowDown className="h-3.5 w-3.5" /> Jump to latest <ArrowDown className="h-3.5 w-3.5" /> {t("log_jump_latest")}
</button> </button>
)} )}
</div> </div>
+12 -1
View File
@@ -1,6 +1,7 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { Phase } from "@/lib/types"; import type { Phase } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type BadgeVariant = "default" | "muted" | "destructive" | "outline"; export type BadgeVariant = "default" | "muted" | "destructive" | "outline";
@@ -17,6 +18,15 @@ export const PHASE_COLOR: Record<Phase, string> = {
Unknown: "#94a3b8", Unknown: "#94a3b8",
}; };
export const PHASE_KEY: Record<Phase, string> = {
Running: "servers:phase_running",
Starting: "servers:phase_starting",
Stopping: "servers:phase_stopping",
Stopped: "servers:phase_stopped",
Failed: "servers:phase_failed",
Unknown: "servers:phase_unknown",
};
const VARIANT: Record<Phase, BadgeVariant> = { const VARIANT: Record<Phase, BadgeVariant> = {
Running: "default", Running: "default",
Starting: "outline", Starting: "outline",
@@ -43,6 +53,7 @@ export function phaseVariant(phase: Phase): BadgeVariant {
} }
export function PhaseBadge({ phase }: { phase: Phase }) { export function PhaseBadge({ phase }: { phase: Phase }) {
const { t } = useTranslation();
return ( return (
<Badge variant={phaseVariant(phase)} className="gap-1.5"> <Badge variant={phaseVariant(phase)} className="gap-1.5">
<span <span
@@ -52,7 +63,7 @@ export function PhaseBadge({ phase }: { phase: Phase }) {
)} )}
style={{ backgroundColor: phaseColor(phase) }} style={{ backgroundColor: phaseColor(phase) }}
/> />
{phase} {t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}
</Badge> </Badge>
); );
} }
+3 -1
View File
@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import { Loading, NotAuthorized } from "@/components/States"; import { Loading, NotAuthorized } from "@/components/States";
@@ -11,6 +12,7 @@ import { Loading, NotAuthorized } from "@/components/States";
// settled, isAdmin is the fail-closed verdict (false on any /me failure). // settled, isAdmin is the fail-closed verdict (false on any /me failure).
export function RequireAdmin() { export function RequireAdmin() {
const { isAdmin, loading } = useTier(); const { isAdmin, loading } = useTier();
if (loading) return <Loading label="Checking access…" />; const { t } = useTranslation("common");
if (loading) return <Loading label={t("checking_access")} />;
return isAdmin ? <Outlet /> : <NotAuthorized />; return isAdmin ? <Outlet /> : <NotAuthorized />;
} }
+3 -1
View File
@@ -1,5 +1,6 @@
import { Navigate, Outlet } from "react-router-dom"; import { Navigate, Outlet } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
// RequireAuth is the layout gate in front of the whole authenticated app (AppShell // RequireAuth is the layout gate in front of the whole authenticated app (AppShell
@@ -16,12 +17,13 @@ import { useTier } from "@/lib/tier";
// 401 bounces to /login. Every admin route remains independently server-guarded. // 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() { export function RequireAuth() {
const { loading, unauthenticated, mustChangePassword } = useTier(); const { loading, unauthenticated, mustChangePassword } = useTier();
const { t } = useTranslation("common");
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted-foreground"> <div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading… {t("loading")}
</div> </div>
); );
} }
+7 -5
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react"; import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge } from "@/components/PhaseBadge";
@@ -16,6 +17,7 @@ interface Props {
} }
export function ServerCard({ server, cfg, onChanged }: Props) { export function ServerCard({ server, cfg, onChanged }: Props) {
const { t } = useTranslation("servers");
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null); const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const host = hostFor(server.subdomain, cfg); const host = hostFor(server.subdomain, cfg);
@@ -65,7 +67,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{running ? ( {running ? (
<span> <span>
{server.players ?? 0} {server.players ?? 0}
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online {server.maxPlayers ? ` / ${server.maxPlayers}` : ""} {t("online")}
</span> </span>
) : ( ) : (
<span>—</span> <span>—</span>
@@ -78,7 +80,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{server.owned && ( {server.owned && (
<Link to={`/servers/${server.name}`}> <Link to={`/servers/${server.name}`}>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Terminal /> Console <Terminal /> {t("console")}
</Button> </Button>
</Link> </Link>
)} )}
@@ -89,7 +91,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null} disabled={busy !== null}
onClick={() => act("claim", () => api.claim(server.name))} onClick={() => act("claim", () => api.claim(server.name))}
> >
<Hand /> {busy === "claim" ? "Claiming…" : "Claim"} <Hand /> {busy === "claim" ? t("claiming") : t("claim")}
</Button> </Button>
)} )}
{server.owned && {server.owned &&
@@ -100,7 +102,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null} disabled={busy !== null}
onClick={() => act("stop", () => api.stop(server.name))} onClick={() => act("stop", () => api.stop(server.name))}
> >
<Square /> {busy === "stop" ? "Stopping…" : "Stop"} <Square /> {busy === "stop" ? t("stopping") : t("stop")}
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -108,7 +110,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
disabled={busy !== null} disabled={busy !== null}
onClick={() => act("wake", () => api.wake(server.name))} onClick={() => act("wake", () => api.wake(server.name))}
> >
<Play /> {busy === "wake" ? "Waking…" : "Wake"} <Play /> {busy === "wake" ? t("waking") : t("wake")}
</Button> </Button>
))} ))}
</CardFooter> </CardFooter>
+13 -10
View File
@@ -1,17 +1,20 @@
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react"; import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { humanizeError } from "@/lib/api"; import { humanizeError } from "@/lib/api";
export function Loading({ label = "Loading…" }: { label?: string }) { export function Loading({ label }: { label?: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{label} {label ?? t("loading")}
</div> </div>
); );
} }
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) { export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
const { t } = useTranslation("common");
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<AlertTriangle className="h-6 w-6 text-destructive" /> <AlertTriangle className="h-6 w-6 text-destructive" />
@@ -21,7 +24,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
onClick={onRetry} onClick={onRetry}
className="text-sm font-medium text-primary hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
Try again {t("try_again")}
</button> </button>
)} )}
</div> </div>
@@ -50,19 +53,18 @@ export function EmptyState({
} }
export function NotAuthorized() { export function NotAuthorized() {
const { t } = useTranslation("common");
return ( return (
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center"> <div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
<ShieldX className="h-8 w-8 text-destructive" /> <ShieldX className="h-8 w-8 text-destructive" />
<div> <div>
<p className="font-medium">Not authorized</p> <p className="font-medium">{t("not_authorized_title")}</p>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
This area is for platform administrators. If you reached it by typing the {t("not_authorized_body")}
URL, your account does not have admin access — and the API would refuse
the request anyway.
</p> </p>
</div> </div>
<Link to="/" className="text-sm font-medium text-primary hover:underline"> <Link to="/" className="text-sm font-medium text-primary hover:underline">
Back to your dashboard {t("back_to_dashboard")}
</Link> </Link>
</div> </div>
); );
@@ -76,14 +78,15 @@ export function NotAuthorized() {
* data. * data.
*/ */
export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) { export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) {
const { t } = useTranslation("common");
return ( return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-16 text-center"> <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-16 text-center">
<Construction className="h-7 w-7 text-muted-foreground" /> <Construction className="h-7 w-7 text-muted-foreground" />
<div> <div>
<p className="font-medium">Not wired yet</p> <p className="font-medium">{t("not_wired_yet")}</p>
<p className="mt-1 max-w-md text-sm text-muted-foreground">{note}</p> <p className="mt-1 max-w-md text-sm text-muted-foreground">{note}</p>
<p className="mt-2 font-mono text-xs text-muted-foreground/80"> <p className="mt-2 font-mono text-xs text-muted-foreground/80">
pending backend: {endpoint} {t("pending_backend_prefix")}{endpoint}
</p> </p>
</div> </div>
</div> </div>
+7 -3
View File
@@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root; export const Dialog = DialogPrimitive.Root;
@@ -25,7 +26,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef< export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => {
const { t } = useTranslation("common");
return (
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@@ -39,11 +42,12 @@ export const DialogContent = React.forwardRef<
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none"> <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">{t("close_sr")}</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
)); );
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogHeader({ export function DialogHeader({
+63
View File
@@ -0,0 +1,63 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enCommon from "./resources/en-US/common.json";
import enAuth from "./resources/en-US/auth.json";
import enDashboard from "./resources/en-US/dashboard.json";
import enServers from "./resources/en-US/servers.json";
import enAccount from "./resources/en-US/account.json";
import enAdmin from "./resources/en-US/admin.json";
import enOps from "./resources/en-US/ops.json";
import enErrors from "./resources/en-US/errors.json";
import enNavigation from "./resources/en-US/navigation.json";
import zhCommon from "./resources/zh-CN/common.json";
import zhAuth from "./resources/zh-CN/auth.json";
import zhDashboard from "./resources/zh-CN/dashboard.json";
import zhServers from "./resources/zh-CN/servers.json";
import zhAccount from "./resources/zh-CN/account.json";
import zhAdmin from "./resources/zh-CN/admin.json";
import zhOps from "./resources/zh-CN/ops.json";
import zhErrors from "./resources/zh-CN/errors.json";
import zhNavigation from "./resources/zh-CN/navigation.json";
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
"en-US": {
common: enCommon,
auth: enAuth,
dashboard: enDashboard,
servers: enServers,
account: enAccount,
admin: enAdmin,
ops: enOps,
errors: enErrors,
navigation: enNavigation,
},
"zh-CN": {
common: zhCommon,
auth: zhAuth,
dashboard: zhDashboard,
servers: zhServers,
account: zhAccount,
admin: zhAdmin,
ops: zhOps,
errors: zhErrors,
navigation: zhNavigation,
},
},
fallbackLng: "en-US",
defaultNS: "common",
interpolation: {
escapeValue: false,
},
detection: {
order: ["localStorage", "navigator"],
caches: ["localStorage"],
lookupLocalStorage: "felis-lang",
},
});
export default i18next;
@@ -0,0 +1,21 @@
{
"title": "Account",
"subtitle": "Identity and Minecraft linking.",
"session": "Session",
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by local password sign-in or the platform's identity proxy (Zero-Trust / Access) (spec §7/§8).",
"sign_out": "Sign out",
"signing_out": "Signing out…",
"minecraft_link": "Minecraft link",
"checking_link": "Checking link status…",
"linked_title": "Your Minecraft account is linked.",
"linked_desc": "Linked sessions can claim and own servers — ownership operations (claim, wake, stop) are unblocked.",
"uuid_label": "UUID",
"step1_title": "Get a code in-game",
"step1_desc_prefix": "Join any server and run ",
"step1_desc_suffix": " in chat. The server already knows your verified identity, so it hands you a one-time code (valid about 10 minutes).",
"step2_title": "Enter it here",
"link_code": "Link code",
"link_code_placeholder": "ABCD2345",
"verify_btn": "Link",
"verifying": "Verifying…"
}
+19
View File
@@ -0,0 +1,19 @@
{
"title": "Admin",
"subtitle": "Server & content administration",
"signed_in_as": " · signed in as {{email}}",
"servers": "Servers",
"servers_desc": "Create platform servers from the structured §15 form and manage the ones you operate.",
"images": "Images",
"images_desc": "The platform image whitelist — the value space the create form draws from.",
"server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
"images_title": "Images",
"images_subtitle": "The platform image whitelist. Only enabled images can back a new server. Editing the whitelist from the Web is auditable (spec §14) — the mutation routes are a pending admin-tier slice, so this view is read-only for now.",
"no_images_title": "No images whitelisted",
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
"enabled": "enabled",
"disabled": "disabled",
"footer_kubectl": "kubectl / CRD operations",
"footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
"footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power."
}
+18
View File
@@ -0,0 +1,18 @@
{
"login_title": "Sign in to Felis",
"login_subtitle": "Operator console",
"username": "Username",
"password": "Password",
"sign_in": "Sign in",
"signing_in": "Signing in…",
"change_password_title": "Set a new password",
"change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.",
"change_password_subtitle_voluntary": "Update your console password.",
"current_password": "Current password",
"new_password": "New password",
"confirm_new_password": "Confirm new password",
"password_mismatch": "Passwords don't match.",
"password_min_length": "At least {{min}} characters.",
"change_password_btn": "Change password",
"saving": "Saving…"
}
@@ -0,0 +1,23 @@
{
"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}}",
"toggle_theme": "Toggle theme"
}
@@ -0,0 +1,14 @@
{
"title": "Dashboard",
"subtitle": "Your fleet at a glance.",
"no_servers_title": "No servers yet",
"no_servers_hint": "Create your first server or claim an unowned one.",
"go_to_my_servers": "Go to My servers",
"stat_servers": "Servers",
"stat_running": "Running",
"stat_players_online": "Players online",
"fleet": "Fleet",
"manage": "Manage",
"loading_scene": "Loading scene…",
"preparing_scene": "Preparing scene…"
}
@@ -0,0 +1,18 @@
{
"local_auth_disabled": "Password sign-in is turned off here — reach this console through your organization's secure access.",
"invalid_credentials": "Incorrect username or password.",
"weak_password": "Pick a password between 8 and 72 characters.",
"password_unchanged": "Your new password must differ from the current one.",
"not_linked": "Link your Minecraft account before claiming (Account → Link).",
"invalid_code": "That link code is invalid or expired — run /link again in-game for a fresh code.",
"already_linked": "That Minecraft account is already linked to another user.",
"quota_exceeded": "You have reached your server quota.",
"already_claimed": "Someone else just claimed this server.",
"image_not_whitelisted": "That image is not on the whitelist.",
"subdomain_taken": "That subdomain is already in use.",
"already_exists": "A server with that name already exists.",
"cooldown": "Wake is cooling down — try again shortly.",
"session_expired": "Your session expired — please sign in again.",
"forbidden": "You are not allowed to do that.",
"generic": "Something went wrong."
}
@@ -0,0 +1,12 @@
{
"dashboard": "Dashboard",
"my_servers": "My servers",
"account": "Account",
"admin_section": "Admin",
"admin_overview": "Overview",
"admin_servers": "Servers",
"admin_images": "Images",
"sysadmin_section": "SysAdmin",
"sysadmin_overview": "Overview",
"sysadmin_fleet": "Fleet"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "SysAdmin",
"subtitle": "Platform observability cockpit — a window, not a lever.",
"cluster_wide_fleet": "Cluster-wide fleet",
"open_fleet_table": "Open fleet table →",
"footer_pre": "Control-plane scaling, RBAC, Secrets and cluster lifecycle are ",
"footer_kubectl": "kubectl / CRD operations",
"footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.",
"fleet_title": "Fleet",
"fleet_subtitle": "Every server on the platform — phase, owner, capacity.",
"fleet_table_pending_note": "The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships.",
"ops_overview_pending_note": "A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers."
}
@@ -0,0 +1,68 @@
{
"my_servers_title": "My servers",
"my_servers_subtitle": "Wake, stop, or claim the servers you can manage.",
"no_servers_linked": "No servers linked to you",
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
"my_servers_footer": "Servers and game types are provisioned and managed by the platform. You claim a node to operate it; raw cluster config is never exposed here (spec §15).",
"phase_running": "Running",
"phase_starting": "Starting",
"phase_stopping": "Stopping",
"phase_stopped": "Stopped",
"phase_failed": "Failed",
"phase_unknown": "Unknown",
"online": "online",
"console": "Console",
"claim": "Claim",
"claiming": "Claiming…",
"stop": "Stop",
"stopping": "Stopping…",
"wake": "Wake",
"waking": "Waking…",
"server_asleep_title": "Server is asleep",
"server_asleep_body": "Wake it to boot a pod — the live console attaches automatically the moment it starts up.",
"server_shutting_down_title": "Server is shutting down",
"server_shutting_down_body": "The pod is terminating, so the live console has detached. It will be asleep in a moment.",
"server_failed_title": "Server failed to start",
"server_failed_body": "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.",
"console_offline_title": "Console is offline",
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
"my_servers_breadcrumb": "My servers",
"console_card_title": "Console",
"console_card_desc": "Live, read-only output streamed from the running pod over SSE (spec §8). Commands run through a separate path — the panel never holds an RCON password.",
"log_connecting": "Connecting…",
"log_live": "Live",
"log_reconnecting": "Reconnecting…",
"log_ended": "Stream ended",
"log_reconnect_btn": "Reconnect",
"log_clear_btn": "Clear",
"log_ended_empty": "Stream ended — reconnect to resume following the log.",
"log_waiting": "Waiting for output…",
"log_jump_latest": "Jump to latest",
"create_server_btn": "New server",
"create_server_title": "Create a server",
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
"create_server_name": "Name",
"create_server_name_placeholder": "survival",
"create_server_subdomain": "Subdomain",
"create_server_subdomain_placeholder": "survival",
"create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.",
"create_server_subdomain_hint": "Will be reachable at {{host}}",
"create_server_display_name": "Display name (optional)",
"create_server_display_name_placeholder": "Survival World",
"create_server_image": "Image",
"create_server_image_loading": "Loading images…",
"create_server_image_choose": "Choose an image",
"create_server_image_none": "No whitelisted images",
"create_server_memory": "Memory",
"create_server_storage": "Storage",
"create_server_policy": "Autostart policy",
"create_server_policy_owner": "Owner only — wake from the panel",
"create_server_policy_public": "Public — any player join wakes it",
"create_server_policy_allowlist": "Allowlist — listed players wake it",
"create_server_cancel": "Cancel",
"create_server_submit": "Create",
"create_server_creating": "Creating…",
"no_servers_managed": "No servers under your management yet",
"no_servers_managed_hint": "Use \"New server\" to provision one from the vetted spec.",
"server_admin_footer": "Server creation goes through the structured form only — the platform maps your choices onto a vetted Kubernetes spec. Raw cluster config (host networking, host paths, arbitrary images, privileged pods) is never expressible here (spec §15)."
}
@@ -0,0 +1,21 @@
{
"title": "账户",
"subtitle": "身份验证与 Minecraft 关联。",
"session": "会话",
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由本地密码登录还是平台身份代理(Zero-Trust / Access)签发(spec §7/§8)。",
"sign_out": "退出登录",
"signing_out": "退出中…",
"minecraft_link": "Minecraft 关联",
"checking_link": "正在检查关联状态…",
"linked_title": "Minecraft 账户已关联。",
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、唤醒、停止)已解锁。",
"uuid_label": "UUID",
"step1_title": "在游戏中获取验证码",
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性验证码(约 10 分钟内有效)。",
"step2_title": "在此输入",
"link_code": "关联码",
"link_code_placeholder": "ABCD2345",
"verify_btn": "关联",
"verifying": "验证中…"
}
+19
View File
@@ -0,0 +1,19 @@
{
"title": "管理",
"subtitle": "服务器与内容管理",
"signed_in_as": " · 当前登录:{{email}}",
"servers": "服务器",
"servers_desc": "通过结构化表单(§15)创建并管理平台服务器。",
"images": "镜像",
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
"images_title": "镜像",
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。Web 端编辑白名单可审计(spec §14)——变更接口待后续上线,当前仅可查看。",
"no_images_title": "无白名单镜像",
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
"enabled": "已启用",
"disabled": "已禁用",
"footer_kubectl": "kubectl / CRD",
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
"footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。"
}
+18
View File
@@ -0,0 +1,18 @@
{
"login_title": "登录 Felis",
"login_subtitle": "运维控制台",
"username": "用户名",
"password": "密码",
"sign_in": "登录",
"signing_in": "登录中…",
"change_password_title": "设置新密码",
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
"change_password_subtitle_voluntary": "修改控制台登录密码。",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_new_password": "确认新密码",
"password_mismatch": "两次输入的密码不一致。",
"password_min_length": "密码至少 {{min}} 个字符。",
"change_password_btn": "修改密码",
"saving": "保存中…"
}
@@ -0,0 +1,23 @@
{
"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}}",
"toggle_theme": "切换主题"
}
@@ -0,0 +1,14 @@
{
"title": "仪表盘",
"subtitle": "服务器运行状态一览。",
"no_servers_title": "暂无服务器",
"no_servers_hint": "创建一个新服务器或认领一台现有服务器即可开始。",
"go_to_my_servers": "前往我的服务器",
"stat_servers": "服务器",
"stat_running": "运行中",
"stat_players_online": "在线玩家",
"fleet": "服务器概览",
"manage": "管理",
"loading_scene": "正在加载 3D 场景…",
"preparing_scene": "正在准备场景…"
}
@@ -0,0 +1,18 @@
{
"local_auth_disabled": "当前部署已禁用本地密码登录,请通过组织的安全入口访问控制台。",
"invalid_credentials": "用户名或密码错误。",
"weak_password": "密码长度需在 8 到 72 个字符之间。",
"password_unchanged": "新密码不可与当前密码相同。",
"not_linked": "请先关联 Minecraft 账户(账户页 → 关联)。",
"invalid_code": "关联码无效或已过期——请在游戏中重新输入 /link 获取新码。",
"already_linked": "该 Minecraft 账户已关联至其他用户。",
"quota_exceeded": "服务器数量已达配额上限。",
"already_claimed": "该服务器已被他人抢先认领。",
"image_not_whitelisted": "该镜像未在白名单中。",
"subdomain_taken": "该子域名已被占用。",
"already_exists": "同名服务器已存在。",
"cooldown": "启动冷却中——请稍后再试。",
"session_expired": "会话已过期——请重新登录。",
"forbidden": "你无权执行此操作。",
"generic": "出了点问题,请稍后重试。"
}
@@ -0,0 +1,12 @@
{
"dashboard": "仪表盘",
"my_servers": "我的服务器",
"account": "账户",
"admin_section": "管理",
"admin_overview": "概览",
"admin_servers": "服务器",
"admin_images": "镜像",
"sysadmin_section": "系统管理",
"sysadmin_overview": "概览",
"sysadmin_fleet": "全平台服务器"
}
+13
View File
@@ -0,0 +1,13 @@
{
"title": "系统管理",
"subtitle": "平台可观测性看板——观察视角,非管理入口。",
"cluster_wide_fleet": "全平台服务器",
"open_fleet_table": "查看全平台服务器列表 →",
"footer_pre": "控制面扩缩、RBAC、Secrets 及集群生命周期属于 ",
"footer_kubectl": "kubectl / CRD",
"footer_post": " 范畴,无法从此处操作。系统管理面仅提供可观测性——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。",
"fleet_title": "全平台服务器",
"fleet_subtitle": "平台所有服务器——运行状态、所有者、资源用量。",
"fleet_table_pending_note": "全平台服务器列表会读取所有服务器的状态(不限于你拥有的)。该管理员层接口为下一个待开发的后端模块,上线后数据将在此呈现。",
"ops_overview_pending_note": "全平台服务器读取(所有服务器,非仅个人拥有)支撑集群汇总及 3D 视图。该管理员层接口尚待开发,与用户层 GET /me/servers 不同。"
}
@@ -0,0 +1,68 @@
{
"my_servers_title": "我的服务器",
"my_servers_subtitle": "管理你拥有或可用的服务器。",
"no_servers_linked": "暂无关联的服务器",
"no_servers_hint": "认领一台你拥有访问权限的服务器,或联系管理员为你分配。",
"my_servers_footer": "服务器及游戏类型由平台统一管理。认领后即可操作节点,原始集群配置不会暴露在此(spec §15)。",
"phase_running": "运行中",
"phase_starting": "启动中",
"phase_stopping": "停止中",
"phase_stopped": "已停止",
"phase_failed": "启动失败",
"phase_unknown": "未知",
"online": "人在线",
"console": "控制台",
"claim": "认领",
"claiming": "认领中…",
"stop": "停止",
"stopping": "停止中…",
"wake": "唤醒",
"waking": "唤醒中…",
"server_asleep_title": "服务器已休眠",
"server_asleep_body": "唤醒后将拉起 Pod,实时控制台会自动连接。",
"server_shutting_down_title": "服务器正在关闭",
"server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。",
"server_failed_title": "服务器启动失败",
"server_failed_body": "当前无运行中的 Pod,无法获取日志流。重新唤醒后控制台将自动连接。",
"console_offline_title": "控制台未连接",
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
"my_servers_breadcrumb": "我的服务器",
"console_card_title": "控制台",
"console_card_desc": "通过 SSE 从 Pod 实时流式输出的只读日志(spec §8)。命令执行走独立通道——面板不持有 RCON 密码。",
"log_connecting": "连接中…",
"log_live": "实时",
"log_reconnecting": "重连中…",
"log_ended": "连接已断开",
"log_reconnect_btn": "重连",
"log_clear_btn": "清屏",
"log_ended_empty": "连接已断开——重连后可恢复日志输出。",
"log_waiting": "等待输出…",
"log_jump_latest": "滚动到最新",
"create_server_btn": "新建服务器",
"create_server_title": "创建服务器",
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
"create_server_name": "名称",
"create_server_name_placeholder": "survival",
"create_server_subdomain": "子域名",
"create_server_subdomain_placeholder": "survival",
"create_server_subdomain_invalid": "仅支持小写字母、数字和连字符。",
"create_server_subdomain_hint": "访问地址:{{host}}",
"create_server_display_name": "显示名称(选填)",
"create_server_display_name_placeholder": "生存世界",
"create_server_image": "镜像",
"create_server_image_loading": "加载镜像列表中…",
"create_server_image_choose": "选择一个镜像",
"create_server_image_none": "暂无白名单镜像",
"create_server_memory": "内存",
"create_server_storage": "存储",
"create_server_policy": "自动启动策略",
"create_server_policy_owner": "仅所有者——通过面板唤醒",
"create_server_policy_public": "公开——任意玩家加入即唤醒",
"create_server_policy_allowlist": "白名单——仅名单内玩家可唤醒",
"create_server_cancel": "取消",
"create_server_submit": "创建",
"create_server_creating": "创建中…",
"no_servers_managed": "你还没有管理的服务器",
"no_servers_managed_hint": "使用「新建服务器」在线分配。",
"server_admin_footer": "服务器仅通过此结构化表单创建——平台将选择映射为审核后的 Kubernetes spec。宿主机网络、宿主机路径、任意镜像、特权 Pod 等原始集群配置在此不可表达(spec §15)。"
}
+6
View File
@@ -55,4 +55,10 @@
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* Global theme toggle transition */
.theme-transitioning,
.theme-transitioning * {
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, text-decoration-color 150ms ease, fill 150ms ease, stroke 150ms ease !important;
}
} }
+18 -16
View File
@@ -9,6 +9,7 @@ import type {
WhitelistImage, WhitelistImage,
} from "./types"; } from "./types";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
import i18next from "i18next";
// Typed client for the felis-api external face (spec §7). Credentials are sent so // Typed client for the felis-api external face (spec §7). Credentials are sent so
// the upstream Zero-Trust / Access cookie rides along; the panel never holds a // the upstream Zero-Trust / Access cookie rides along; the panel never holds a
@@ -126,37 +127,38 @@ export function consoleStreamURL(apiBase: string, name: string): string {
/** humanizeError turns the stable error code into a user-facing line. */ /** humanizeError turns the stable error code into a user-facing line. */
export function humanizeError(e: unknown): string { export function humanizeError(e: unknown): string {
const err = e as Partial<ApiError>; const err = e as Partial<ApiError>;
const t = i18next.getFixedT(null, "errors");
switch (err.code) { switch (err.code) {
// Local-password auth (spec §B1). // Local-password auth (spec §B1).
case "local_auth_disabled": case "local_auth_disabled":
return "Password sign-in is turned off here — reach this console through your organization's secure access."; return t("local_auth_disabled");
case "invalid_credentials": case "invalid_credentials":
return "Incorrect username or password."; return t("invalid_credentials");
case "weak_password": case "weak_password":
return "Pick a password between 8 and 72 characters."; return t("weak_password");
case "password_unchanged": case "password_unchanged":
return "Your new password must differ from the current one."; return t("password_unchanged");
case "not_linked": case "not_linked":
return "Link your Minecraft account before claiming (Account → Link)."; return t("not_linked");
case "invalid_code": case "invalid_code":
return "That link code is invalid or expired — run /link again in-game for a fresh code."; return t("invalid_code");
case "already_linked": case "already_linked":
return "That Minecraft account is already linked to another user."; return t("already_linked");
case "quota_exceeded": case "quota_exceeded":
return "You have reached your server quota."; return t("quota_exceeded");
case "already_claimed": case "already_claimed":
return "Someone else just claimed this server."; return t("already_claimed");
case "image_not_whitelisted": case "image_not_whitelisted":
return "That image is not on the whitelist."; return t("image_not_whitelisted");
case "subdomain_taken": case "subdomain_taken":
return "That subdomain is already in use."; return t("subdomain_taken");
case "already_exists": case "already_exists":
return "A server with that name already exists."; return t("already_exists");
case "cooldown": case "cooldown":
return "Wake is cooling down — try again shortly."; return t("cooldown");
default: default:
if (err.status === 401) return "Your session expired — please sign in again."; if (err.status === 401) return t("session_expired");
if (err.status === 403) return "You are not allowed to do that."; if (err.status === 403) return t("forbidden");
return err.message ?? "Something went wrong."; return err.message ?? t("generic");
} }
} }
+14 -14
View File
@@ -23,7 +23,7 @@ import {
export interface NavItem { export interface NavItem {
to: string; to: string;
label: string; key: string;
icon: LucideIcon; icon: LucideIcon;
/** react-router NavLink `end` — exact-match active styling for index routes. */ /** react-router NavLink `end` — exact-match active styling for index routes. */
end?: boolean; end?: boolean;
@@ -31,8 +31,8 @@ export interface NavItem {
export interface NavSection { export interface NavSection {
id: "user" | "admin" | "ops"; id: "user" | "admin" | "ops";
/** Section heading shown in the sidebar; null renders no heading (User-Side). */ /** Section heading key; null renders no heading (User-Side). */
title: string | null; titleKey: string | null;
/** When true the section is shown only to admins (is_admin === true). */ /** When true the section is shown only to admins (is_admin === true). */
adminOnly: boolean; adminOnly: boolean;
items: NavItem[]; items: NavItem[];
@@ -41,31 +41,31 @@ export interface NavSection {
export const NAV_SECTIONS: NavSection[] = [ export const NAV_SECTIONS: NavSection[] = [
{ {
id: "user", id: "user",
title: null, titleKey: null,
adminOnly: false, adminOnly: false,
items: [ items: [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, { to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
{ to: "/servers", label: "My servers", icon: Server }, { to: "/servers", key: "my_servers", icon: Server },
{ to: "/account", label: "Account", icon: UserRound }, { to: "/account", key: "account", icon: UserRound },
], ],
}, },
{ {
id: "admin", id: "admin",
title: "Admin", titleKey: "admin_section",
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: "/admin", label: "Overview", icon: ShieldCheck, end: true }, { to: "/admin", key: "admin_overview", icon: ShieldCheck, end: true },
{ to: "/admin/servers", label: "Servers", icon: ServerCog }, { to: "/admin/servers", key: "admin_servers", icon: ServerCog },
{ to: "/admin/images", label: "Images", icon: Boxes }, { to: "/admin/images", key: "admin_images", icon: Boxes },
], ],
}, },
{ {
id: "ops", id: "ops",
title: "SysAdmin", titleKey: "sysadmin_section",
adminOnly: true, adminOnly: true,
items: [ items: [
{ to: "/ops", label: "Overview", icon: Gauge, end: true }, { to: "/ops", key: "sysadmin_overview", icon: Gauge, end: true },
{ to: "/ops/fleet", label: "Fleet", icon: Network }, { to: "/ops/fleet", key: "sysadmin_fleet", icon: Network },
], ],
}, },
]; ];
+98
View File
@@ -0,0 +1,98 @@
import { createContext, useContext, useEffect, useState } from "react";
export type Theme = "light" | "dark";
type ThemeProviderProps = {
children: React.ReactNode;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
};
const initialState: ThemeProviderState = {
theme: "dark",
setTheme: () => null,
toggleTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
storageKey = "felis-theme",
...props
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = localStorage.getItem(storageKey) as Theme | null;
if (stored === "light" || stored === "dark") {
return stored;
}
// Auto-detect system preference
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(theme);
}, [theme]);
// Listen to system preference changes ONLY IF the user hasn't explicitly set a preference yet
useEffect(() => {
const stored = localStorage.getItem(storageKey);
if (stored === "light" || stored === "dark") {
return;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
const root = window.document.documentElement;
root.classList.add("theme-transitioning");
setThemeState(e.matches ? "dark" : "light");
setTimeout(() => {
root.classList.remove("theme-transitioning");
}, 150);
};
mediaQuery.addEventListener("change", handleSystemThemeChange);
return () => {
mediaQuery.removeEventListener("change", handleSystemThemeChange);
};
}, [storageKey]);
const setTheme = (newTheme: Theme) => {
const root = window.document.documentElement;
root.classList.add("theme-transitioning");
localStorage.setItem(storageKey, newTheme);
setThemeState(newTheme);
setTimeout(() => {
root.classList.remove("theme-transitioning");
}, 150);
};
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return (
<ThemeProviderContext.Provider
{...props}
value={{ theme, setTheme, toggleTheme }}
>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};
+1
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "./i18n";
import App from "./App"; import App from "./App";
import "./index.css"; import "./index.css";
+22 -26
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react"; import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -18,6 +19,7 @@ import { useTier } from "@/lib/tier";
export function Account() { export function Account() {
const status = useAsync(() => api.linkStatus(), []); const status = useAsync(() => api.linkStatus(), []);
const { refresh } = useTier(); const { refresh } = useTier();
const { t } = useTranslation("account");
// Sign-out ends a local-password session: clear it server-side, then refresh /me. // Sign-out ends a local-password session: clear it server-side, then refresh /me.
// For a local session that read now 401s → the tier model flips to // For a local session that read now 401s → the tier model flips to
@@ -67,24 +69,20 @@ export function Account() {
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Account</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Identity and Minecraft linking. {t("subtitle")}
</p> </p>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4 text-primary" /> Session <ShieldCheck className="h-4 w-4 text-primary" /> {t("session")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-sm text-muted-foreground"> <CardContent className="space-y-4 text-sm text-muted-foreground">
<p> <p>{t("session_desc")}</p>
The panel itself holds no credentials — every request rides your existing
session cookie, whether issued by local password sign-in or the platform's
identity proxy (Zero-Trust / Access) (spec §7/§8).
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -92,7 +90,7 @@ export function Account() {
disabled={signingOut} disabled={signingOut}
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
{signingOut ? "Signing out…" : "Sign out"} {signingOut ? t("signing_out") : t("sign_out")}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -100,14 +98,14 @@ export function Account() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-primary" /> Minecraft link <Link2 className="h-4 w-4 text-primary" /> {t("minecraft_link")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm"> <CardContent className="text-sm">
{linked ? ( {linked ? (
<LinkedState uuid={verifiedUUID} /> <LinkedState uuid={verifiedUUID} />
) : status.loading && !status.data ? ( ) : status.loading && !status.data ? (
<Loading label="Checking link status…" /> <Loading label={t("checking_link")} />
) : status.error ? ( ) : status.error ? (
<ErrorState error={status.error} onRetry={status.reload} /> <ErrorState error={status.error} onRetry={status.reload} />
) : ( ) : (
@@ -129,19 +127,17 @@ export function Account() {
* just verified it (start does not return it), so a pre-existing link renders * just verified it (start does not return it), so a pre-existing link renders
* the confirmation without a UUID rather than inventing one. */ * the confirmation without a UUID rather than inventing one. */
function LinkedState({ uuid }: { uuid: string | null }) { function LinkedState({ uuid }: { uuid: string | null }) {
const { t } = useTranslation("account");
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 font-medium text-foreground"> <div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> <CheckCircle2 className="h-4 w-4 text-emerald-500" />
Your Minecraft account is linked. {t("linked_title")}
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t("linked_desc")}</p>
Linked sessions can claim and own servers — ownership operations (claim,
wake, stop) are unblocked.
</p>
{uuid && ( {uuid && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs uppercase tracking-wide">UUID</span> <span className="text-xs uppercase tracking-wide">{t("uuid_label")}</span>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground"> <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{uuid} {uuid}
</code> </code>
@@ -167,34 +163,34 @@ function LinkForm({
error: string | null; error: string | null;
onSubmit: (e: FormEvent) => void; onSubmit: (e: FormEvent) => void;
}) { }) {
const { t } = useTranslation("account");
return ( return (
<ol className="space-y-4"> <ol className="space-y-4">
<li className="flex gap-3"> <li className="flex gap-3">
<StepBadge n={1} /> <StepBadge n={1} />
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium text-foreground">Get a code in-game</p> <p className="font-medium text-foreground">{t("step1_title")}</p>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Join any server and run{" "} {t("step1_desc_prefix")}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground"> <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
/link /link
</code>{" "} </code>
in chat. The server already knows your verified identity, so it hands {t("step1_desc_suffix")}
you a one-time code (valid about 10 minutes).
</p> </p>
</div> </div>
</li> </li>
<li className="flex gap-3"> <li className="flex gap-3">
<StepBadge n={2} /> <StepBadge n={2} />
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<p className="font-medium text-foreground">Enter it here</p> <p className="font-medium text-foreground">{t("step2_title")}</p>
<form onSubmit={onSubmit} className="space-y-2"> <form onSubmit={onSubmit} className="space-y-2">
<Label htmlFor="link-code">Link code</Label> <Label htmlFor="link-code">{t("link_code")}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="link-code" id="link-code"
value={code} value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())} onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="ABCD2345" placeholder={t("link_code_placeholder")}
autoComplete="off" autoComplete="off"
autoCapitalize="characters" autoCapitalize="characters"
spellCheck={false} spellCheck={false}
@@ -206,7 +202,7 @@ function LinkForm({
type="submit" type="submit"
disabled={submitting || code.trim().length === 0} disabled={submitting || code.trim().length === 0}
> >
{submitting ? "Verifying…" : "Link"} {submitting ? t("verifying") : t("verify_btn")}
</Button> </Button>
</div> </div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
+13 -11
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout"; import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,6 +26,7 @@ const MIN_PASSWORD = 8;
export function ChangePassword() { export function ChangePassword() {
const { loading, unauthenticated, mustChangePassword, refresh } = useTier(); const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("auth");
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [next, setNext] = useState(""); const [next, setNext] = useState("");
@@ -34,10 +36,10 @@ export function ChangePassword() {
if (loading) { if (loading) {
return ( return (
<AuthLayout title="Felis"> <AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading… {t("common:loading")}
</div> </div>
</AuthLayout> </AuthLayout>
); );
@@ -69,18 +71,18 @@ export function ChangePassword() {
return ( return (
<AuthLayout <AuthLayout
title="Set a new password" title={t("change_password_title")}
subtitle={ subtitle={
mustChangePassword mustChangePassword
? "Your account was issued a one-time password. Choose a new one to continue." ? t("change_password_subtitle_forced")
: "Update your console password." : t("change_password_subtitle_voluntary")
} }
> >
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current">Current password</Label> <Label htmlFor="current">{t("current_password")}</Label>
<Input <Input
id="current" id="current"
type="password" type="password"
@@ -92,7 +94,7 @@ export function ChangePassword() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-password">New password</Label> <Label htmlFor="new-password">{t("new_password")}</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
@@ -103,12 +105,12 @@ export function ChangePassword() {
/> />
{tooShort && ( {tooShort && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
At least {MIN_PASSWORD} characters. {t("password_min_length", { min: MIN_PASSWORD })}
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password">Confirm new password</Label> <Label htmlFor="confirm-password">{t("confirm_new_password")}</Label>
<Input <Input
id="confirm-password" id="confirm-password"
type="password" type="password"
@@ -118,12 +120,12 @@ export function ChangePassword() {
aria-invalid={mismatch ? true : undefined} aria-invalid={mismatch ? true : undefined}
/> />
{mismatch && ( {mismatch && (
<p className="text-xs text-destructive">Passwords don't match.</p> <p className="text-xs text-destructive">{t("password_mismatch")}</p>
)} )}
</div> </div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={!canSubmit}> <Button type="submit" className="w-full" disabled={!canSubmit}>
{submitting ? "Saving…" : "Change password"} {submitting ? t("saving") : t("change_password_btn")}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
+15 -12
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useMemo } from "react"; import { lazy, Suspense, useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Activity, Server, Play, Hand } from "lucide-react"; import { Activity, Server, Play, Hand } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -41,6 +42,7 @@ function Stat({
export function Dashboard() { export function Dashboard() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("dashboard");
const counts = useMemo(() => { const counts = useMemo(() => {
const servers = data ?? []; const servers = data ?? [];
@@ -55,8 +57,8 @@ export function Dashboard() {
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">Your fleet at a glance.</p> <p className="text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
{loading && !data ? ( {loading && !data ? (
@@ -79,14 +81,15 @@ function FleetView({
counts: { total: number; running: number; players: number }; counts: { total: number; running: number; players: number };
hasConfig: boolean; hasConfig: boolean;
}) { }) {
const { t } = useTranslation("dashboard");
if (servers.length === 0) { if (servers.length === 0) {
return ( return (
<EmptyState <EmptyState
title="No servers yet" title={t("no_servers_title")}
hint="Create your first server or claim an unowned one." hint={t("no_servers_hint")}
> >
<Link to="/servers"> <Link to="/servers">
<Button>Go to My servers</Button> <Button>{t("go_to_my_servers")}</Button>
</Link> </Link>
</EmptyState> </EmptyState>
); );
@@ -95,29 +98,29 @@ function FleetView({
return ( return (
<> <>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Stat icon={Server} label="Servers" value={counts.total} /> <Stat icon={Server} label={t("stat_servers")} value={counts.total} />
<Stat icon={Play} label="Running" value={counts.running} /> <Stat icon={Play} label={t("stat_running")} value={counts.running} />
<Stat icon={Activity} label="Players online" value={counts.players} /> <Stat icon={Activity} label={t("stat_players_online")} value={counts.players} />
</div> </div>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardHeader className="flex-row items-center justify-between space-y-0"> <CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Fleet</CardTitle> <CardTitle className="text-base">{t("fleet")}</CardTitle>
<Link to="/servers"> <Link to="/servers">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
Manage <Hand className="h-3.5 w-3.5" /> {t("manage")} <Hand className="h-3.5 w-3.5" />
</Button> </Button>
</Link> </Link>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{hasConfig ? ( {hasConfig ? (
<div className="h-[340px] w-full bg-gradient-to-b from-transparent to-primary/5"> <div className="h-[340px] w-full bg-gradient-to-b from-transparent to-primary/5">
<Suspense fallback={<Loading label="Loading scene…" />}> <Suspense fallback={<Loading label={t("loading_scene")} />}>
<VoxelFleet servers={servers} /> <VoxelFleet servers={servers} />
</Suspense> </Suspense>
</div> </div>
) : ( ) : (
<Loading label="Preparing scene…" /> <Loading label={t("preparing_scene")} />
)} )}
</CardContent> </CardContent>
</Card> </Card>
+8 -6
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate } from "react-router-dom";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AuthLayout } from "@/components/AuthLayout"; import { AuthLayout } from "@/components/AuthLayout";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -20,6 +21,7 @@ import { api, humanizeError } from "@/lib/api";
export function Login() { export function Login() {
const { loading, identity, mustChangePassword, refresh } = useTier(); const { loading, identity, mustChangePassword, refresh } = useTier();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("auth");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -30,10 +32,10 @@ export function Login() {
// would briefly see a login form before being redirected away. // would briefly see a login form before being redirected away.
if (loading) { if (loading) {
return ( return (
<AuthLayout title="Felis"> <AuthLayout title={t("common:brand_name")}>
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading… {t("common:loading")}
</div> </div>
</AuthLayout> </AuthLayout>
); );
@@ -59,12 +61,12 @@ export function Login() {
} }
return ( return (
<AuthLayout title="Sign in to Felis" subtitle="Operator console"> <AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<form onSubmit={submit} className="space-y-4"> <form onSubmit={submit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">{t("username")}</Label>
<Input <Input
id="username" id="username"
value={username} value={username}
@@ -78,7 +80,7 @@ export function Login() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t("password")}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@@ -94,7 +96,7 @@ export function Login() {
className="w-full" className="w-full"
disabled={submitting || !username.trim() || !password} disabled={submitting || !username.trim() || !password}
> >
{submitting ? "Signing in…" : "Sign in"} {submitting ? t("signing_in") : t("sign_in")}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
+8 -8
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -10,14 +11,15 @@ import { useAsync, useConfig } from "@/lib/hooks";
export function MyServers() { export function MyServers() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">My servers</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("my_servers_title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Wake, stop, or claim the servers you can manage. {t("my_servers_subtitle")}
</p> </p>
</div> </div>
@@ -26,11 +28,11 @@ export function MyServers() {
) : error ? ( ) : error ? (
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : !cfg ? ( ) : !cfg ? (
<Loading label="Loading config…" /> <Loading label={t("common:loading_config")} />
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<EmptyState <EmptyState
title="No servers linked to you" title={t("no_servers_linked")}
hint="Claim an unowned server you have access to, or ask an admin to provision one." hint={t("no_servers_hint")}
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -42,9 +44,7 @@ export function MyServers() {
<Card className="border-dashed bg-transparent"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="p-4 text-xs text-muted-foreground">
Servers and game types are provisioned and managed by the platform. You {t("my_servers_footer")}
claim a node to operate it; raw cluster config is never exposed here
(spec §15).
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+18 -17
View File
@@ -1,5 +1,6 @@
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react"; import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PhaseBadge } from "@/components/PhaseBadge"; import { PhaseBadge } from "@/components/PhaseBadge";
@@ -15,37 +16,38 @@ import type { Phase } from "@/lib/types";
// Stopped/Failed/Stopping each get their own honest line rather than an empty // Stopped/Failed/Stopping each get their own honest line rather than an empty
// console. `default` covers Unknown plus any future phase the backend may emit // console. `default` covers Unknown plus any future phase the backend may emit
// that the panel hasn't modelled yet — the screen stays informative regardless. // that the panel hasn't modelled yet — the screen stays informative regardless.
function notStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } { function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } {
const { t } = useTranslation("servers");
switch (phase) { switch (phase) {
case "Stopped": case "Stopped":
return { return {
icon: Moon, icon: Moon,
title: "Server is asleep", title: t("server_asleep_title"),
body: "Wake it to boot a pod — the live console attaches automatically the moment it starts up.", body: t("server_asleep_body"),
}; };
case "Stopping": case "Stopping":
return { return {
icon: Moon, icon: Moon,
title: "Server is shutting down", title: t("server_shutting_down_title"),
body: "The pod is terminating, so the live console has detached. It will be asleep in a moment.", body: t("server_shutting_down_body"),
}; };
case "Failed": case "Failed":
return { return {
icon: ShieldAlert, icon: ShieldAlert,
title: "Server failed to start", title: t("server_failed_title"),
body: "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.", body: t("server_failed_body"),
}; };
default: default:
return { return {
icon: HelpCircle, icon: HelpCircle,
title: "Console is offline", title: t("console_offline_title"),
body: "The live console attaches automatically as soon as the server is running.", body: t("console_offline_body"),
}; };
} }
} }
function NotStreaming({ phase }: { phase: Phase }) { function NotStreaming({ phase }: { phase: Phase }) {
const { icon: Icon, title, body } = notStreamingCopy(phase); const { icon: Icon, title, body } = useNotStreamingCopy(phase);
return ( return (
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground"> <div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" /> <Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" />
@@ -60,6 +62,7 @@ function NotStreaming({ phase }: { phase: Phase }) {
export function ServerConsole() { export function ServerConsole() {
const { name = "" } = useParams(); const { name = "" } = useParams();
const cfg = useConfig(); const cfg = useConfig();
const { t } = useTranslation("servers");
const { data, error, loading, reload } = useAsync( const { data, error, loading, reload } = useAsync(
() => api.status(name), () => api.status(name),
[name], [name],
@@ -78,7 +81,7 @@ export function ServerConsole() {
to="/servers" to="/servers"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
> >
<ArrowLeft className="h-4 w-4" /> My servers <ArrowLeft className="h-4 w-4" /> {t("my_servers_breadcrumb")}
</Link> </Link>
{loading && !data ? ( {loading && !data ? (
@@ -106,11 +109,11 @@ export function ServerConsole() {
size="sm" size="sm"
onClick={() => api.stop(name).then(reload)} onClick={() => api.stop(name).then(reload)}
> >
Stop {t("stop")}
</Button> </Button>
) : ( ) : (
<Button size="sm" onClick={() => api.wake(name).then(reload)}> <Button size="sm" onClick={() => api.wake(name).then(reload)}>
Wake {t("wake")}
</Button> </Button>
)} )}
</div> </div>
@@ -119,12 +122,10 @@ export function ServerConsole() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Terminal className="h-4 w-4" /> Console <Terminal className="h-4 w-4" /> {t("console_card_title")}
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Live, read-only output streamed from the running pod over SSE {t("console_card_desc")}
(spec §8). Commands run through a separate path — the panel never
holds an RCON password.
</p> </p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
+23 -23
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ServerCog, Boxes, ShieldCheck } from "lucide-react"; import { ServerCog, Boxes, ShieldCheck } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
@@ -7,33 +8,34 @@ import { useTier } from "@/lib/tier";
// content/server-admin surfaces. It deliberately states the four-power boundary // content/server-admin surfaces. It deliberately states the four-power boundary
// so an admin never expects cluster-lifecycle levers here — those stay kubectl/CRD. // so an admin never expects cluster-lifecycle levers here — those stay kubectl/CRD.
const LINKS = [
{
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() { export function AdminHome() {
const { identity } = useTier(); 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 ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ShieldCheck className="h-6 w-6 text-primary" /> <ShieldCheck className="h-6 w-6 text-primary" />
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Admin</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Server &amp; content administration {t("subtitle")}
{identity?.email ? ` · signed in as ${identity.email}` : ""}. {identity?.email ? t("signed_in_as", { email: identity.email }) : ""}.
</p> </p>
</div> </div>
</div> </div>
@@ -54,11 +56,9 @@ export function AdminHome() {
<Card className="border-dashed bg-transparent"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="p-4 text-xs text-muted-foreground">
Cluster scaling, RBAC, Secrets and control-plane lifecycle are{" "} {t("footer_pre")}
<span className="font-medium">kubectl / CRD operations</span> and are <span className="font-medium">{t("footer_kubectl")}</span>
intentionally not available from the panel — the four-power separation {t("footer_post")}
(build / runtime / operator / app) is preserved. This is a window onto the
platform, not a lever for operator power.
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+8 -8
View File
@@ -1,4 +1,5 @@
import { Boxes, CheckCircle2, CircleSlash } from "lucide-react"; import { Boxes, CheckCircle2, CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Loading, ErrorState, EmptyState } from "@/components/States"; import { Loading, ErrorState, EmptyState } from "@/components/States";
@@ -11,17 +12,16 @@ import { useAsync } from "@/lib/hooks";
// added, so this page surfaces the whitelist honestly and labels the missing // added, so this page surfaces the whitelist honestly and labels the missing
// mutation rather than shipping dead buttons. // mutation rather than shipping dead buttons.
export function ImageAdmin() { export function ImageAdmin() {
const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []); const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
const images = data ?? []; const images = data ?? [];
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Images</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("images_title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The platform image whitelist. Only enabled images can back a new server. {t("images_subtitle")}
Editing the whitelist from the Web is auditable (spec §14) — the mutation
routes are a pending admin-tier slice, so this view is read-only for now.
</p> </p>
</div> </div>
@@ -31,8 +31,8 @@ export function ImageAdmin() {
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : images.length === 0 ? ( ) : images.length === 0 ? (
<EmptyState <EmptyState
title="No images whitelisted" title={t("no_images_title")}
hint="The whitelist is empty — a platform admin must add one (CLI for now)." hint={t("no_images_hint")}
/> />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@@ -50,11 +50,11 @@ export function ImageAdmin() {
</div> </div>
{img.enabled ? ( {img.enabled ? (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary"> <span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary">
<CheckCircle2 className="h-3.5 w-3.5" /> enabled <CheckCircle2 className="h-3.5 w-3.5" /> {t("enabled")}
</span> </span>
) : ( ) : (
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground"> <span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<CircleSlash className="h-3.5 w-3.5" /> disabled <CircleSlash className="h-3.5 w-3.5" /> {t("disabled")}
</span> </span>
)} )}
</CardContent> </CardContent>
+9 -10
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { ServerCard } from "@/components/ServerCard"; import { ServerCard } from "@/components/ServerCard";
import { CreateServerDialog } from "@/components/CreateServerDialog"; import { CreateServerDialog } from "@/components/CreateServerDialog";
@@ -17,16 +18,17 @@ import { useAsync, useConfig } from "@/lib/hooks";
export function ServerAdmin() { export function ServerAdmin() {
const cfg = useConfig(); const cfg = useConfig();
const { data, error, loading, reload } = useAsync(() => api.myServers(), []); const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
const { t } = useTranslation("admin");
const { t: ts } = useTranslation("servers");
const servers = data ?? []; const servers = data ?? [];
return ( return (
<div className="mx-auto max-w-6xl space-y-6"> <div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Servers</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("servers")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Create platform servers from the structured form and manage the ones {t("server_admin_subtitle")}
you operate.
</p> </p>
</div> </div>
{cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />} {cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
@@ -37,11 +39,11 @@ export function ServerAdmin() {
) : error ? ( ) : error ? (
<ErrorState error={error} onRetry={reload} /> <ErrorState error={error} onRetry={reload} />
) : !cfg ? ( ) : !cfg ? (
<Loading label="Loading config…" /> <Loading label={ts("common:loading_config")} />
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<EmptyState <EmptyState
title="No servers under your management yet" title={ts("no_servers_managed")}
hint="Use Create server to provision one from the vetted spec." hint={ts("no_servers_managed_hint")}
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@@ -53,10 +55,7 @@ export function ServerAdmin() {
<Card className="border-dashed bg-transparent"> <Card className="border-dashed bg-transparent">
<CardContent className="p-4 text-xs text-muted-foreground"> <CardContent className="p-4 text-xs text-muted-foreground">
Server creation goes through the structured form only — the platform maps {ts("server_admin_footer")}
your choices onto a vetted Kubernetes spec. Raw cluster config (host
networking, host paths, arbitrary images, privileged pods) is never
expressible here (spec §15).
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+5 -3
View File
@@ -1,4 +1,5 @@
import { Network } from "lucide-react"; import { Network } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PendingBackend } from "@/components/States"; import { PendingBackend } from "@/components/States";
// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with // FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with
@@ -6,21 +7,22 @@ import { PendingBackend } from "@/components/States";
// fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend // fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend
// slice. Honest placeholder until that lands — no fabricated rows. // slice. Honest placeholder until that lands — no fabricated rows.
export function FleetTable() { export function FleetTable() {
const { t } = useTranslation("ops");
return ( return (
<div className="mx-auto max-w-5xl space-y-6"> <div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Network className="h-6 w-6 text-primary" /> <Network className="h-6 w-6 text-primary" />
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Fleet</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Every server on the platform — phase, owner, capacity. {t("fleet_subtitle")}
</p> </p>
</div> </div>
</div> </div>
<PendingBackend <PendingBackend
endpoint="GET /api/v1/admin/servers" endpoint="GET /api/v1/admin/servers"
note="The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships." note={t("fleet_table_pending_note")}
/> />
</div> </div>
); );
+10 -9
View File
@@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Gauge, Network, ServerOff } from "lucide-react"; import { Gauge, Network, ServerOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { PendingBackend } from "@/components/States"; import { PendingBackend } from "@/components/States";
@@ -11,14 +12,15 @@ import { PendingBackend } from "@/components/States";
// does not exist yet — that is a separate Oracle-verifiable backend slice. Until // does not exist yet — that is a separate Oracle-verifiable backend slice. Until
// then this surface is honest about the gap rather than charting fake data. // then this surface is honest about the gap rather than charting fake data.
export function OpsOverview() { export function OpsOverview() {
const { t } = useTranslation("ops");
return ( return (
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Gauge className="h-6 w-6 text-primary" /> <Gauge className="h-6 w-6 text-primary" />
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">SysAdmin</h1> <h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Platform observability cockpit — a window, not a lever. {t("subtitle")}
</p> </p>
</div> </div>
</div> </div>
@@ -26,17 +28,17 @@ export function OpsOverview() {
<Card> <Card>
<CardContent className="space-y-3 p-4"> <CardContent className="space-y-3 p-4">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<Network className="h-4 w-4 text-primary" /> Cluster-wide fleet <Network className="h-4 w-4 text-primary" /> {t("cluster_wide_fleet")}
</div> </div>
<PendingBackend <PendingBackend
endpoint="GET /api/v1/admin/servers" endpoint="GET /api/v1/admin/servers"
note="A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers." note={t("ops_overview_pending_note")}
/> />
<Link <Link
to="/ops/fleet" to="/ops/fleet"
className="inline-block text-sm font-medium text-primary hover:underline" className="inline-block text-sm font-medium text-primary hover:underline"
> >
Open fleet table → {t("open_fleet_table")}
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -45,10 +47,9 @@ export function OpsOverview() {
<CardContent className="flex items-start gap-3 p-4 text-xs text-muted-foreground"> <CardContent className="flex items-start gap-3 p-4 text-xs text-muted-foreground">
<ServerOff className="mt-0.5 h-4 w-4 shrink-0" /> <ServerOff className="mt-0.5 h-4 w-4 shrink-0" />
<span> <span>
Control-plane scaling, RBAC, Secrets and cluster lifecycle are{" "} {t("footer_pre")}
<span className="font-medium">kubectl / CRD operations</span> and are not <span className="font-medium">{t("footer_kubectl")}</span>
reachable from here. SysAdmin-Side is observability only — the {t("footer_post")}
four-power separation (build / runtime / operator / app) is preserved.
</span> </span>
</CardContent> </CardContent>
</Card> </Card>
+1 -1
View File
@@ -14,5 +14,5 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts", "dev/**/*.ts"]
} }
+21 -10
View File
@@ -1,17 +1,28 @@
import { defineConfig } from "vite"; import { defineConfig, type PluginOption } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
// The panel is served by felis-api behind Zero-Trust; the API lives under the // The panel is served by felis-api behind Zero-Trust; the API lives under the
// same origin in production, so /api proxies there in dev. // same origin in production, so /api proxies there in dev.
export default defineConfig({ export default defineConfig(async ({ mode }) => {
plugins: [react()], const plugins: PluginOption[] = [react()];
resolve: { if (mode === "mock") {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, const { mockApiPlugin } = await import("./dev/mockApi");
}, plugins.unshift(mockApiPlugin());
server: { }
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true }, return {
plugins,
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
}, },
}, server: {
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true },
},
},
test: {
setupFiles: ["./vitest.setup.ts"],
},
};
}); });
+4
View File
@@ -0,0 +1,4 @@
import i18next from "i18next";
import "./src/i18n";
i18next.changeLanguage("en-US");