Merge branch 'main' of https://github.com/MliroLirrorsIngenuity/Felis
This commit is contained in:
54 files changed
+1968
-247
No files matched your search
+325
@@ -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.
|
||||
@@ -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
@@ -1,9 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+104
-1
@@ -14,9 +14,12 @@
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.3.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"three": "^0.172.0"
|
||||
@@ -283,6 +286,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -2750,6 +2762,52 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
|
||||
"integrity": "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -3301,6 +3359,33 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3815,7 +3900,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3906,6 +3991,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -4122,6 +4216,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"description": "Felis control panel — external face of felis-api (spec §7)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:mock": "vite --mode mock",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -18,9 +19,12 @@
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.3.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"three": "^0.172.0"
|
||||
|
||||
+5
-2
@@ -1,4 +1,5 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ThemeProvider } from "@/lib/theme";
|
||||
import { TierProvider } from "@/lib/tier";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
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.
|
||||
export default function App() {
|
||||
return (
|
||||
<TierProvider>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider storageKey="felis-theme">
|
||||
<TierProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Pre-app local-password surfaces (spec §B1). They sit OUTSIDE
|
||||
RequireAuth — RequireAuth redirects here — and outside AppShell, so
|
||||
@@ -62,5 +64,6 @@ export default function App() {
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TierProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,78 @@
|
||||
import { useEffect } from "react";
|
||||
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 { useTier } from "@/lib/tier";
|
||||
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() {
|
||||
const { isAdmin } = useTier();
|
||||
const { t, i18n } = useTranslation("navigation");
|
||||
// Sections are derived purely from is_admin: User-Side always, Admin/SysAdmin
|
||||
// only for admins. isAdmin is fail-closed (false while /me loads or on failure),
|
||||
// so admin sections appear only once identity is confirmed.
|
||||
const sections = visibleSections(isAdmin);
|
||||
|
||||
// Sync document metadata with the active language.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language;
|
||||
document.title = t("common:page_title");
|
||||
}, [i18n.language, t]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-card/40 p-4 md:flex">
|
||||
<div className="mb-6 flex items-center gap-2 px-2">
|
||||
<Cat className="h-6 w-6 text-primary" />
|
||||
<span className="text-lg font-semibold tracking-tight">Felis</span>
|
||||
<span className="text-lg font-semibold tracking-tight">{t("common:brand_name")}</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="flex flex-col gap-1">
|
||||
{section.title && (
|
||||
{section.titleKey && (
|
||||
<div className="px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
{section.title}
|
||||
{t(section.titleKey)}
|
||||
</div>
|
||||
)}
|
||||
{section.items.map((item) => (
|
||||
@@ -42,22 +90,36 @@ export function AppShell() {
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
{t(item.key)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto px-3 pt-4 text-xs text-muted-foreground">
|
||||
K8s-native Minecraft orchestration
|
||||
<div className="mt-auto px-3 pt-4 border-t border-border/60">
|
||||
<div className="flex items-start gap-2">
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-14 items-center gap-2 border-b border-border px-4 md:hidden">
|
||||
<Cat className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold">Felis</span>
|
||||
<header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<main className="flex-1 p-6">
|
||||
<Outlet />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { Cat } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// AuthLayout is the chrome for the pre-app auth surfaces (login, forced change).
|
||||
// These live OUTSIDE AppShell — there is no nav, no tier context to honor yet —
|
||||
@@ -13,6 +14,12 @@ export function AuthLayout({
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { i18n } = useTranslation("common");
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language;
|
||||
document.title = title;
|
||||
}, [i18n.language, title]);
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-12">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,20 +33,24 @@ import type { AutostartPolicy, CreateServerRequest } from "@/lib/types";
|
||||
|
||||
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
||||
const STORAGE_OPTIONS = ["5Gi", "10Gi", "20Gi", "50Gi"];
|
||||
const POLICIES: { value: AutostartPolicy; label: string }[] = [
|
||||
{ value: "ownerOnly", label: "Owner only — wake from the panel" },
|
||||
{ value: "public", label: "Public — any player join wakes it" },
|
||||
{ value: "allowlist", label: "Allowlist — listed players wake it" },
|
||||
];
|
||||
|
||||
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
||||
|
||||
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||
return [
|
||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
||||
{ value: "public", label: t("create_server_policy_public") },
|
||||
{ value: "allowlist", label: t("create_server_policy_allowlist") },
|
||||
];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cfg: RuntimeConfig;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [open, setOpen] = useState(false);
|
||||
const images = useAsync(() => api.listImages(), []);
|
||||
|
||||
@@ -94,69 +99,70 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const policies = policyOptions(t);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus /> New server
|
||||
<Plus /> {t("create_server_btn")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a server</DialogTitle>
|
||||
<DialogTitle>{t("create_server_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick from whitelisted images and sizes — the platform provisions the
|
||||
rest. No raw cluster config is exposed here.
|
||||
{t("create_server_desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cs-name">Name</Label>
|
||||
<Label htmlFor="cs-name">{t("create_server_name")}</Label>
|
||||
<Input
|
||||
id="cs-name"
|
||||
placeholder="survival"
|
||||
placeholder={t("create_server_name_placeholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cs-sub">Subdomain</Label>
|
||||
<Label htmlFor="cs-sub">{t("create_server_subdomain")}</Label>
|
||||
<Input
|
||||
id="cs-sub"
|
||||
placeholder="survival"
|
||||
placeholder={t("create_server_subdomain_placeholder")}
|
||||
value={form.subdomain}
|
||||
onChange={(e) => set("subdomain", e.target.value.toLowerCase())}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.subdomain && !subdomainValid
|
||||
? "Lowercase letters, digits and hyphens only."
|
||||
: `Will be reachable at ${hostFor(form.subdomain || "name", cfg)}`}
|
||||
? t("create_server_subdomain_invalid")
|
||||
: t("create_server_subdomain_hint", { host: hostFor(form.subdomain || "name", cfg) })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cs-display">Display name (optional)</Label>
|
||||
<Label htmlFor="cs-display">{t("create_server_display_name")}</Label>
|
||||
<Input
|
||||
id="cs-display"
|
||||
placeholder="Survival World"
|
||||
placeholder={t("create_server_display_name_placeholder")}
|
||||
value={form.displayName ?? ""}
|
||||
onChange={(e) => set("displayName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Image</Label>
|
||||
<Label>{t("create_server_image")}</Label>
|
||||
<Select value={form.image} onValueChange={(v) => set("image", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
images.loading
|
||||
? "Loading images…"
|
||||
? t("create_server_image_loading")
|
||||
: enabledImages.length
|
||||
? "Choose an image"
|
||||
: "No whitelisted images"
|
||||
? t("create_server_image_choose")
|
||||
: t("create_server_image_none")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -172,7 +178,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Memory</Label>
|
||||
<Label>{t("create_server_memory")}</Label>
|
||||
<Select value={form.memory} onValueChange={(v) => set("memory", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -187,7 +193,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Storage</Label>
|
||||
<Label>{t("create_server_storage")}</Label>
|
||||
<Select value={form.storage} onValueChange={(v) => set("storage", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -204,7 +210,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Autostart policy</Label>
|
||||
<Label>{t("create_server_policy")}</Label>
|
||||
<Select
|
||||
value={form.autostartPolicy}
|
||||
onValueChange={(v) => set("autostartPolicy", v as AutostartPolicy)}
|
||||
@@ -213,7 +219,7 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{POLICIES.map((p) => (
|
||||
{policies.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
@@ -227,10 +233,10 @@ export function CreateServerDialog({ cfg, onCreated }: Props) {
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
|
||||
Cancel
|
||||
{t("create_server_cancel")}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!canSubmit}>
|
||||
{submitting ? "Creating…" : "Create"}
|
||||
{submitting ? t("create_server_creating") : t("create_server_submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLogStream } from "@/lib/useLogStream";
|
||||
@@ -19,11 +20,12 @@ const LEVEL_CLASS: Record<LogLevel, string> = {
|
||||
const PIN_THRESHOLD_PX = 24;
|
||||
|
||||
function StatusIndicator({ status }: { status: StreamStatus }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const map: Record<StreamStatus, { dot: string; label: string; text: string }> = {
|
||||
connecting: { dot: "bg-amber-400 animate-pulse", label: "Connecting…", text: "text-amber-300" },
|
||||
open: { dot: "bg-emerald-400", label: "Live", text: "text-emerald-300" },
|
||||
reconnecting: { dot: "bg-amber-400 animate-pulse", label: "Reconnecting…", text: "text-amber-300" },
|
||||
ended: { dot: "bg-zinc-500", label: "Stream ended", text: "text-zinc-400" },
|
||||
connecting: { dot: "bg-amber-400 animate-pulse", label: t("log_connecting"), text: "text-amber-300" },
|
||||
open: { dot: "bg-emerald-400", label: t("log_live"), text: "text-emerald-300" },
|
||||
reconnecting: { dot: "bg-amber-400 animate-pulse", label: t("log_reconnecting"), text: "text-amber-300" },
|
||||
ended: { dot: "bg-zinc-500", label: t("log_ended"), text: "text-zinc-400" },
|
||||
};
|
||||
const s = map[status];
|
||||
return (
|
||||
@@ -46,6 +48,7 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
|
||||
* scrolling back to the bottom re-pins. The buffer is bounded by the controller.
|
||||
*/
|
||||
export function LogConsole({ url }: { url: string }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const { lines, status, clear, reconnect } = useLogStream(url);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
@@ -81,7 +84,7 @@ export function LogConsole({ url }: { url: string }) {
|
||||
onClick={reconnect}
|
||||
className="border-zinc-700 bg-transparent text-zinc-200 hover:bg-zinc-800 hover:text-zinc-50"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" /> Reconnect
|
||||
<RotateCw className="h-3.5 w-3.5" /> {t("log_reconnect_btn")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -91,7 +94,7 @@ export function LogConsole({ url }: { url: string }) {
|
||||
disabled={lines.length === 0}
|
||||
className="text-zinc-300 hover:bg-zinc-800 hover:text-zinc-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Clear
|
||||
<Trash2 className="h-3.5 w-3.5" /> {t("log_clear_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,13 +109,13 @@ export function LogConsole({ url }: { url: string }) {
|
||||
{lines.length === 0 ? (
|
||||
<p className="select-none py-8 text-center text-zinc-600">
|
||||
{status === "ended"
|
||||
? "Stream ended — reconnect to resume following the log."
|
||||
: "Waiting for output…"}
|
||||
? t("log_ended_empty")
|
||||
: t("log_waiting")}
|
||||
</p>
|
||||
) : (
|
||||
lines.map((line) => (
|
||||
<div key={line.seq} className={cn("whitespace-pre-wrap break-all", LEVEL_CLASS[line.level])}>
|
||||
{line.text || " "}
|
||||
{line.text || "\u00A0"}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -126,7 +129,7 @@ export function LogConsole({ url }: { url: string }) {
|
||||
onClick={jumpToLatest}
|
||||
className="absolute bottom-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground shadow-lg transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" /> Jump to latest
|
||||
<ArrowDown className="h-3.5 w-3.5" /> {t("log_jump_latest")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Phase } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type BadgeVariant = "default" | "muted" | "destructive" | "outline";
|
||||
|
||||
@@ -17,6 +18,15 @@ export const PHASE_COLOR: Record<Phase, string> = {
|
||||
Unknown: "#94a3b8",
|
||||
};
|
||||
|
||||
export const PHASE_KEY: Record<Phase, string> = {
|
||||
Running: "servers:phase_running",
|
||||
Starting: "servers:phase_starting",
|
||||
Stopping: "servers:phase_stopping",
|
||||
Stopped: "servers:phase_stopped",
|
||||
Failed: "servers:phase_failed",
|
||||
Unknown: "servers:phase_unknown",
|
||||
};
|
||||
|
||||
const VARIANT: Record<Phase, BadgeVariant> = {
|
||||
Running: "default",
|
||||
Starting: "outline",
|
||||
@@ -43,6 +53,7 @@ export function phaseVariant(phase: Phase): BadgeVariant {
|
||||
}
|
||||
|
||||
export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Badge variant={phaseVariant(phase)} className="gap-1.5">
|
||||
<span
|
||||
@@ -52,7 +63,7 @@ export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||
)}
|
||||
style={{ backgroundColor: phaseColor(phase) }}
|
||||
/>
|
||||
{phase}
|
||||
{t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { Loading, NotAuthorized } from "@/components/States";
|
||||
|
||||
@@ -11,6 +12,7 @@ import { Loading, NotAuthorized } from "@/components/States";
|
||||
// settled, isAdmin is the fail-closed verdict (false on any /me failure).
|
||||
export function RequireAdmin() {
|
||||
const { isAdmin, loading } = useTier();
|
||||
if (loading) return <Loading label="Checking access…" />;
|
||||
const { t } = useTranslation("common");
|
||||
if (loading) return <Loading label={t("checking_access")} />;
|
||||
return isAdmin ? <Outlet /> : <NotAuthorized />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Outlet } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTier } from "@/lib/tier";
|
||||
|
||||
// RequireAuth is the layout gate in front of the whole authenticated app (AppShell
|
||||
@@ -16,12 +17,13 @@ import { useTier } from "@/lib/tier";
|
||||
// 401 bounces to /login. Every admin route remains independently server-guarded.
|
||||
export function RequireAuth() {
|
||||
const { loading, unauthenticated, mustChangePassword } = useTier();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Play, Square, Hand, Users, ExternalLink, Terminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
@@ -16,6 +17,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [busy, setBusy] = useState<null | "wake" | "stop" | "claim">(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const host = hostFor(server.subdomain, cfg);
|
||||
@@ -65,7 +67,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||
{running ? (
|
||||
<span>
|
||||
{server.players ?? 0}
|
||||
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} online
|
||||
{server.maxPlayers ? ` / ${server.maxPlayers}` : ""} {t("online")}
|
||||
</span>
|
||||
) : (
|
||||
<span>—</span>
|
||||
@@ -78,7 +80,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||
{server.owned && (
|
||||
<Link to={`/servers/${server.name}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Terminal /> Console
|
||||
<Terminal /> {t("console")}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -89,7 +91,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||
disabled={busy !== null}
|
||||
onClick={() => act("claim", () => api.claim(server.name))}
|
||||
>
|
||||
<Hand /> {busy === "claim" ? "Claiming…" : "Claim"}
|
||||
<Hand /> {busy === "claim" ? t("claiming") : t("claim")}
|
||||
</Button>
|
||||
)}
|
||||
{server.owned &&
|
||||
@@ -100,7 +102,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||
disabled={busy !== null}
|
||||
onClick={() => act("stop", () => api.stop(server.name))}
|
||||
>
|
||||
<Square /> {busy === "stop" ? "Stopping…" : "Stop"}
|
||||
<Square /> {busy === "stop" ? t("stopping") : t("stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -108,7 +110,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
|
||||
disabled={busy !== null}
|
||||
onClick={() => act("wake", () => api.wake(server.name))}
|
||||
>
|
||||
<Play /> {busy === "wake" ? "Waking…" : "Wake"}
|
||||
<Play /> {busy === "wake" ? t("waking") : t("wake")}
|
||||
</Button>
|
||||
))}
|
||||
</CardFooter>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
|
||||
export function Loading({ label = "Loading…" }: { label?: string }) {
|
||||
export function Loading({ label }: { label?: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{label}
|
||||
{label ?? t("loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
@@ -21,7 +24,7 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
|
||||
onClick={onRetry}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Try again
|
||||
{t("try_again")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -50,19 +53,18 @@ export function EmptyState({
|
||||
}
|
||||
|
||||
export function NotAuthorized() {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<ShieldX className="h-8 w-8 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">Not authorized</p>
|
||||
<p className="font-medium">{t("not_authorized_title")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
This area is for platform administrators. If you reached it by typing the
|
||||
URL, your account does not have admin access — and the API would refuse
|
||||
the request anyway.
|
||||
{t("not_authorized_body")}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/" className="text-sm font-medium text-primary hover:underline">
|
||||
Back to your dashboard
|
||||
{t("back_to_dashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -76,14 +78,15 @@ export function NotAuthorized() {
|
||||
* data.
|
||||
*/
|
||||
export function PendingBackend({ endpoint, note }: { endpoint: string; note: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border py-16 text-center">
|
||||
<Construction className="h-7 w-7 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Not wired yet</p>
|
||||
<p className="font-medium">{t("not_wired_yet")}</p>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">{note}</p>
|
||||
<p className="mt-2 font-mono text-xs text-muted-foreground/80">
|
||||
pending backend: {endpoint}
|
||||
{t("pending_backend_prefix")}{endpoint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Dialog = DialogPrimitive.Root;
|
||||
@@ -25,7 +26,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
export const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -39,11 +42,12 @@ export const DialogContent = React.forwardRef<
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("close_sr")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
));
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
export function DialogHeader({
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import i18next from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import enCommon from "./resources/en-US/common.json";
|
||||
import enAuth from "./resources/en-US/auth.json";
|
||||
import enDashboard from "./resources/en-US/dashboard.json";
|
||||
import enServers from "./resources/en-US/servers.json";
|
||||
import enAccount from "./resources/en-US/account.json";
|
||||
import enAdmin from "./resources/en-US/admin.json";
|
||||
import enOps from "./resources/en-US/ops.json";
|
||||
import enErrors from "./resources/en-US/errors.json";
|
||||
import enNavigation from "./resources/en-US/navigation.json";
|
||||
import zhCommon from "./resources/zh-CN/common.json";
|
||||
import zhAuth from "./resources/zh-CN/auth.json";
|
||||
import zhDashboard from "./resources/zh-CN/dashboard.json";
|
||||
import zhServers from "./resources/zh-CN/servers.json";
|
||||
import zhAccount from "./resources/zh-CN/account.json";
|
||||
import zhAdmin from "./resources/zh-CN/admin.json";
|
||||
import zhOps from "./resources/zh-CN/ops.json";
|
||||
import zhErrors from "./resources/zh-CN/errors.json";
|
||||
import zhNavigation from "./resources/zh-CN/navigation.json";
|
||||
|
||||
i18next
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
"en-US": {
|
||||
common: enCommon,
|
||||
auth: enAuth,
|
||||
dashboard: enDashboard,
|
||||
servers: enServers,
|
||||
account: enAccount,
|
||||
admin: enAdmin,
|
||||
ops: enOps,
|
||||
errors: enErrors,
|
||||
navigation: enNavigation,
|
||||
},
|
||||
"zh-CN": {
|
||||
common: zhCommon,
|
||||
auth: zhAuth,
|
||||
dashboard: zhDashboard,
|
||||
servers: zhServers,
|
||||
account: zhAccount,
|
||||
admin: zhAdmin,
|
||||
ops: zhOps,
|
||||
errors: zhErrors,
|
||||
navigation: zhNavigation,
|
||||
},
|
||||
},
|
||||
fallbackLng: "en-US",
|
||||
defaultNS: "common",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
caches: ["localStorage"],
|
||||
lookupLocalStorage: "felis-lang",
|
||||
},
|
||||
});
|
||||
|
||||
export default i18next;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "Account",
|
||||
"subtitle": "Identity and Minecraft linking.",
|
||||
"session": "Session",
|
||||
"session_desc": "The panel itself holds no credentials — every request rides your existing session cookie, whether issued by local password sign-in or the platform's identity proxy (Zero-Trust / Access) (spec §7/§8).",
|
||||
"sign_out": "Sign out",
|
||||
"signing_out": "Signing out…",
|
||||
"minecraft_link": "Minecraft link",
|
||||
"checking_link": "Checking link status…",
|
||||
"linked_title": "Your Minecraft account is linked.",
|
||||
"linked_desc": "Linked sessions can claim and own servers — ownership operations (claim, wake, stop) are unblocked.",
|
||||
"uuid_label": "UUID",
|
||||
"step1_title": "Get a code in-game",
|
||||
"step1_desc_prefix": "Join any server and run ",
|
||||
"step1_desc_suffix": " in chat. The server already knows your verified identity, so it hands you a one-time code (valid about 10 minutes).",
|
||||
"step2_title": "Enter it here",
|
||||
"link_code": "Link code",
|
||||
"link_code_placeholder": "ABCD2345",
|
||||
"verify_btn": "Link",
|
||||
"verifying": "Verifying…"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "Admin",
|
||||
"subtitle": "Server & content administration",
|
||||
"signed_in_as": " · signed in as {{email}}",
|
||||
"servers": "Servers",
|
||||
"servers_desc": "Create platform servers from the structured §15 form and manage the ones you operate.",
|
||||
"images": "Images",
|
||||
"images_desc": "The platform image whitelist — the value space the create form draws from.",
|
||||
"server_admin_subtitle": "Create platform servers from the structured form and manage the ones you operate.",
|
||||
"images_title": "Images",
|
||||
"images_subtitle": "The platform image whitelist. Only enabled images can back a new server. Editing the whitelist from the Web is auditable (spec §14) — the mutation routes are a pending admin-tier slice, so this view is read-only for now.",
|
||||
"no_images_title": "No images whitelisted",
|
||||
"no_images_hint": "The whitelist is empty — a platform admin must add one (CLI for now).",
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled",
|
||||
"footer_kubectl": "kubectl / CRD operations",
|
||||
"footer_pre": "Cluster scaling, RBAC, Secrets and control-plane lifecycle are ",
|
||||
"footer_post": " and are intentionally not available from the panel — the four-power separation (build / runtime / operator / app) is preserved. This is a window onto the platform, not a lever for operator power."
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"login_title": "Sign in to Felis",
|
||||
"login_subtitle": "Operator console",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"sign_in": "Sign in",
|
||||
"signing_in": "Signing in…",
|
||||
"change_password_title": "Set a new password",
|
||||
"change_password_subtitle_forced": "Your account was issued a one-time password. Choose a new one to continue.",
|
||||
"change_password_subtitle_voluntary": "Update your console password.",
|
||||
"current_password": "Current password",
|
||||
"new_password": "New password",
|
||||
"confirm_new_password": "Confirm new password",
|
||||
"password_mismatch": "Passwords don't match.",
|
||||
"password_min_length": "At least {{min}} characters.",
|
||||
"change_password_btn": "Change password",
|
||||
"saving": "Saving…"
|
||||
}
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"title": "SysAdmin",
|
||||
"subtitle": "Platform observability cockpit — a window, not a lever.",
|
||||
"cluster_wide_fleet": "Cluster-wide fleet",
|
||||
"open_fleet_table": "Open fleet table →",
|
||||
"footer_pre": "Control-plane scaling, RBAC, Secrets and cluster lifecycle are ",
|
||||
"footer_kubectl": "kubectl / CRD operations",
|
||||
"footer_post": " and are not reachable from here. SysAdmin-Side is observability only — the four-power separation (build / runtime / operator / app) is preserved.",
|
||||
"fleet_title": "Fleet",
|
||||
"fleet_subtitle": "Every server on the platform — phase, owner, capacity.",
|
||||
"fleet_table_pending_note": "The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships.",
|
||||
"ops_overview_pending_note": "A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers."
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"my_servers_title": "My servers",
|
||||
"my_servers_subtitle": "Wake, stop, or claim the servers you can manage.",
|
||||
"no_servers_linked": "No servers linked to you",
|
||||
"no_servers_hint": "Claim an unowned server you have access to, or ask an admin to provision one.",
|
||||
"my_servers_footer": "Servers and game types are provisioned and managed by the platform. You claim a node to operate it; raw cluster config is never exposed here (spec §15).",
|
||||
"phase_running": "Running",
|
||||
"phase_starting": "Starting",
|
||||
"phase_stopping": "Stopping",
|
||||
"phase_stopped": "Stopped",
|
||||
"phase_failed": "Failed",
|
||||
"phase_unknown": "Unknown",
|
||||
"online": "online",
|
||||
"console": "Console",
|
||||
"claim": "Claim",
|
||||
"claiming": "Claiming…",
|
||||
"stop": "Stop",
|
||||
"stopping": "Stopping…",
|
||||
"wake": "Wake",
|
||||
"waking": "Waking…",
|
||||
"server_asleep_title": "Server is asleep",
|
||||
"server_asleep_body": "Wake it to boot a pod — the live console attaches automatically the moment it starts up.",
|
||||
"server_shutting_down_title": "Server is shutting down",
|
||||
"server_shutting_down_body": "The pod is terminating, so the live console has detached. It will be asleep in a moment.",
|
||||
"server_failed_title": "Server failed to start",
|
||||
"server_failed_body": "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.",
|
||||
"console_offline_title": "Console is offline",
|
||||
"console_offline_body": "The live console attaches automatically as soon as the server is running.",
|
||||
"my_servers_breadcrumb": "My servers",
|
||||
"console_card_title": "Console",
|
||||
"console_card_desc": "Live, read-only output streamed from the running pod over SSE (spec §8). Commands run through a separate path — the panel never holds an RCON password.",
|
||||
"log_connecting": "Connecting…",
|
||||
"log_live": "Live",
|
||||
"log_reconnecting": "Reconnecting…",
|
||||
"log_ended": "Stream ended",
|
||||
"log_reconnect_btn": "Reconnect",
|
||||
"log_clear_btn": "Clear",
|
||||
"log_ended_empty": "Stream ended — reconnect to resume following the log.",
|
||||
"log_waiting": "Waiting for output…",
|
||||
"log_jump_latest": "Jump to latest",
|
||||
"create_server_btn": "New server",
|
||||
"create_server_title": "Create a server",
|
||||
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
|
||||
"create_server_name": "Name",
|
||||
"create_server_name_placeholder": "survival",
|
||||
"create_server_subdomain": "Subdomain",
|
||||
"create_server_subdomain_placeholder": "survival",
|
||||
"create_server_subdomain_invalid": "Lowercase letters, digits and hyphens only.",
|
||||
"create_server_subdomain_hint": "Will be reachable at {{host}}",
|
||||
"create_server_display_name": "Display name (optional)",
|
||||
"create_server_display_name_placeholder": "Survival World",
|
||||
"create_server_image": "Image",
|
||||
"create_server_image_loading": "Loading images…",
|
||||
"create_server_image_choose": "Choose an image",
|
||||
"create_server_image_none": "No whitelisted images",
|
||||
"create_server_memory": "Memory",
|
||||
"create_server_storage": "Storage",
|
||||
"create_server_policy": "Autostart policy",
|
||||
"create_server_policy_owner": "Owner only — wake from the panel",
|
||||
"create_server_policy_public": "Public — any player join wakes it",
|
||||
"create_server_policy_allowlist": "Allowlist — listed players wake it",
|
||||
"create_server_cancel": "Cancel",
|
||||
"create_server_submit": "Create",
|
||||
"create_server_creating": "Creating…",
|
||||
"no_servers_managed": "No servers under your management yet",
|
||||
"no_servers_managed_hint": "Use \"New server\" to provision one from the vetted spec.",
|
||||
"server_admin_footer": "Server creation goes through the structured form only — the platform maps your choices onto a vetted Kubernetes spec. Raw cluster config (host networking, host paths, arbitrary images, privileged pods) is never expressible here (spec §15)."
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"title": "账户",
|
||||
"subtitle": "身份验证与 Minecraft 关联。",
|
||||
"session": "会话",
|
||||
"session_desc": "面板不持有凭据——每次请求均通过当前会话 Cookie 完成认证,无论该 Cookie 由本地密码登录还是平台身份代理(Zero-Trust / Access)签发(spec §7/§8)。",
|
||||
"sign_out": "退出登录",
|
||||
"signing_out": "退出中…",
|
||||
"minecraft_link": "Minecraft 关联",
|
||||
"checking_link": "正在检查关联状态…",
|
||||
"linked_title": "Minecraft 账户已关联。",
|
||||
"linked_desc": "关联后可认领及管理服务器——所有权相关操作(认领、唤醒、停止)已解锁。",
|
||||
"uuid_label": "UUID",
|
||||
"step1_title": "在游戏中获取验证码",
|
||||
"step1_desc_prefix": "加入任意服务器,在聊天框输入 ",
|
||||
"step1_desc_suffix": " 。服务器已确认你的身份,会提供一个一次性验证码(约 10 分钟内有效)。",
|
||||
"step2_title": "在此输入",
|
||||
"link_code": "关联码",
|
||||
"link_code_placeholder": "ABCD2345",
|
||||
"verify_btn": "关联",
|
||||
"verifying": "验证中…"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "管理",
|
||||
"subtitle": "服务器与内容管理",
|
||||
"signed_in_as": " · 当前登录:{{email}}",
|
||||
"servers": "服务器",
|
||||
"servers_desc": "通过结构化表单(§15)创建并管理平台服务器。",
|
||||
"images": "镜像",
|
||||
"images_desc": "平台镜像白名单——创建服务器时的可选镜像范围。",
|
||||
"server_admin_subtitle": "通过结构化表单创建并管理平台服务器。",
|
||||
"images_title": "镜像",
|
||||
"images_subtitle": "平台镜像白名单。仅已启用的镜像可用于创建服务器。Web 端编辑白名单可审计(spec §14)——变更接口待后续上线,当前仅可查看。",
|
||||
"no_images_title": "无白名单镜像",
|
||||
"no_images_hint": "白名单为空——平台管理员需通过 CLI 添加镜像。",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"footer_kubectl": "kubectl / CRD",
|
||||
"footer_pre": "集群扩缩、RBAC、Secrets 及控制面生命周期等属于 ",
|
||||
"footer_post": " 范畴,刻意不在面板中暴露——遵循四层职责分离原则(构建 / 运行时 / 运维 / 应用)。此处为平台观察视角,并非运维管理入口。"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"login_title": "登录 Felis",
|
||||
"login_subtitle": "运维控制台",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"sign_in": "登录",
|
||||
"signing_in": "登录中…",
|
||||
"change_password_title": "设置新密码",
|
||||
"change_password_subtitle_forced": "当前为一次性密码,请设置新密码后继续。",
|
||||
"change_password_subtitle_voluntary": "修改控制台登录密码。",
|
||||
"current_password": "当前密码",
|
||||
"new_password": "新密码",
|
||||
"confirm_new_password": "确认新密码",
|
||||
"password_mismatch": "两次输入的密码不一致。",
|
||||
"password_min_length": "密码至少 {{min}} 个字符。",
|
||||
"change_password_btn": "修改密码",
|
||||
"saving": "保存中…"
|
||||
}
|
||||
@@ -0,0 +1,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": "全平台服务器"
|
||||
}
|
||||
@@ -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)。"
|
||||
}
|
||||
@@ -55,4 +55,10 @@
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-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
@@ -9,6 +9,7 @@ import type {
|
||||
WhitelistImage,
|
||||
} from "./types";
|
||||
import { loadConfig } from "./config";
|
||||
import i18next from "i18next";
|
||||
|
||||
// Typed client for the felis-api external face (spec §7). Credentials are sent so
|
||||
// the upstream Zero-Trust / Access cookie rides along; the panel never holds a
|
||||
@@ -126,37 +127,38 @@ export function consoleStreamURL(apiBase: string, name: string): string {
|
||||
/** humanizeError turns the stable error code into a user-facing line. */
|
||||
export function humanizeError(e: unknown): string {
|
||||
const err = e as Partial<ApiError>;
|
||||
const t = i18next.getFixedT(null, "errors");
|
||||
switch (err.code) {
|
||||
// Local-password auth (spec §B1).
|
||||
case "local_auth_disabled":
|
||||
return "Password sign-in is turned off here — reach this console through your organization's secure access.";
|
||||
return t("local_auth_disabled");
|
||||
case "invalid_credentials":
|
||||
return "Incorrect username or password.";
|
||||
return t("invalid_credentials");
|
||||
case "weak_password":
|
||||
return "Pick a password between 8 and 72 characters.";
|
||||
return t("weak_password");
|
||||
case "password_unchanged":
|
||||
return "Your new password must differ from the current one.";
|
||||
return t("password_unchanged");
|
||||
case "not_linked":
|
||||
return "Link your Minecraft account before claiming (Account → Link).";
|
||||
return t("not_linked");
|
||||
case "invalid_code":
|
||||
return "That link code is invalid or expired — run /link again in-game for a fresh code.";
|
||||
return t("invalid_code");
|
||||
case "already_linked":
|
||||
return "That Minecraft account is already linked to another user.";
|
||||
return t("already_linked");
|
||||
case "quota_exceeded":
|
||||
return "You have reached your server quota.";
|
||||
return t("quota_exceeded");
|
||||
case "already_claimed":
|
||||
return "Someone else just claimed this server.";
|
||||
return t("already_claimed");
|
||||
case "image_not_whitelisted":
|
||||
return "That image is not on the whitelist.";
|
||||
return t("image_not_whitelisted");
|
||||
case "subdomain_taken":
|
||||
return "That subdomain is already in use.";
|
||||
return t("subdomain_taken");
|
||||
case "already_exists":
|
||||
return "A server with that name already exists.";
|
||||
return t("already_exists");
|
||||
case "cooldown":
|
||||
return "Wake is cooling down — try again shortly.";
|
||||
return t("cooldown");
|
||||
default:
|
||||
if (err.status === 401) return "Your session expired — please sign in again.";
|
||||
if (err.status === 403) return "You are not allowed to do that.";
|
||||
return err.message ?? "Something went wrong.";
|
||||
if (err.status === 401) return t("session_expired");
|
||||
if (err.status === 403) return t("forbidden");
|
||||
return err.message ?? t("generic");
|
||||
}
|
||||
}
|
||||
+14
-14
@@ -23,7 +23,7 @@ import {
|
||||
|
||||
export interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
/** react-router NavLink `end` — exact-match active styling for index routes. */
|
||||
end?: boolean;
|
||||
@@ -31,8 +31,8 @@ export interface NavItem {
|
||||
|
||||
export interface NavSection {
|
||||
id: "user" | "admin" | "ops";
|
||||
/** Section heading shown in the sidebar; null renders no heading (User-Side). */
|
||||
title: string | null;
|
||||
/** Section heading key; null renders no heading (User-Side). */
|
||||
titleKey: string | null;
|
||||
/** When true the section is shown only to admins (is_admin === true). */
|
||||
adminOnly: boolean;
|
||||
items: NavItem[];
|
||||
@@ -41,31 +41,31 @@ export interface NavSection {
|
||||
export const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
id: "user",
|
||||
title: null,
|
||||
titleKey: null,
|
||||
adminOnly: false,
|
||||
items: [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/servers", label: "My servers", icon: Server },
|
||||
{ to: "/account", label: "Account", icon: UserRound },
|
||||
{ to: "/", key: "dashboard", icon: LayoutDashboard, end: true },
|
||||
{ to: "/servers", key: "my_servers", icon: Server },
|
||||
{ to: "/account", key: "account", icon: UserRound },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
title: "Admin",
|
||||
titleKey: "admin_section",
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: "/admin", label: "Overview", icon: ShieldCheck, end: true },
|
||||
{ to: "/admin/servers", label: "Servers", icon: ServerCog },
|
||||
{ to: "/admin/images", label: "Images", icon: Boxes },
|
||||
{ to: "/admin", key: "admin_overview", icon: ShieldCheck, end: true },
|
||||
{ to: "/admin/servers", key: "admin_servers", icon: ServerCog },
|
||||
{ to: "/admin/images", key: "admin_images", icon: Boxes },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ops",
|
||||
title: "SysAdmin",
|
||||
titleKey: "sysadmin_section",
|
||||
adminOnly: true,
|
||||
items: [
|
||||
{ to: "/ops", label: "Overview", icon: Gauge, end: true },
|
||||
{ to: "/ops/fleet", label: "Fleet", icon: Network },
|
||||
{ to: "/ops", key: "sysadmin_overview", icon: Gauge, end: true },
|
||||
{ to: "/ops/fleet", key: "sysadmin_fleet", icon: Network },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./i18n";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
|
||||
+22
-26
@@ -1,5 +1,6 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { CheckCircle2, Link2, LogOut, ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,6 +19,7 @@ import { useTier } from "@/lib/tier";
|
||||
export function Account() {
|
||||
const status = useAsync(() => api.linkStatus(), []);
|
||||
const { refresh } = useTier();
|
||||
const { t } = useTranslation("account");
|
||||
|
||||
// Sign-out ends a local-password session: clear it server-side, then refresh /me.
|
||||
// For a local session that read now 401s → the tier model flips to
|
||||
@@ -67,24 +69,20 @@ export function Account() {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Account</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Identity and Minecraft linking.
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" /> Session
|
||||
<ShieldCheck className="h-4 w-4 text-primary" /> {t("session")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
The panel itself holds no credentials — every request rides your existing
|
||||
session cookie, whether issued by local password sign-in or the platform's
|
||||
identity proxy (Zero-Trust / Access) (spec §7/§8).
|
||||
</p>
|
||||
<p>{t("session_desc")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -92,7 +90,7 @@ export function Account() {
|
||||
disabled={signingOut}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{signingOut ? "Signing out…" : "Sign out"}
|
||||
{signingOut ? t("signing_out") : t("sign_out")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -100,14 +98,14 @@ export function Account() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Link2 className="h-4 w-4 text-primary" /> Minecraft link
|
||||
<Link2 className="h-4 w-4 text-primary" /> {t("minecraft_link")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
{linked ? (
|
||||
<LinkedState uuid={verifiedUUID} />
|
||||
) : status.loading && !status.data ? (
|
||||
<Loading label="Checking link status…" />
|
||||
<Loading label={t("checking_link")} />
|
||||
) : status.error ? (
|
||||
<ErrorState error={status.error} onRetry={status.reload} />
|
||||
) : (
|
||||
@@ -129,19 +127,17 @@ export function Account() {
|
||||
* just verified it (start does not return it), so a pre-existing link renders
|
||||
* the confirmation without a UUID rather than inventing one. */
|
||||
function LinkedState({ uuid }: { uuid: string | null }) {
|
||||
const { t } = useTranslation("account");
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
Your Minecraft account is linked.
|
||||
{t("linked_title")}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Linked sessions can claim and own servers — ownership operations (claim,
|
||||
wake, stop) are unblocked.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("linked_desc")}</p>
|
||||
{uuid && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs uppercase tracking-wide">UUID</span>
|
||||
<span className="text-xs uppercase tracking-wide">{t("uuid_label")}</span>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||
{uuid}
|
||||
</code>
|
||||
@@ -167,34 +163,34 @@ function LinkForm({
|
||||
error: string | null;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("account");
|
||||
return (
|
||||
<ol className="space-y-4">
|
||||
<li className="flex gap-3">
|
||||
<StepBadge n={1} />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">Get a code in-game</p>
|
||||
<p className="font-medium text-foreground">{t("step1_title")}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Join any server and run{" "}
|
||||
{t("step1_desc_prefix")}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
|
||||
/link
|
||||
</code>{" "}
|
||||
in chat. The server already knows your verified identity, so it hands
|
||||
you a one-time code (valid about 10 minutes).
|
||||
</code>
|
||||
{t("step1_desc_suffix")}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<StepBadge n={2} />
|
||||
<div className="w-full space-y-2">
|
||||
<p className="font-medium text-foreground">Enter it here</p>
|
||||
<p className="font-medium text-foreground">{t("step2_title")}</p>
|
||||
<form onSubmit={onSubmit} className="space-y-2">
|
||||
<Label htmlFor="link-code">Link code</Label>
|
||||
<Label htmlFor="link-code">{t("link_code")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="link-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="ABCD2345"
|
||||
placeholder={t("link_code_placeholder")}
|
||||
autoComplete="off"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
@@ -206,7 +202,7 @@ function LinkForm({
|
||||
type="submit"
|
||||
disabled={submitting || code.trim().length === 0}
|
||||
>
|
||||
{submitting ? "Verifying…" : "Link"}
|
||||
{submitting ? t("verifying") : t("verify_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthLayout } from "@/components/AuthLayout";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -25,6 +26,7 @@ const MIN_PASSWORD = 8;
|
||||
export function ChangePassword() {
|
||||
const { loading, unauthenticated, mustChangePassword, refresh } = useTier();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
@@ -34,10 +36,10 @@ export function ChangePassword() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout title="Felis">
|
||||
<AuthLayout title={t("common:brand_name")}>
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
{t("common:loading")}
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
@@ -69,18 +71,18 @@ export function ChangePassword() {
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Set a new password"
|
||||
title={t("change_password_title")}
|
||||
subtitle={
|
||||
mustChangePassword
|
||||
? "Your account was issued a one-time password. Choose a new one to continue."
|
||||
: "Update your console password."
|
||||
? t("change_password_subtitle_forced")
|
||||
: t("change_password_subtitle_voluntary")
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current">Current password</Label>
|
||||
<Label htmlFor="current">{t("current_password")}</Label>
|
||||
<Input
|
||||
id="current"
|
||||
type="password"
|
||||
@@ -92,7 +94,7 @@ export function ChangePassword() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">New password</Label>
|
||||
<Label htmlFor="new-password">{t("new_password")}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -103,12 +105,12 @@ export function ChangePassword() {
|
||||
/>
|
||||
{tooShort && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
At least {MIN_PASSWORD} characters.
|
||||
{t("password_min_length", { min: MIN_PASSWORD })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">Confirm new password</Label>
|
||||
<Label htmlFor="confirm-password">{t("confirm_new_password")}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
@@ -118,12 +120,12 @@ export function ChangePassword() {
|
||||
aria-invalid={mismatch ? true : undefined}
|
||||
/>
|
||||
{mismatch && (
|
||||
<p className="text-xs text-destructive">Passwords don't match.</p>
|
||||
<p className="text-xs text-destructive">{t("password_mismatch")}</p>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={!canSubmit}>
|
||||
{submitting ? "Saving…" : "Change password"}
|
||||
{submitting ? t("saving") : t("change_password_btn")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Activity, Server, Play, Hand } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||
@@ -41,6 +42,7 @@ function Stat({
|
||||
export function Dashboard() {
|
||||
const cfg = useConfig();
|
||||
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const servers = data ?? [];
|
||||
@@ -55,8 +57,8 @@ export function Dashboard() {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Your fleet at a glance.</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
@@ -79,14 +81,15 @@ function FleetView({
|
||||
counts: { total: number; running: number; players: number };
|
||||
hasConfig: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No servers yet"
|
||||
hint="Create your first server or claim an unowned one."
|
||||
title={t("no_servers_title")}
|
||||
hint={t("no_servers_hint")}
|
||||
>
|
||||
<Link to="/servers">
|
||||
<Button>Go to My servers</Button>
|
||||
<Button>{t("go_to_my_servers")}</Button>
|
||||
</Link>
|
||||
</EmptyState>
|
||||
);
|
||||
@@ -95,29 +98,29 @@ function FleetView({
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Stat icon={Server} label="Servers" value={counts.total} />
|
||||
<Stat icon={Play} label="Running" value={counts.running} />
|
||||
<Stat icon={Activity} label="Players online" value={counts.players} />
|
||||
<Stat icon={Server} label={t("stat_servers")} value={counts.total} />
|
||||
<Stat icon={Play} label={t("stat_running")} value={counts.running} />
|
||||
<Stat icon={Activity} label={t("stat_players_online")} value={counts.players} />
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">Fleet</CardTitle>
|
||||
<CardTitle className="text-base">{t("fleet")}</CardTitle>
|
||||
<Link to="/servers">
|
||||
<Button variant="ghost" size="sm">
|
||||
Manage <Hand className="h-3.5 w-3.5" />
|
||||
{t("manage")} <Hand className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{hasConfig ? (
|
||||
<div className="h-[340px] w-full bg-gradient-to-b from-transparent to-primary/5">
|
||||
<Suspense fallback={<Loading label="Loading scene…" />}>
|
||||
<Suspense fallback={<Loading label={t("loading_scene")} />}>
|
||||
<VoxelFleet servers={servers} />
|
||||
</Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<Loading label="Preparing scene…" />
|
||||
<Loading label={t("preparing_scene")} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthLayout } from "@/components/AuthLayout";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +21,7 @@ import { api, humanizeError } from "@/lib/api";
|
||||
export function Login() {
|
||||
const { loading, identity, mustChangePassword, refresh } = useTier();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -30,10 +32,10 @@ export function Login() {
|
||||
// would briefly see a login form before being redirected away.
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout title="Felis">
|
||||
<AuthLayout title={t("common:brand_name")}>
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
{t("common:loading")}
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
@@ -59,12 +61,12 @@ export function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout title="Sign in to Felis" subtitle="Operator console">
|
||||
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
|
||||
<Card>
|
||||
<CardContent className="pt-5">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t("username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
@@ -78,7 +80,7 @@ export function Login() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -94,7 +96,7 @@ export function Login() {
|
||||
className="w-full"
|
||||
disabled={submitting || !username.trim() || !password}
|
||||
>
|
||||
{submitting ? "Signing in…" : "Sign in"}
|
||||
{submitting ? t("signing_in") : t("sign_in")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ServerCard } from "@/components/ServerCard";
|
||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||
@@ -10,14 +11,15 @@ import { useAsync, useConfig } from "@/lib/hooks";
|
||||
export function MyServers() {
|
||||
const cfg = useConfig();
|
||||
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
|
||||
const { t } = useTranslation("servers");
|
||||
const servers = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">My servers</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("my_servers_title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Wake, stop, or claim the servers you can manage.
|
||||
{t("my_servers_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -26,11 +28,11 @@ export function MyServers() {
|
||||
) : error ? (
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
) : !cfg ? (
|
||||
<Loading label="Loading config…" />
|
||||
<Loading label={t("common:loading_config")} />
|
||||
) : servers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No servers linked to you"
|
||||
hint="Claim an unowned server you have access to, or ask an admin to provision one."
|
||||
title={t("no_servers_linked")}
|
||||
hint={t("no_servers_hint")}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -42,9 +44,7 @@ export function MyServers() {
|
||||
|
||||
<Card className="border-dashed bg-transparent">
|
||||
<CardContent className="p-4 text-xs text-muted-foreground">
|
||||
Servers and game types are provisioned and managed by the platform. You
|
||||
claim a node to operate it; raw cluster config is never exposed here
|
||||
(spec §15).
|
||||
{t("my_servers_footer")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, Terminal, Moon, ShieldAlert, HelpCircle, type LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
@@ -15,37 +16,38 @@ import type { Phase } from "@/lib/types";
|
||||
// Stopped/Failed/Stopping each get their own honest line rather than an empty
|
||||
// console. `default` covers Unknown plus any future phase the backend may emit
|
||||
// that the panel hasn't modelled yet — the screen stays informative regardless.
|
||||
function notStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } {
|
||||
function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string } {
|
||||
const { t } = useTranslation("servers");
|
||||
switch (phase) {
|
||||
case "Stopped":
|
||||
return {
|
||||
icon: Moon,
|
||||
title: "Server is asleep",
|
||||
body: "Wake it to boot a pod — the live console attaches automatically the moment it starts up.",
|
||||
title: t("server_asleep_title"),
|
||||
body: t("server_asleep_body"),
|
||||
};
|
||||
case "Stopping":
|
||||
return {
|
||||
icon: Moon,
|
||||
title: "Server is shutting down",
|
||||
body: "The pod is terminating, so the live console has detached. It will be asleep in a moment.",
|
||||
title: t("server_shutting_down_title"),
|
||||
body: t("server_shutting_down_body"),
|
||||
};
|
||||
case "Failed":
|
||||
return {
|
||||
icon: ShieldAlert,
|
||||
title: "Server failed to start",
|
||||
body: "No pod is running, so there is nothing to stream. Wake it to retry; the console attaches once a pod comes back up.",
|
||||
title: t("server_failed_title"),
|
||||
body: t("server_failed_body"),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: HelpCircle,
|
||||
title: "Console is offline",
|
||||
body: "The live console attaches automatically as soon as the server is running.",
|
||||
title: t("console_offline_title"),
|
||||
body: t("console_offline_body"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function NotStreaming({ phase }: { phase: Phase }) {
|
||||
const { icon: Icon, title, body } = notStreamingCopy(phase);
|
||||
const { icon: Icon, title, body } = useNotStreamingCopy(phase);
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" />
|
||||
@@ -60,6 +62,7 @@ function NotStreaming({ phase }: { phase: Phase }) {
|
||||
export function ServerConsole() {
|
||||
const { name = "" } = useParams();
|
||||
const cfg = useConfig();
|
||||
const { t } = useTranslation("servers");
|
||||
const { data, error, loading, reload } = useAsync(
|
||||
() => api.status(name),
|
||||
[name],
|
||||
@@ -78,7 +81,7 @@ export function ServerConsole() {
|
||||
to="/servers"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> My servers
|
||||
<ArrowLeft className="h-4 w-4" /> {t("my_servers_breadcrumb")}
|
||||
</Link>
|
||||
|
||||
{loading && !data ? (
|
||||
@@ -106,11 +109,11 @@ export function ServerConsole() {
|
||||
size="sm"
|
||||
onClick={() => api.stop(name).then(reload)}
|
||||
>
|
||||
Stop
|
||||
{t("stop")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => api.wake(name).then(reload)}>
|
||||
Wake
|
||||
{t("wake")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,12 +122,10 @@ export function ServerConsole() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="h-4 w-4" /> Console
|
||||
<Terminal className="h-4 w-4" /> {t("console_card_title")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Live, read-only output streamed from the running pod over SSE
|
||||
(spec §8). Commands run through a separate path — the panel never
|
||||
holds an RCON password.
|
||||
{t("console_card_desc")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ServerCog, Boxes, ShieldCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useTier } from "@/lib/tier";
|
||||
|
||||
@@ -7,33 +8,34 @@ import { useTier } from "@/lib/tier";
|
||||
// content/server-admin surfaces. It deliberately states the four-power boundary
|
||||
// so an admin never expects cluster-lifecycle levers here — those stay kubectl/CRD.
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
to: "/admin/servers",
|
||||
icon: ServerCog,
|
||||
title: "Servers",
|
||||
body: "Create platform servers from the structured §15 form and manage the ones you operate.",
|
||||
},
|
||||
{
|
||||
to: "/admin/images",
|
||||
icon: Boxes,
|
||||
title: "Images",
|
||||
body: "The platform image whitelist — the value space the create form draws from.",
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminHome() {
|
||||
const { identity } = useTier();
|
||||
const { t } = useTranslation("admin");
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
to: "/admin/servers",
|
||||
icon: ServerCog,
|
||||
title: t("servers"),
|
||||
body: t("servers_desc"),
|
||||
},
|
||||
{
|
||||
to: "/admin/images",
|
||||
icon: Boxes,
|
||||
title: t("images"),
|
||||
body: t("images_desc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Admin</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Server & content administration
|
||||
{identity?.email ? ` · signed in as ${identity.email}` : ""}.
|
||||
{t("subtitle")}
|
||||
{identity?.email ? t("signed_in_as", { email: identity.email }) : ""}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,11 +56,9 @@ export function AdminHome() {
|
||||
|
||||
<Card className="border-dashed bg-transparent">
|
||||
<CardContent className="p-4 text-xs text-muted-foreground">
|
||||
Cluster scaling, RBAC, Secrets and control-plane lifecycle are{" "}
|
||||
<span className="font-medium">kubectl / CRD operations</span> and are
|
||||
intentionally not available from the panel — the four-power separation
|
||||
(build / runtime / operator / app) is preserved. This is a window onto the
|
||||
platform, not a lever for operator power.
|
||||
{t("footer_pre")}
|
||||
<span className="font-medium">{t("footer_kubectl")}</span>
|
||||
{t("footer_post")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Boxes, CheckCircle2, CircleSlash } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||
@@ -11,17 +12,16 @@ import { useAsync } from "@/lib/hooks";
|
||||
// added, so this page surfaces the whitelist honestly and labels the missing
|
||||
// mutation rather than shipping dead buttons.
|
||||
export function ImageAdmin() {
|
||||
const { t } = useTranslation("admin");
|
||||
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
|
||||
const images = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Images</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("images_title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The platform image whitelist. Only enabled images can back a new server.
|
||||
Editing the whitelist from the Web is auditable (spec §14) — the mutation
|
||||
routes are a pending admin-tier slice, so this view is read-only for now.
|
||||
{t("images_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -31,8 +31,8 @@ export function ImageAdmin() {
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
) : images.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No images whitelisted"
|
||||
hint="The whitelist is empty — a platform admin must add one (CLI for now)."
|
||||
title={t("no_images_title")}
|
||||
hint={t("no_images_hint")}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -50,11 +50,11 @@ export function ImageAdmin() {
|
||||
</div>
|
||||
{img.enabled ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-primary">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> enabled
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> {t("enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
<CircleSlash className="h-3.5 w-3.5" /> disabled
|
||||
<CircleSlash className="h-3.5 w-3.5" /> {t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ServerCard } from "@/components/ServerCard";
|
||||
import { CreateServerDialog } from "@/components/CreateServerDialog";
|
||||
@@ -17,16 +18,17 @@ import { useAsync, useConfig } from "@/lib/hooks";
|
||||
export function ServerAdmin() {
|
||||
const cfg = useConfig();
|
||||
const { data, error, loading, reload } = useAsync(() => api.myServers(), []);
|
||||
const { t } = useTranslation("admin");
|
||||
const { t: ts } = useTranslation("servers");
|
||||
const servers = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Servers</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("servers")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create platform servers from the structured form and manage the ones
|
||||
you operate.
|
||||
{t("server_admin_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{cfg && <CreateServerDialog cfg={cfg} onCreated={reload} />}
|
||||
@@ -37,11 +39,11 @@ export function ServerAdmin() {
|
||||
) : error ? (
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
) : !cfg ? (
|
||||
<Loading label="Loading config…" />
|
||||
<Loading label={ts("common:loading_config")} />
|
||||
) : servers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No servers under your management yet"
|
||||
hint="Use Create server to provision one from the vetted spec."
|
||||
title={ts("no_servers_managed")}
|
||||
hint={ts("no_servers_managed_hint")}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -53,10 +55,7 @@ export function ServerAdmin() {
|
||||
|
||||
<Card className="border-dashed bg-transparent">
|
||||
<CardContent className="p-4 text-xs text-muted-foreground">
|
||||
Server creation goes through the structured form only — the platform maps
|
||||
your choices onto a vetted Kubernetes spec. Raw cluster config (host
|
||||
networking, host paths, arbitrary images, privileged pods) is never
|
||||
expressible here (spec §15).
|
||||
{ts("server_admin_footer")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PendingBackend } from "@/components/States";
|
||||
|
||||
// FleetTable is the SysAdmin-Side cluster-wide server list: ALL servers with
|
||||
@@ -6,21 +7,22 @@ import { PendingBackend } from "@/components/States";
|
||||
// fleet-wide GET /admin/servers read, which is the next Oracle-verifiable backend
|
||||
// slice. Honest placeholder until that lands — no fabricated rows.
|
||||
export function FleetTable() {
|
||||
const { t } = useTranslation("ops");
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Network className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Fleet</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("fleet_title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every server on the platform — phase, owner, capacity.
|
||||
{t("fleet_subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PendingBackend
|
||||
endpoint="GET /api/v1/admin/servers"
|
||||
note="The cluster-wide fleet table reads every server's status (not just yours). That admin-tier read is the next backend slice; rows appear here once it ships."
|
||||
note={t("fleet_table_pending_note")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Gauge, Network, ServerOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PendingBackend } from "@/components/States";
|
||||
|
||||
@@ -11,14 +12,15 @@ import { PendingBackend } from "@/components/States";
|
||||
// does not exist yet — that is a separate Oracle-verifiable backend slice. Until
|
||||
// then this surface is honest about the gap rather than charting fake data.
|
||||
export function OpsOverview() {
|
||||
const { t } = useTranslation("ops");
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Gauge className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">SysAdmin</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Platform observability cockpit — a window, not a lever.
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,17 +28,17 @@ export function OpsOverview() {
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Network className="h-4 w-4 text-primary" /> Cluster-wide fleet
|
||||
<Network className="h-4 w-4 text-primary" /> {t("cluster_wide_fleet")}
|
||||
</div>
|
||||
<PendingBackend
|
||||
endpoint="GET /api/v1/admin/servers"
|
||||
note="A platform-scoped fleet read (every server, not just the ones you own) powers the cluster-wide rollup and 3D fleet view. It is an admin-tier backend slice still to be added — distinct from the app-tier GET /me/servers."
|
||||
note={t("ops_overview_pending_note")}
|
||||
/>
|
||||
<Link
|
||||
to="/ops/fleet"
|
||||
className="inline-block text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Open fleet table →
|
||||
{t("open_fleet_table")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -45,10 +47,9 @@ export function OpsOverview() {
|
||||
<CardContent className="flex items-start gap-3 p-4 text-xs text-muted-foreground">
|
||||
<ServerOff className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Control-plane scaling, RBAC, Secrets and cluster lifecycle are{" "}
|
||||
<span className="font-medium">kubectl / CRD operations</span> and are not
|
||||
reachable from here. SysAdmin-Side is observability only — the
|
||||
four-power separation (build / runtime / operator / app) is preserved.
|
||||
{t("footer_pre")}
|
||||
<span className="font-medium">{t("footer_kubectl")}</span>
|
||||
{t("footer_post")}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "dev/**/*.ts"]
|
||||
}
|
||||
+21
-10
@@ -1,17 +1,28 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type PluginOption } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
// 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.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8080", changeOrigin: true },
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const plugins: PluginOption[] = [react()];
|
||||
if (mode === "mock") {
|
||||
const { mockApiPlugin } = await import("./dev/mockApi");
|
||||
plugins.unshift(mockApiPlugin());
|
||||
}
|
||||
|
||||
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"],
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import i18next from "i18next";
|
||||
import "./src/i18n";
|
||||
|
||||
i18next.changeLanguage("en-US");
|
||||
Reference in new issue
Block a user