fix(panel): diagnose runtime failures and add owner recovery controls
This commit is contained in:
47 files changed
+1590
-102
No files matched your search
@@ -5,6 +5,7 @@ import type { Plugin } from "vite";
|
||||
import type {
|
||||
AuthSourceConfig,
|
||||
AuthSourcesSettings,
|
||||
WakePolicySettings,
|
||||
AutostartPolicy,
|
||||
BackupView,
|
||||
Build,
|
||||
@@ -92,6 +93,7 @@ interface MockState {
|
||||
submissions: Submission[];
|
||||
updateWindow: { start: string | null; end: string | null };
|
||||
authSources: AuthSourcesSettings;
|
||||
wakePolicy: WakePolicySettings;
|
||||
// Each server's world volume, seeded on first visit.
|
||||
files: Record<string, MockTree>;
|
||||
}
|
||||
@@ -455,6 +457,7 @@ function initialState(): MockState {
|
||||
},
|
||||
],
|
||||
updateWindow: { start: null, end: null },
|
||||
wakePolicy: { maxRunningServers: 0, wakeCooldownSeconds: 30, revision: "initial", managed: false },
|
||||
authSources: {
|
||||
sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }],
|
||||
revision: "installation-defaults",
|
||||
@@ -1016,6 +1019,23 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
||||
|
||||
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
switch (route(ctx)) {
|
||||
case "GET settings/wake-policy":
|
||||
case "PUT settings/wake-policy": {
|
||||
if (!isOwner(ctx.account.role)) {
|
||||
sendError(ctx.res, 403, "forbidden", "Owner account required");
|
||||
} else if (is("GET", ctx)) {
|
||||
sendJSON(ctx.res, 200, ctx.state.wakePolicy);
|
||||
} else {
|
||||
const body = await readJSON<Omit<WakePolicySettings, "managed">>(ctx.req);
|
||||
if (body.revision !== ctx.state.wakePolicy.revision) sendError(ctx.res, 409, "conflict", "policy changed; reload before saving");
|
||||
else if (!Number.isInteger(body.maxRunningServers) || body.maxRunningServers < 0 || body.maxRunningServers > 10000 || !Number.isInteger(body.wakeCooldownSeconds) || body.wakeCooldownSeconds < 0 || body.wakeCooldownSeconds > 3600) sendError(ctx.res, 400, "bad_request", "invalid wake policy");
|
||||
else {
|
||||
ctx.state.wakePolicy = { ...body, revision: createHash("sha256").update(JSON.stringify(body)).digest("hex"), managed: true };
|
||||
sendJSON(ctx.res, 200, ctx.state.wakePolicy);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "GET settings/auth-sources":
|
||||
case "PUT settings/auth-sources":
|
||||
case "POST settings/auth-sources/test": {
|
||||
@@ -2324,6 +2344,14 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
|
||||
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
|
||||
return true;
|
||||
}
|
||||
if (is("POST", ctx) && ctx.parts[4] === "emergency-stop") {
|
||||
if (!ctx.account || !isOwner(ctx.account.role)) { sendError(ctx.res, 403, "forbidden", "owner access required"); return true; }
|
||||
const body = await readJSON(ctx.req) as { confirm?: string };
|
||||
if (body.confirm !== serverInfo.name) { sendError(ctx.res, 400, "bad_request", "type the server name"); return true; }
|
||||
setPhase(serverInfo, "Stopped");
|
||||
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Stopped" });
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
|
||||
test("platform policy is an Owner entry and persists after reload", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.goto("/admin/platform");
|
||||
await expect(page.getByRole("heading", { name: t("admin:platform_title"), exact: true })).toBeVisible();
|
||||
const limit = page.getByLabel(t("admin:platform_limit"));
|
||||
await expect(limit).toBeEnabled();
|
||||
await limit.fill("3");
|
||||
await page.getByLabel(t("admin:platform_cooldown")).fill("45");
|
||||
await page.getByRole("button", { name: t("admin:platform_save") }).click();
|
||||
await expect(page.getByText(t("admin:platform_saved"), { exact: true })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(limit).toHaveValue("3");
|
||||
await expect(page.getByLabel(t("admin:platform_cooldown"))).toHaveValue("45");
|
||||
await expectFitsScreen(page);
|
||||
await signIn("linked");
|
||||
await page.goto("/admin/platform");
|
||||
await expect(page.getByRole("heading", { name: t("admin:platform_title"), exact: true })).toBeHidden();
|
||||
expect((await page.request.get("/api/v1/settings/wake-policy")).status()).toBe(403);
|
||||
});
|
||||
|
||||
test("source disclosure does not scale on press and long pages keep bottom breathing room", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
for (const viewport of [{ width: 1440, height: 600 }, { width: 375, height: 812 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/admin/auth-sources");
|
||||
const disclosure = page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) });
|
||||
const box = await disclosure.boundingBox();
|
||||
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||
await page.mouse.down();
|
||||
await expect.poll(() => disclosure.evaluate((el) => getComputedStyle(el).transform)).toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/);
|
||||
await page.mouse.up();
|
||||
await expect(page.getByRole("button", { name: t("authSources:collapse", { name: "littleskin" }) })).toHaveAttribute("aria-expanded", "true");
|
||||
const main = page.getByRole("main");
|
||||
await main.evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||
const save = page.getByRole("button", { name: t("authSources:save"), exact: true });
|
||||
await expect(save).toBeInViewport();
|
||||
const room = await save.evaluate((el) => el.closest("main")!.getBoundingClientRect().bottom - el.parentElement!.parentElement!.getBoundingClientRect().bottom);
|
||||
expect(room).toBeGreaterThanOrEqual(23);
|
||||
await expectFitsScreen(page);
|
||||
}
|
||||
});
|
||||
|
||||
test("startup shows actual scheduling blockage, stale API state, then recovery", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
let state: "blocked" | "outage" | "running" = "blocked";
|
||||
await page.route("**/api/v1/servers/survival/status", async (route) => {
|
||||
if (state === "outage") { await route.fulfill({ status: 503, json: { error: { code: "upstream_unavailable", message: "test API outage" } } }); return; }
|
||||
const original = await route.fetch();
|
||||
const data = await original.json();
|
||||
await route.fulfill({ json: { ...data, phase: state === "running" ? "Running" : "Starting", desiredState: "Running", ready: state === "running", startup: state === "blocked" ? { stage: "scheduling", reason: "Unschedulable", message: "0/1 nodes: Insufficient memory", startedAt: new Date(Date.now() - 60000).toISOString(), logsAvailable: false } : undefined } });
|
||||
});
|
||||
await page.goto("/servers/survival");
|
||||
await expect(page.getByText(t("servers:startup_scheduling"), { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t("servers:startup_memory"), { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t("servers:log_ended"), { exact: true })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: t("servers:retire_delete"), exact: true })).toBeHidden();
|
||||
state = "outage";
|
||||
await expect(page.getByText(t("servers:status_stale"), { exact: true })).toBeVisible({ timeout: 7000 });
|
||||
await expect(page.getByText(t("servers:startup_scheduling"), { exact: true })).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: t("servers:stop"), exact: true })).toBeHidden();
|
||||
state = "running";
|
||||
await expect(page.getByText(t("servers:status_stale"), { exact: true })).toBeHidden({ timeout: 7000 });
|
||||
await page.getByRole("button", { name: t("servers:more_actions"), exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: t("servers:retire_delete"), exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Owner emergency stop stays secondary, confirms name, and distinguishes acceptance from shutdown", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.goto("/servers/survival");
|
||||
await page.getByRole("button", { name: t("servers:more_actions"), exact: true }).click();
|
||||
await page.getByRole("button", { name: t("servers:emergency_stop"), exact: true }).click();
|
||||
const confirm = page.getByRole("button", { name: t("servers:emergency_stop"), exact: true });
|
||||
await expect(confirm).toBeDisabled();
|
||||
await page.getByLabel(t("servers:retire_confirm_label"), { exact: false }).fill("survival");
|
||||
await confirm.click();
|
||||
await expect(page.getByText(t("servers:emergency_stop_accepted"), { exact: true })).toBeVisible();
|
||||
await expect(confirm).toBeDisabled();
|
||||
await page.getByRole("dialog").getByRole("button", { name: t("common:close_sr"), exact: true }).last().click();
|
||||
await expectFitsScreen(page);
|
||||
await page.goto("/admin/platform");
|
||||
const ownerNav = page.getByRole("navigation").getByRole("link");
|
||||
const links = await ownerNav.evaluateAll((elements) => elements.map((el) => el.getAttribute("href")));
|
||||
expect(links.at(-1)).toBe("/admin/platform");
|
||||
});
|
||||
@@ -65,6 +65,7 @@ const LobbyPage = lazyWithReload(() =>
|
||||
const UpdatesPage = lazyWithReload(() =>
|
||||
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
||||
);
|
||||
const PlatformSettingsPage = lazyWithReload(() => import("@/pages/admin/PlatformSettingsPage").then((m) => ({ default: m.PlatformSettingsPage })));
|
||||
const AuthSourcesPage = lazyWithReload(() => import("@/pages/admin/AuthSourcesPage").then((m) => ({ default: m.AuthSourcesPage })));
|
||||
|
||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||
@@ -132,6 +133,7 @@ export default function App() {
|
||||
<Route path="updates" element={<UpdatesPage />} />
|
||||
{/* Owner-gated platform settings and user management. */}
|
||||
<Route element={<RequireOwner />}>
|
||||
<Route path="platform" element={<PlatformSettingsPage />} />
|
||||
<Route path="auth-sources" element={<AuthSourcesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
|
||||
|
||||
@@ -309,8 +309,8 @@ export function AppShell() {
|
||||
</div>
|
||||
</header>
|
||||
<div ref={setHeaderHost} className="shrink-0 border-b border-border bg-background p-4 empty:hidden md:p-6" />
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-8xl flex-1 flex-col gap-6">
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-4 md:px-6 md:pt-6">
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-8xl flex-1 flex-col gap-6 after:h-6 after:shrink-0 after:content-['']">
|
||||
{/* A crash on one page leaves the navigation usable; moving to
|
||||
another route clears it. Pages load as their own chunks, so the
|
||||
first visit to one shows a spinner here with the shell in place,
|
||||
|
||||
@@ -104,3 +104,20 @@ describe("LogConsole", () => {
|
||||
expect(verdicts.filter((same) => same === false)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startup log retries", () => {
|
||||
it("recovers from a fatal early attach without requiring a manual reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
const view = render(<LogConsole url="/startup" starting />);
|
||||
try {
|
||||
const original = FakeEventSource.last!;
|
||||
act(() => { original.readyState = 2; original.onerror?.({}); });
|
||||
expect(screen.getByText(i18next.t("servers:log_reconnecting"))).toBeTruthy();
|
||||
expect(screen.getByText(i18next.t("servers:log_starting_wait"))).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTime(5000));
|
||||
expect(FakeEventSource.last).not.toBe(original);
|
||||
act(() => FakeEventSource.last!.onopen?.({}));
|
||||
expect(screen.getByText(i18next.t("servers:log_live"))).toBeTruthy();
|
||||
} finally { view.unmount(); vi.useRealTimers(); }
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { memo, useEffect, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -81,9 +81,14 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
|
||||
* scrolling back to the bottom re-pins. The buffer is bounded by the controller,
|
||||
* which also batches lines to one update per frame.
|
||||
*/
|
||||
export function LogConsole({ url, className }: { url: string; className?: string }) {
|
||||
export function LogConsole({ url, className, starting = false }: { url: string; className?: string; starting?: boolean }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const { lines, status, clear, reconnect } = useLogStream(url);
|
||||
const { lines, status, retryable, clear, reconnect } = useLogStream(url);
|
||||
useEffect(() => {
|
||||
if (!starting || !retryable || status !== "ended") return;
|
||||
const timer = window.setTimeout(reconnect, 5000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [starting, retryable, status, reconnect]);
|
||||
const chunks = useMemo(() => chunkLines(lines), [lines]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [pinned, setPinned] = useState(true);
|
||||
@@ -110,7 +115,7 @@ export function LogConsole({ url, className }: { url: string; className?: string
|
||||
<div className={cn("overflow-hidden rounded-md border border-border bg-black flex-1 flex flex-col min-h-0", className)}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2 shrink-0">
|
||||
<StatusIndicator status={status} />
|
||||
<StatusIndicator status={starting && retryable && status === "ended" ? "reconnecting" : status} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{status === "ended" && (
|
||||
<Button
|
||||
@@ -143,9 +148,9 @@ export function LogConsole({ url, className }: { url: string; className?: string
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p className="select-none py-8 text-center text-zinc-600">
|
||||
{status === "ended"
|
||||
{status === "ended" && (!starting || !retryable)
|
||||
? t("log_ended_empty")
|
||||
: t("log_waiting")}
|
||||
: t(starting ? "log_starting_wait" : "log_waiting")}
|
||||
</p>
|
||||
) : (
|
||||
chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
|
||||
|
||||
@@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { RetireCard, RetireNotice } from "./Retirement";
|
||||
|
||||
const calls = vi.hoisted(() => ({ retireServer: vi.fn(), cancelRetire: vi.fn() }));
|
||||
const calls = vi.hoisted(() => ({ retireServer: vi.fn(), cancelRetire: vi.fn(), emergencyStop: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
@@ -17,15 +17,42 @@ const hourAgo = () => new Date(Date.now() - 3600_000).toISOString();
|
||||
beforeEach(() => {
|
||||
calls.retireServer.mockReset();
|
||||
calls.cancelRetire.mockReset();
|
||||
calls.emergencyStop.mockReset();
|
||||
});
|
||||
|
||||
describe("RetireCard", () => {
|
||||
it("requires exact confirmation and reports acceptance without claiming shutdown", async () => {
|
||||
calls.emergencyStop.mockResolvedValue({ desiredState: "Stopped" });
|
||||
const onChanged = vi.fn();
|
||||
render(<RetireCard name="survival" label="World" isAdmin isOwner onChanged={onChanged} />);
|
||||
expect(screen.queryByRole("button", { name: t("servers:emergency_stop") })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:emergency_stop") }));
|
||||
const confirm = screen.getByRole("button", { name: t("servers:emergency_stop") });
|
||||
expect(confirm).toHaveProperty("disabled", true);
|
||||
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
|
||||
await userEvent.click(confirm);
|
||||
expect(calls.emergencyStop).toHaveBeenCalledExactlyOnceWith("survival", "survival");
|
||||
expect(screen.getByText(t("servers:emergency_stop_accepted"))).toBeTruthy();
|
||||
expect(confirm).toHaveProperty("disabled", true);
|
||||
expect(onChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps system services behind emergency controls without offering retirement", async () => {
|
||||
render(<RetireCard name="login" label="Login" isAdmin isOwner canRetire={false} onChanged={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||
expect(screen.getByRole("button", { name: t("servers:emergency_stop") })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: t("servers:retire_release") })).toBeNull();
|
||||
});
|
||||
|
||||
it("lets an owner give the server up once its name is typed out, and never offers a deletion", async () => {
|
||||
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: false } });
|
||||
const onChanged = vi.fn();
|
||||
render(<RetireCard name="survival" label="Survival World" isAdmin={false} onChanged={onChanged} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_release") }));
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
@@ -57,6 +84,7 @@ describe("RetireCard", () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.textContent).toContain(t("servers:retire_step_delete"));
|
||||
@@ -73,6 +101,7 @@ describe("RetireCard", () => {
|
||||
const onChanged = vi.fn();
|
||||
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
|
||||
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_confirm_delete") }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Hourglass, Loader2, PackageX } from "lucide-react";
|
||||
import { ChevronRight, Ellipsis, Hourglass, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -15,6 +15,7 @@ import { MessageLine } from "@/components/MessageLine";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { formatAbsolute, formatRelative } from "@/lib/format";
|
||||
import type { RetireState } from "@/lib/types";
|
||||
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A server leaves its owner, or the platform, through a retirement
|
||||
@@ -100,7 +101,7 @@ export function RetireNotice({
|
||||
);
|
||||
}
|
||||
|
||||
type Mode = "release" | "delete";
|
||||
type Mode = "release" | "delete" | "stop";
|
||||
|
||||
/** RetireCard is the console sidebar's way to give a server up (its owner or an
|
||||
* admin) or delete it (an admin). Either asks for the server's name typed out,
|
||||
@@ -109,43 +110,40 @@ export function RetireCard({
|
||||
name,
|
||||
label,
|
||||
isAdmin,
|
||||
isOwner = false,
|
||||
canRetire = true,
|
||||
onChanged,
|
||||
}: {
|
||||
name: string;
|
||||
/** What the dialog calls the server: its display name, else its name. */
|
||||
label: string;
|
||||
isAdmin: boolean;
|
||||
isOwner?: boolean;
|
||||
canRetire?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [mode, setMode] = useState<Mode | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-card p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<PackageX className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{isAdmin ? t("retire_card_title_admin") : t("retire_card_title")}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isAdmin ? t("retire_card_desc_admin") : t("retire_card_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setMode("release")}
|
||||
>
|
||||
{t("retire_release")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant="destructive" onClick={() => setMode("delete")}>
|
||||
{t("retire_delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outline" className="h-auto w-full justify-start gap-3 border-border bg-card p-4 text-left active:scale-100" onClick={() => setOpen(true)}>
|
||||
<Ellipsis className="text-muted-foreground" /><span className="flex-1">{t("more_actions")}</span><ChevronRight className="text-muted-foreground" />
|
||||
</Button>
|
||||
<Dialog open={open && mode === null} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("more_actions")}</DialogTitle>
|
||||
<DialogDescription>{canRetire ? (isAdmin ? t("retire_card_desc_admin") : t("retire_card_desc")) : t("emergency_stop_warning")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isOwner && <Button variant="outline" onClick={() => setMode("stop")}>{t("emergency_stop")}</Button>}
|
||||
{canRetire && <Button variant="outline" onClick={() => setMode("release")}>{t("retire_release")}</Button>}
|
||||
{canRetire && isAdmin && <Button variant="destructive" onClick={() => setMode("delete")}>{t("retire_delete")}</Button>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{mode && (
|
||||
<RetireDialog
|
||||
name={name}
|
||||
@@ -153,7 +151,7 @@ export function RetireCard({
|
||||
mode={mode}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setMode(null)}
|
||||
onDone={onChanged}
|
||||
onDone={() => { setOpen(false); onChanged(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -180,40 +178,51 @@ function RetireDialog({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const del = mode === "delete";
|
||||
const stopping = mode === "stop";
|
||||
const reauth = useReauth();
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
function setOpen(open: boolean) {
|
||||
if (!open && !busy) onClose();
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (typed !== name || busy) return;
|
||||
if (typed !== name || busy || accepted) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (stopping) {
|
||||
await reauth.guard(() => api.emergencyStop(name, typed));
|
||||
setAccepted(true);
|
||||
setBusy(false);
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
await api.retireServer(name, { confirm: typed, delete: del });
|
||||
setBusy(false);
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(humanizeError(e));
|
||||
if (!isReauthCancelled(e)) setError(humanizeError(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{del ? t("retire_delete_title", { name: label }) : t("retire_release_title", { name: label })}</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<DialogTitle>{stopping ? t("emergency_stop") : del ? t("retire_delete_title", { name: label }) : t("retire_release_title", { name: label })}</DialogTitle>
|
||||
{stopping ? <DialogDescription>{t("emergency_stop_warning")}</DialogDescription> : <DialogDescription asChild>
|
||||
<ul className="list-disc space-y-1 pl-5 text-left">
|
||||
<li>{t("retire_step_stop")}</li>
|
||||
<li>{del ? t("retire_step_delete") : t("retire_step_release")}</li>
|
||||
<li>{isAdmin ? t("retire_step_cancel_admin") : t("retire_step_cancel_owner")}</li>
|
||||
</ul>
|
||||
</DialogDescription>
|
||||
</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
{accepted ? <MessageLine kind="success" message={t("emergency_stop_accepted")} /> : <div className="grid gap-2">
|
||||
<label htmlFor="retire-confirm" className="text-sm">
|
||||
{t("retire_confirm_label")} <code className="rounded bg-muted px-1 font-mono text-xs">{name}</code>
|
||||
</label>
|
||||
@@ -228,17 +237,20 @@ function RetireDialog({
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
{error && <MessageLine kind="error" message={error} compact />}
|
||||
{stopping && error && <p className="text-sm text-muted-foreground">{t("emergency_stop_unknown")}</p>}
|
||||
<ConfirmFooter
|
||||
onCancel={() => setOpen(false)}
|
||||
onConfirm={() => void confirm()}
|
||||
disabled={typed !== name}
|
||||
disabled={typed !== name || accepted}
|
||||
loading={busy}
|
||||
cancelLabel={t("access_cancel")}
|
||||
confirmLabel={del ? t("retire_confirm_delete") : t("retire_confirm_release")}
|
||||
cancelLabel={accepted ? t("common:close_sr") : t("access_cancel")}
|
||||
confirmLabel={stopping ? t("emergency_stop") : del ? t("retire_confirm_delete") : t("retire_confirm_release")}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{reauth.dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -306,5 +306,20 @@
|
||||
"scan_download_report": "Trivy report",
|
||||
"scan_download_sbom": "SBOM",
|
||||
"scan_not_kept": "Not kept with this build: the file was too large, or the step that writes it failed.",
|
||||
"scan_download_failed": "Couldn't download: {{reason}}"
|
||||
"scan_download_failed": "Couldn't download: {{reason}}",
|
||||
"platform_title": "Platform settings",
|
||||
"platform_subtitle": "Global controls for server startup and wake requests.",
|
||||
"platform_reload": "Reload",
|
||||
"platform_wake_title": "Startup and wake policy",
|
||||
"platform_loading": "Loading the saved policy…",
|
||||
"platform_limit": "Running server limit (0–10000)",
|
||||
"platform_limit_hint": "0 means unlimited. New starts count servers already running or requested to run; system services can always start. Lowering the limit does not stop existing servers.",
|
||||
"platform_cooldown": "Wake cooldown (0–3600 seconds)",
|
||||
"platform_cooldown_hint": "Minimum interval between accepted wake requests for the same server. 0 disables the cooldown.",
|
||||
"platform_scope": "The limit applies to panel starts, in-game wakes and scheduled starts. It is an admission check; available memory still determines whether Kubernetes can schedule a server. User quotas remain under Users.",
|
||||
"platform_saved": "Saved. The new policy is active without a restart.",
|
||||
"platform_unsaved": "Unsaved changes",
|
||||
"platform_live": "Saved changes apply immediately on all API replicas.",
|
||||
"platform_discard": "Discard changes",
|
||||
"platform_save": "Save and apply"
|
||||
}
|
||||
@@ -11,5 +11,6 @@
|
||||
"admin_submissions": "Submissions",
|
||||
"admin_updates": "Maintenance & Backups",
|
||||
"admin_lobby": "Login & lobby",
|
||||
"auth_sources": "Authentication sources"
|
||||
"auth_sources": "Authentication sources",
|
||||
"platform_settings": "Platform settings"
|
||||
}
|
||||
@@ -270,5 +270,29 @@
|
||||
"migration_state_succeeded": "Migration complete; start manually after inspection",
|
||||
"migration_state_failed": "Migration failed; server remains stopped",
|
||||
"system_customize": "Customize space",
|
||||
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle."
|
||||
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle.",
|
||||
"more_actions": "More actions",
|
||||
"log_starting_wait": "Waiting for startup logs. The connection retries automatically while the server is starting.",
|
||||
"startup_creating": "Creating the server container",
|
||||
"startup_scheduling": "Waiting for resources and scheduling",
|
||||
"startup_preparing": "Preparing files and pulling images",
|
||||
"startup_booting": "Starting Minecraft and checking readiness",
|
||||
"startup_elapsed": "Startup requested {{seconds}} seconds ago",
|
||||
"startup_memory": "Not enough schedulable memory. Reduce this server’s memory allocation, stop another server, or add node capacity. Java has not started yet.",
|
||||
"startup_hint": "Startup status refreshes automatically. Scheduling, file preparation and Minecraft startup are separate stages; no log output does not mean the process has hung.",
|
||||
"status_stale": "Status unavailable",
|
||||
"status_stale_detail": "Cannot confirm the current server state. Last successful read: {{time}}. Retrying automatically. {{error}}",
|
||||
"startup_failed": "Startup blocked or container failed",
|
||||
"logs_unavailable_title": "Logs are not available yet",
|
||||
"logs_unavailable_body": "The container has not started producing logs. Check the startup diagnostics above; logs attach automatically once available.",
|
||||
"startup_problem": "Startup issue",
|
||||
"startup_waiting_resources": "Waiting for resources",
|
||||
"startup_failed_hint": "Check the image, configuration and memory using the reported error. Correct the problem before restarting the server.",
|
||||
"emergency_stop": "Emergency stop",
|
||||
"emergency_stop_warning": "Use when normal stop is unresponsive. Scale the workload to zero independently of the game process and Operator, retaining normal shutdown grace. Worlds are not deleted, but unsaved changes may be lost. Fresh authentication is required.",
|
||||
"emergency_stop_accepted": "Stop intent saved and workload scaled to zero. Wait for the Pod to exit; status will continue checking. Accepted does not mean stopped.",
|
||||
"emergency_stop_unknown": "The result is unconfirmed. Stop intent may already be saved. Refresh status; if the control API is unreachable, use the host recovery steps.",
|
||||
"host_recovery": "Control API unreachable? Show host recovery steps",
|
||||
"host_recovery_hint": "The panel cannot bypass an offline control API. Run these commands on the deployment host: save stop intent, scale to zero, then check game Pods.",
|
||||
"host_recovery_verify": "A terminating Pod or unreachable node does not confirm that the process stopped. Check the node and container runtime; force-deleting a Pod is not proof of shutdown."
|
||||
}
|
||||
@@ -302,5 +302,20 @@
|
||||
"scan_download_report": "Trivy 报告",
|
||||
"scan_download_sbom": "SBOM",
|
||||
"scan_not_kept": "这次构建未留存该文件:文件过大,或生成它的步骤失败。",
|
||||
"scan_download_failed": "下载失败:{{reason}}"
|
||||
"scan_download_failed": "下载失败:{{reason}}",
|
||||
"platform_title": "平台设置",
|
||||
"platform_subtitle": "统一管理服务器启动与唤醒的全局策略。",
|
||||
"platform_reload": "重新读取",
|
||||
"platform_wake_title": "启动与唤醒策略",
|
||||
"platform_loading": "正在读取已保存的策略…",
|
||||
"platform_limit": "同时运行的服务器上限(0–10000)",
|
||||
"platform_limit_hint": "0 表示不限制。启动时计入已运行和已请求运行的服务器;系统服务始终可以启动。降低上限不会自动停止现有服务器。",
|
||||
"platform_cooldown": "唤醒冷却时间(0–3600 秒)",
|
||||
"platform_cooldown_hint": "同一服务器两次成功接受唤醒请求的最短间隔。0 表示关闭冷却。",
|
||||
"platform_scope": "运行上限统一用于面板启动、游戏内唤醒与计划任务。此上限用于启动准入,能否实际调度仍取决于节点可用内存。用户配额在“用户管理”中调整。",
|
||||
"platform_saved": "已保存并立即生效,无需重启。",
|
||||
"platform_unsaved": "有尚未保存的更改",
|
||||
"platform_live": "保存后会立即在所有 API 实例上生效。",
|
||||
"platform_discard": "放弃更改",
|
||||
"platform_save": "保存并生效"
|
||||
}
|
||||
@@ -11,5 +11,6 @@
|
||||
"admin_submissions": "审核提交",
|
||||
"admin_updates": "维护与备份",
|
||||
"admin_lobby": "登录与大厅",
|
||||
"auth_sources": "认证源"
|
||||
"auth_sources": "认证源",
|
||||
"platform_settings": "平台设置"
|
||||
}
|
||||
@@ -269,5 +269,29 @@
|
||||
"migration_state_succeeded": "迁移完成,等待手动启动",
|
||||
"migration_state_failed": "迁移失败,服务器保持停服",
|
||||
"system_customize": "自定义大厅",
|
||||
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。"
|
||||
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。",
|
||||
"more_actions": "更多操作",
|
||||
"log_starting_wait": "正在等待启动日志,启动期间会自动重试连接。",
|
||||
"startup_creating": "正在创建服务器容器",
|
||||
"startup_scheduling": "正在等待资源与调度",
|
||||
"startup_preparing": "正在准备文件与拉取镜像",
|
||||
"startup_booting": "正在启动 Minecraft 并检查就绪状态",
|
||||
"startup_elapsed": "本次启动已等待 {{seconds}} 秒",
|
||||
"startup_memory": "可调度内存不足。请降低此服务器的内存配置、停止其他服务器,或增加节点容量。Java 尚未启动。",
|
||||
"startup_hint": "启动状态会自动刷新。资源调度、文件准备与游戏启动是不同阶段,没有日志输出并不代表程序卡死。",
|
||||
"status_stale": "状态已过期",
|
||||
"status_stale_detail": "无法确认服务器当前状态。最后成功读取:{{time}}。正在自动重试。{{error}}",
|
||||
"startup_failed": "启动受阻或容器异常",
|
||||
"logs_unavailable_title": "日志暂不可用",
|
||||
"logs_unavailable_body": "容器尚未开始输出日志。请查看上方的启动状态与阻塞原因;日志可用后会自动连接。",
|
||||
"startup_problem": "启动异常",
|
||||
"startup_waiting_resources": "等待资源",
|
||||
"startup_failed_hint": "请根据报错检查镜像、配置与内存。修正问题后再重新启动服务器。",
|
||||
"emergency_stop": "应急停止",
|
||||
"emergency_stop_warning": "普通停止无响应时使用。直接将工作负载缩到零,绕过游戏进程和 Operator,保留正常退出时间;不会删除世界,但无法保证未保存的内容。需要重新验证身份。",
|
||||
"emergency_stop_accepted": "停止意图已保存,工作负载已缩到零。仍需等待 Pod 退出;上方状态会继续检查,不能将请求受理视为已经停止。",
|
||||
"emergency_stop_unknown": "操作结果尚未确认。停止意图可能已经保存,请重新读取状态;若控制 API 不可达,请使用主机侧恢复步骤。",
|
||||
"host_recovery": "控制 API 不可达?查看主机侧应急停止步骤",
|
||||
"host_recovery_hint": "面板无法绕过已经离线的控制 API。仅在部署主机上执行下列命令:先保存停止意图,再缩到零,最后检查游戏 Pod。",
|
||||
"host_recovery_verify": "如果 Pod 仍在退出或节点不可达,不能确认进程已停止。检查对应节点和容器运行时;不要强制删除 Pod 来冒充停止成功。"
|
||||
}
|
||||
@@ -1813,3 +1813,22 @@ describe("world export wire shapes", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("server status timeout", () => {
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
it("bounds a stalled status request and reports an unavailable connection", async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
|
||||
vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
|
||||
init.signal!.addEventListener("abort", () => reject(init.signal!.reason));
|
||||
})));
|
||||
const request = api.status("survival");
|
||||
const result = expect(request).rejects.toMatchObject({ code: "network_error", status: 0 });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
controller.abort(new DOMException("Status request timed out", "TimeoutError"));
|
||||
await result;
|
||||
expect(timeout).toHaveBeenCalledWith(12_000);
|
||||
expect(isConnectionLost()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
MinecraftAuthSource,
|
||||
AuthSourceConfig,
|
||||
AuthSourcesSettings,
|
||||
WakePolicySettings,
|
||||
MinecraftProfile,
|
||||
LinkStatus,
|
||||
BindResult,
|
||||
@@ -441,7 +442,8 @@ export const api = rejectingSync({
|
||||
fleet: () =>
|
||||
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
|
||||
|
||||
status: (name: string) => request<ServerStatus>("GET", urlPath`/servers/${name}/status`),
|
||||
emergencyStop: (name: string, confirm: string) => send<{ name: string; desiredState: string }>(urlPath`/servers/${name}/emergency-stop`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm }), signal: AbortSignal.timeout(20_000) }),
|
||||
status: (name: string) => send<ServerStatus>(urlPath`/servers/${name}/status`, { method: "GET", signal: AbortSignal.timeout(12_000) }),
|
||||
|
||||
wake: (name: string) =>
|
||||
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`),
|
||||
@@ -952,6 +954,8 @@ export const api = rejectingSync({
|
||||
|
||||
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
||||
|
||||
getWakePolicy: () => request<WakePolicySettings>("GET", "/settings/wake-policy"),
|
||||
setWakePolicy: (policy: Omit<WakePolicySettings, "managed">) => request<WakePolicySettings>("PUT", "/settings/wake-policy", policy),
|
||||
getAuthSources: () => request<AuthSourcesSettings>("GET", "/settings/auth-sources"),
|
||||
setAuthSources: (sources: AuthSourceConfig[], revision: string) => request<AuthSourcesSettings>("PUT", "/settings/auth-sources", { sources, revision }),
|
||||
testAuthSource: (source: AuthSourceConfig) => request<{ ok: boolean; status: number; elapsed_ms: number }>("POST", "/settings/auth-sources/test", source),
|
||||
|
||||
@@ -154,3 +154,30 @@ describe("usePolling", () => {
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("polling slow or unavailable backends", () => {
|
||||
it("does not supersede an in-flight poll and keeps a failure visible until a successful read", async () => {
|
||||
vi.useFakeTimers();
|
||||
const req = requests();
|
||||
const hook = renderHook(() => {
|
||||
const query = useAsync(() => req.fetch("a"), [], { coalesce: true });
|
||||
usePolling(query.reload, 4000);
|
||||
return query;
|
||||
});
|
||||
try {
|
||||
await req.settle(0, { ok: "Starting" });
|
||||
const checked = hook.result.current.updatedAt;
|
||||
act(() => vi.advanceTimersByTime(4000));
|
||||
act(() => vi.advanceTimersByTime(12000));
|
||||
expect(req.pending).toHaveLength(2);
|
||||
await req.settle(1, { fail: new Error("backend timeout") });
|
||||
expect(hook.result.current).toMatchObject({ data: "Starting", loading: false, updatedAt: checked });
|
||||
act(() => vi.advanceTimersByTime(4000));
|
||||
expect(hook.result.current.error).toBeInstanceOf(Error);
|
||||
expect(req.pending).toHaveLength(3);
|
||||
await req.settle(2, { ok: "Running" });
|
||||
expect(hook.result.current).toMatchObject({ data: "Running", error: null, loading: false });
|
||||
expect(hook.result.current.updatedAt).toBeGreaterThan(checked!);
|
||||
} finally { hook.unmount(); vi.useRealTimers(); }
|
||||
});
|
||||
});
|
||||
+17
-5
@@ -21,6 +21,7 @@ export interface AsyncState<T> {
|
||||
data: T | null;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
updatedAt: number | null;
|
||||
/** Re-run the async fn (e.g. after a mutation). */
|
||||
reload: () => void;
|
||||
}
|
||||
@@ -30,6 +31,8 @@ export interface AsyncOptions {
|
||||
* are its filter or page: the old rows stay put (each still acts on its own
|
||||
* item) instead of blanking to a spinner on every keystroke. */
|
||||
keepPrevious?: boolean;
|
||||
/** Polling must not supersede a request that is still awaiting its timeout. */
|
||||
coalesce?: boolean;
|
||||
}
|
||||
|
||||
interface Settled<T> {
|
||||
@@ -38,6 +41,7 @@ interface Settled<T> {
|
||||
data: T | null;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
updatedAt: number | null;
|
||||
}
|
||||
|
||||
/** useAsync runs an async producer on mount and on demand, guarding against
|
||||
@@ -49,33 +53,39 @@ interface Settled<T> {
|
||||
export function useAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
deps: unknown[] = [],
|
||||
{ keepPrevious = false }: AsyncOptions = {},
|
||||
{ keepPrevious = false, coalesce = false }: AsyncOptions = {},
|
||||
): AsyncState<T> {
|
||||
const [settled, setSettled] = useState<Settled<T> | null>(null);
|
||||
const seq = useRef(0);
|
||||
const inFlight = useRef<{ run: () => Promise<T>; ticket: number } | null>(null);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const run = useCallback(fn, deps);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (coalesce && inFlight.current?.run === run) return;
|
||||
const ticket = ++seq.current;
|
||||
inFlight.current = { run, ticket };
|
||||
setSettled((s) => ({
|
||||
run,
|
||||
data: s?.run === run || keepPrevious ? (s?.data ?? null) : null,
|
||||
error: null,
|
||||
error: s?.run === run ? s.error : null,
|
||||
updatedAt: s?.run === run ? s.updatedAt : null,
|
||||
loading: true,
|
||||
}));
|
||||
run().then(
|
||||
(d) => {
|
||||
if (ticket === seq.current) setSettled({ run, data: d, error: null, loading: false });
|
||||
if (inFlight.current?.ticket === ticket) inFlight.current = null;
|
||||
if (ticket === seq.current) setSettled({ run, data: d, error: null, loading: false, updatedAt: Date.now() });
|
||||
},
|
||||
(e) => {
|
||||
if (inFlight.current?.ticket === ticket) inFlight.current = null;
|
||||
if (ticket === seq.current) {
|
||||
setSettled((s) => ({ run, data: s?.data ?? null, error: e, loading: false }));
|
||||
setSettled((s) => ({ run, data: s?.data ?? null, error: e, loading: false, updatedAt: s?.updatedAt ?? null }));
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [run, keepPrevious]);
|
||||
}, [run, keepPrevious, coalesce]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
@@ -83,6 +93,7 @@ export function useAsync<T>(
|
||||
const tickets = seq;
|
||||
return () => {
|
||||
tickets.current++;
|
||||
inFlight.current = null;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
@@ -94,6 +105,7 @@ export function useAsync<T>(
|
||||
error: current ? settled.error : null,
|
||||
loading: current ? settled.loading : true,
|
||||
reload,
|
||||
updatedAt: current ? settled.updatedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ describe("LogStreamController", () => {
|
||||
es.emitOpen();
|
||||
es.emitMessage("[12:00:00] [Server thread/INFO]: hello");
|
||||
es.emitEvent("revoked"); // no frame ran: the status change carries the line
|
||||
expect(ctrl.getSnapshot().retryable).toBe(false);
|
||||
expect(ctrl.getSnapshot().status).toBe("ended");
|
||||
expect(es.closed).toBe(true);
|
||||
expect(ctrl.getSnapshot().lines).toHaveLength(1);
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface LogLine {
|
||||
export interface LogStreamSnapshot {
|
||||
lines: LogLine[];
|
||||
status: StreamStatus;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
// EventSource readyState constants. We avoid referencing the DOM EventSource
|
||||
@@ -158,13 +159,14 @@ export class LogStreamController {
|
||||
// reference between mutations — required by useSyncExternalStore to avoid an
|
||||
// infinite render loop.
|
||||
private snapshot: LogStreamSnapshot;
|
||||
private retryable = true;
|
||||
|
||||
constructor(opts: LogStreamOptions) {
|
||||
this.url = opts.url;
|
||||
this.factory = opts.factory;
|
||||
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
|
||||
this.frame = opts.frame ?? nextFrame;
|
||||
this.snapshot = { lines: this.lines, status: this.status };
|
||||
this.snapshot = { lines: this.lines, status: this.status, retryable: this.retryable };
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void): (() => void) => {
|
||||
@@ -201,6 +203,7 @@ export class LogStreamController {
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
this.retryable = true;
|
||||
this.setStatus("connecting");
|
||||
const es = this.factory(this.url);
|
||||
this.es = es;
|
||||
@@ -212,6 +215,7 @@ export class LogStreamController {
|
||||
es.addEventListener("revoked", () => {
|
||||
if (this.es !== es) return;
|
||||
this.disconnect();
|
||||
this.retryable = false;
|
||||
this.setStatus("ended");
|
||||
});
|
||||
es.onerror = () => {
|
||||
@@ -281,7 +285,7 @@ export class LogStreamController {
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
this.snapshot = { lines: this.lines, status: this.status };
|
||||
this.snapshot = { lines: this.lines, status: this.status, retryable: this.retryable };
|
||||
this.listeners.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Upload,
|
||||
Clock,
|
||||
ShieldCheck,
|
||||
Settings,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -78,6 +79,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
items: [
|
||||
{ to: "/admin/users", key: "admin_users", icon: Users },
|
||||
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
|
||||
{ to: "/admin/platform", key: "platform_settings", icon: Settings },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -600,6 +600,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/emergency-stop": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Scale an owned game workload to zero independently of Operator and RCON.
|
||||
* @description Owner only; fresh authentication and exact name confirmation required. A 202 acknowledges scaling, not process exit. Normal Pod termination grace is retained.
|
||||
*/
|
||||
post: operations["emergencyStop"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/restart": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1243,6 +1263,27 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/settings/wake-policy": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Read platform startup policy (Owner). */
|
||||
get: operations["getWakePolicy"];
|
||||
/**
|
||||
* Save platform startup policy (Owner, fresh reauthentication).
|
||||
* @description Atomically persists an override shared by all replicas. The admission limit applies to panel, in-game and scheduled starts without restarting. It does not stop existing servers or reserve memory. Wake cooldown applies to panel and in-game wakes.
|
||||
*/
|
||||
put: operations["setWakePolicy"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/settings/auth-sources": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2915,6 +2956,21 @@ export interface components {
|
||||
api_url: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
StartupStatus: {
|
||||
/** @enum {string} */
|
||||
stage: "creating" | "scheduling" | "preparing" | "booting" | "failed";
|
||||
reason?: string;
|
||||
message?: string;
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
logsAvailable: boolean;
|
||||
};
|
||||
WakePolicySettings: {
|
||||
maxRunningServers: number;
|
||||
wakeCooldownSeconds: number;
|
||||
revision: string;
|
||||
managed: boolean;
|
||||
};
|
||||
AuthSourcesSettings: {
|
||||
/** @description Third-party sources in priority order; built-in Mojang always precedes them and is immutable. */
|
||||
sources: components["schemas"]["AuthSourceConfig"][];
|
||||
@@ -3074,6 +3130,8 @@ export interface components {
|
||||
};
|
||||
/** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
|
||||
ServerInfo: {
|
||||
/** @description Private runtime and startup diagnostics for authorized server managers. */
|
||||
startup?: components["schemas"]["StartupStatus"];
|
||||
/** @description Execution node; legacy servers report the observed node. */
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
@@ -5069,6 +5127,44 @@ export interface operations {
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
emergencyStop: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
confirm: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Stop intent persisted and workload scaled to zero; await Pod termination. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
name: string;
|
||||
/** @constant */
|
||||
desiredState: "Stopped";
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
409: components["responses"]["Conflict"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
restart: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -6729,6 +6825,68 @@ export interface operations {
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
getWakePolicy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Current startup policy and revision. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WakePolicySettings"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
};
|
||||
};
|
||||
setWakePolicy: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
maxRunningServers: number;
|
||||
wakeCooldownSeconds: number;
|
||||
revision: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Saved policy and new revision. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["WakePolicySettings"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description The policy changed; reload before saving. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAuthSources: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface MyServerView {
|
||||
* A caller who does not own the server gets the public subset, so everything
|
||||
* past the counts may be absent. */
|
||||
export interface ServerStatus {
|
||||
startup?: { stage: "creating" | "scheduling" | "preparing" | "booting" | "failed"; reason?: string; message?: string; startedAt?: string; logsAvailable: boolean };
|
||||
nodeName?: string;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
@@ -768,3 +769,10 @@ export interface WorldMigration {
|
||||
switched: boolean;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export interface WakePolicySettings {
|
||||
maxRunningServers: number;
|
||||
wakeCooldownSeconds: number;
|
||||
revision: string;
|
||||
managed: boolean;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const browserEventSource: EventSourceFactory = (url) =>
|
||||
export interface UseLogStream {
|
||||
lines: LogLine[];
|
||||
status: StreamStatus;
|
||||
retryable: boolean;
|
||||
clear: () => void;
|
||||
reconnect: () => void;
|
||||
}
|
||||
@@ -58,5 +59,5 @@ export function useLogStream(url: string): UseLogStream {
|
||||
const clear = useCallback(() => controller.clear(), [controller]);
|
||||
const reconnect = useCallback(() => controller.reconnect(), [controller]);
|
||||
|
||||
return { lines: snapshot.lines, status: snapshot.status, clear, reconnect };
|
||||
return { lines: snapshot.lines, status: snapshot.status, retryable: snapshot.retryable, clear, reconnect };
|
||||
}
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="
|
||||
|
||||
beforeEach(() => {
|
||||
tier.isAdmin = true;
|
||||
tier.isOwner = false;
|
||||
calls.status.mockReset();
|
||||
calls.myServers.mockReset();
|
||||
calls.myServers.mockResolvedValue([]);
|
||||
@@ -53,6 +54,18 @@ function status(over: Record<string, unknown>) {
|
||||
return { name: "survival", subdomain: "survival", phase: "Failed", ready: false, playersOnline: 0, playersMax: 20, ...over };
|
||||
}
|
||||
|
||||
describe("ServerConsole host recovery", () => {
|
||||
it("offers host recovery even when the first API read fails", async () => {
|
||||
tier.isOwner = true;
|
||||
calls.status.mockRejectedValue(new Error("API offline"));
|
||||
renderConsole();
|
||||
expect(await screen.findByText("Control API unreachable? Show host recovery steps")).toBeTruthy();
|
||||
expect(screen.getByText(/kubectl patch minecraftserver survival/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||
tier.isOwner = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerConsole failed start", () => {
|
||||
it("shows the failed attempt's log under what the owner can do once retries are spent", async () => {
|
||||
calls.status.mockResolvedValue(status({ desiredState: "Running", startGaveUp: true, autoRestarts: 3 }));
|
||||
@@ -157,10 +170,10 @@ describe("ServerConsole following the server", () => {
|
||||
|
||||
calls.status.mockRejectedValue({ status: 409, code: "test_failure", message: "status backend down" });
|
||||
await advance(STATUS_POLL_SLOW_MS);
|
||||
await waitFor(() => expect(screen.getByText(/Couldn't refresh/)).toBeTruthy());
|
||||
await waitFor(() => expect(screen.getByText(/Cannot confirm the current server state/)).toBeTruthy());
|
||||
expect(screen.getByText(/status backend down/)).toBeTruthy();
|
||||
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,16 +191,21 @@ describe("ServerConsole retirement", () => {
|
||||
calls.status.mockResolvedValue(stopped());
|
||||
renderConsole();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Give up" })).toBeTruthy();
|
||||
const more = await screen.findByRole("button", { name: "More actions" });
|
||||
expect(giveUp()).toBeNull();
|
||||
await userEvent.click(more);
|
||||
expect(screen.getByRole("button", { name: "Give up" })).toBeTruthy();
|
||||
expect(del()).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Wake" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers an admin both", async () => {
|
||||
calls.status.mockResolvedValue(stopped());
|
||||
renderConsole();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Delete" })).toBeTruthy();
|
||||
const more = await screen.findByRole("button", { name: "More actions" });
|
||||
expect(del()).toBeNull();
|
||||
await userEvent.click(more);
|
||||
expect(screen.getByRole("button", { name: "Delete" })).toBeTruthy();
|
||||
expect(giveUp()).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -324,3 +342,42 @@ describe("ServerConsole command line", () => {
|
||||
expect(kept.slice(-2)).toEqual(["cmd 49", "list"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startup diagnostics and stale state", () => {
|
||||
it("shows a crashed container even while the operator phase still says Starting", async () => {
|
||||
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running", startup: { stage: "failed", reason: "OOMKilled", message: "Container minecraft exited with code 137", logsAvailable: false } }));
|
||||
renderConsole();
|
||||
expect(await screen.findByText("Startup issue")).toBeTruthy();
|
||||
expect(screen.getByText(/OOMKilled.*137/)).toBeTruthy();
|
||||
expect(screen.queryByText("Starting", { exact: true })).toBeNull();
|
||||
expect(screen.getByText("Logs are not available yet")).toBeTruthy();
|
||||
});
|
||||
it("explains scheduling failure before logs exist", async () => {
|
||||
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running", startup: { stage: "scheduling", reason: "Unschedulable", message: "0/1 nodes: Insufficient memory", startedAt: new Date().toISOString(), logsAvailable: false } }));
|
||||
renderConsole();
|
||||
expect(await screen.findByText("Waiting for resources and scheduling")).toBeTruthy();
|
||||
expect(screen.getByText(/Not enough schedulable memory/)).toBeTruthy();
|
||||
expect(screen.queryByTestId("log-stream")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Delete server" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy();
|
||||
});
|
||||
it("does not claim a live startup state when the API is unavailable, and recovers automatically", async () => {
|
||||
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running", startup: { stage: "booting", logsAvailable: true } }));
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderConsole();
|
||||
await screen.findByText("Starting Minecraft and checking readiness");
|
||||
try {
|
||||
calls.status.mockRejectedValue({ status: 0, code: "network_error", message: "backend unavailable" });
|
||||
await act(async () => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||
expect(screen.getByText("Status unavailable")).toBeTruthy();
|
||||
expect(screen.getByRole("alert").textContent).toContain("Last successful read");
|
||||
expect(screen.queryByText("Starting Minecraft and checking readiness")).toBeNull();
|
||||
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /^Stop/ })).toBeNull();
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", ready: true, desiredState: "Running" }));
|
||||
await act(async () => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||
expect(screen.queryByText("Status unavailable")).toBeNull();
|
||||
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,13 @@ import { useState, useRef, useCallback, useId, useLayoutEffect, type KeyboardEve
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, CalendarClock, ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { MAX_AUTO_RESTARTS, PhaseBadge, pendingPower, shownPhase, startFailure, type StartFailure } from "@/components/PhaseBadge";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LogConsole } from "@/components/LogConsole";
|
||||
import { Loading, ErrorState, RefreshError } from "@/components/States";
|
||||
import { Loading, ErrorState } from "@/components/States";
|
||||
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
|
||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
@@ -19,15 +20,16 @@ import { EditServerDialog } from "@/components/EditServerDialog";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { RetireCard, RetireNotice } from "@/components/Retirement";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||
|
||||
// notStreamingCopy explains why there is no live feed for a phase that has no
|
||||
// streamable pod. The read path only has something to relay once a pod is up, so
|
||||
// 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 useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string; spin?: boolean } {
|
||||
function useNotStreamingCopy(phase: Phase, waitingForContainer: boolean): { icon: LucideIcon; title: string; body: string; spin?: boolean } {
|
||||
const { t } = useTranslation("servers");
|
||||
if (waitingForContainer) return { icon: HelpCircle, title: t("logs_unavailable_title"), body: t("logs_unavailable_body") };
|
||||
switch (phase) {
|
||||
case "Starting":
|
||||
// Asked to start with no pod yet: the stream attaches once the pod is up.
|
||||
@@ -58,8 +60,8 @@ function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; b
|
||||
}
|
||||
}
|
||||
|
||||
function NotStreaming({ phase }: { phase: Phase }) {
|
||||
const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase);
|
||||
function NotStreaming({ phase, waitingForContainer = false }: { phase: Phase; waitingForContainer?: boolean }) {
|
||||
const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase, waitingForContainer);
|
||||
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={cn("mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70", spin && "animate-spin")} />
|
||||
@@ -229,11 +231,12 @@ function CommandInput({ name }: { name: string }) {
|
||||
export function ServerConsole() {
|
||||
const { name = "" } = useParams();
|
||||
const cfg = useConfig();
|
||||
const { isAdmin } = useTier();
|
||||
const { isAdmin, isOwner } = useTier();
|
||||
const { t } = useTranslation("servers");
|
||||
const { data, error, loading, reload } = useAsync(
|
||||
const { data, error, loading, reload, updatedAt } = useAsync(
|
||||
() => api.status(name),
|
||||
[name],
|
||||
{ coalesce: true },
|
||||
);
|
||||
// The header, the power button and whether the stream attaches all follow the
|
||||
// status, so it is reread: fast while the server is on its way somewhere (a
|
||||
@@ -259,8 +262,10 @@ export function ServerConsole() {
|
||||
// read most wants them, so the gate streams for both, not Running alone. A
|
||||
// Failed start usually leaves its pod behind, and that pod's log is how the
|
||||
// owner finds out why it failed, so Failed streams too.
|
||||
const streamable = data?.phase === "Running" || data?.phase === "Starting" || data?.phase === "Failed";
|
||||
const streamable = data?.phase === "Running" || ((data?.phase === "Starting" || data?.phase === "Failed") && data.startup?.logsAvailable !== false);
|
||||
const failure = data ? startFailure(data) : null;
|
||||
const startup = data?.startup;
|
||||
const startupBlocked = startup?.stage === "failed" || startup?.reason === "Unschedulable";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-1 lg:min-h-[35rem] gap-4 min-h-0">
|
||||
@@ -271,18 +276,26 @@ export function ServerConsole() {
|
||||
{loading && !data ? (
|
||||
<Loading />
|
||||
) : error && !data ? (
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
<div className="space-y-3"><ErrorState error={error} onRetry={reload} />{isOwner && <HostRecovery name={name} />}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{error && <RefreshError error={error} />}
|
||||
{error && <div className="space-y-2"><MessageLine kind="error" message={t("status_stale_detail", { time: updatedAt ? new Date(updatedAt).toLocaleTimeString() : "—", error: humanizeError(error) })} />{isOwner && <HostRecovery name={name} />}</div>}
|
||||
<PageHeader
|
||||
icon={Terminal}
|
||||
title={data.displayName || data.name}
|
||||
subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={shownPhase(data)} failure={failure} autoRestarts={data.autoRestarts} />
|
||||
<PowerButton
|
||||
{error ? (
|
||||
<span role="status" className="text-sm font-medium text-destructive">{t("status_stale")}</span>
|
||||
) : startupBlocked ? (
|
||||
<Badge variant="outline" className="border-amber-500/30 text-amber-600 dark:text-amber-400">
|
||||
{t(startup?.stage === "failed" ? "startup_problem" : "startup_waiting_resources")}
|
||||
</Badge>
|
||||
) : (
|
||||
<PhaseBadge phase={shownPhase(data)} failure={failure} autoRestarts={data.autoRestarts} />
|
||||
)}
|
||||
{!error && <PowerButton
|
||||
name={name}
|
||||
phase={data.phase}
|
||||
desiredState={data.desiredState}
|
||||
@@ -291,7 +304,7 @@ export function ServerConsole() {
|
||||
playerCountUnknown={data.playerCountUnknown}
|
||||
retiring={data.retiring}
|
||||
onChanged={reload}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
}
|
||||
className="mb-6"
|
||||
@@ -301,6 +314,14 @@ export function ServerConsole() {
|
||||
<RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} />
|
||||
)}
|
||||
|
||||
{!error && startup && (
|
||||
<div role="status" className="shrink-0 space-y-2 rounded-lg border border-border bg-card p-4 text-sm">
|
||||
<p className="flex items-center gap-2 font-medium">{startupBlocked ? <ShieldAlert className="h-4 w-4 text-amber-500" /> : <Loader2 className="h-4 w-4 animate-spin text-primary" />}{t(`startup_${startup.stage}`)}</p>
|
||||
{startup.startedAt && <p className="text-xs text-muted-foreground">{t("startup_elapsed", { seconds: Math.max(0, Math.floor((Date.now() - new Date(startup.startedAt).getTime()) / 1000)) })}</p>}
|
||||
{startup.reason && <p className="break-words text-muted-foreground">{startup.reason === "Unschedulable" && startup.message?.includes("Insufficient memory") ? t("startup_memory") : [startup.reason, startup.message].filter(Boolean).join(": ")}</p>}
|
||||
<p className="text-xs text-muted-foreground">{t(startup.stage === "failed" ? "startup_failed_hint" : "startup_hint")}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 lg:grid-rows-[minmax(0,1fr)] flex-1 lg:min-h-0 min-h-0">
|
||||
{/* Left/Main column: Console */}
|
||||
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
|
||||
@@ -308,7 +329,7 @@ export function ServerConsole() {
|
||||
<CardContent className="p-0 flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
||||
{!streamable ? (
|
||||
<div className="flex-1 flex flex-col justify-center p-6 bg-black">
|
||||
<NotStreaming phase={shownPhase(data)} />
|
||||
<NotStreaming phase={error ? "Unknown" : shownPhase(data)} waitingForContainer={!error && data.startup?.logsAvailable === false} />
|
||||
</div>
|
||||
) : cfg ? (
|
||||
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
||||
@@ -317,10 +338,11 @@ export function ServerConsole() {
|
||||
)}
|
||||
<LogConsole
|
||||
key={name}
|
||||
starting={!error && data.phase === "Starting"}
|
||||
url={consoleStreamURL(cfg.apiBase, name)}
|
||||
className="border-0 rounded-none bg-transparent"
|
||||
/>
|
||||
{data.phase === "Running" && (
|
||||
{!error && data.phase === "Running" && (
|
||||
<div className="p-3 bg-zinc-900/40 border-t border-zinc-800">
|
||||
<CommandInput name={name} />
|
||||
</div>
|
||||
@@ -430,8 +452,8 @@ export function ServerConsole() {
|
||||
{/* Giving the server up (owner) or deleting it (admin) closes the
|
||||
sidebar. A system server is never retired, and one already on its
|
||||
way out shows the notice above with the way back instead. */}
|
||||
{owned && !data.reaperExempt && !data.retiring && (
|
||||
<RetireCard name={name} label={data.displayName || data.name} isAdmin={isAdmin} onChanged={reload} />
|
||||
{owned && (isOwner || (!data.reaperExempt && !data.retiring)) && (
|
||||
<RetireCard name={name} label={data.displayName || data.name} isAdmin={isAdmin} isOwner={isOwner} canRetire={!data.reaperExempt && !data.retiring} onChanged={reload} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,3 +462,11 @@ export function ServerConsole() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostRecovery({ name }: { name: string }) {
|
||||
const { t } = useTranslation("servers");
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) return null;
|
||||
return <details className="rounded-lg border border-border bg-card p-3 text-sm"><summary className="cursor-pointer font-medium">{t("host_recovery")}</summary><p className="my-3 text-muted-foreground">{t("host_recovery_hint")}</p><pre className="overflow-x-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{`kubectl patch minecraftserver ${name} -n minecraft --type=merge -p '{"spec":{"desiredState":"Stopped"}}'
|
||||
kubectl scale statefulset ${name} -n minecraft --replicas=0
|
||||
kubectl get pods -n minecraft -l felis.lolicon.best/server=${name},felis.lolicon.best/component=server -o wide`}</pre><p className="mt-3 text-muted-foreground">{t("host_recovery_verify")}</p></details>;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function AuthSourcesPage() {
|
||||
return <Card key={index} className="overflow-hidden rounded-xl shadow-none">
|
||||
<CardHeader className={cn("flex-row flex-wrap items-center justify-between gap-3 space-y-0 bg-muted/20 py-4", expanded && "border-b border-border/60")}>
|
||||
<CardTitle className="min-w-0 flex-1 text-sm">
|
||||
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left hover:bg-transparent" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
|
||||
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left hover:bg-transparent active:scale-100" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
|
||||
<ChevronRight className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-90")} />
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span>
|
||||
<span className="break-all">{source.tag || t(draft ? "new_source" : "loading")}</span>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { PlatformSettingsPage } from "./PlatformSettingsPage";
|
||||
import type { WakePolicySettings } from "@/lib/types";
|
||||
const calls = vi.hoisted(() => ({ getWakePolicy: vi.fn(), setWakePolicy: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
|
||||
const policy: WakePolicySettings = { maxRunningServers: 0, wakeCooldownSeconds: 0, revision: "initial", managed: false };
|
||||
const limit = () => screen.getByRole("spinbutton", { name: "Running server limit (0–10000)" }) as HTMLInputElement;
|
||||
const cooldown = () => screen.getByRole("spinbutton", { name: "Wake cooldown (0–3600 seconds)" });
|
||||
function page() { render(<MemoryRouter><PlatformSettingsPage /></MemoryRouter>); }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
calls.getWakePolicy.mockResolvedValue(policy);
|
||||
calls.setWakePolicy.mockImplementation(async (body) => ({ ...body, revision: "saved", managed: true }));
|
||||
});
|
||||
describe("platform policy", () => {
|
||||
it("keeps the form visible during loading without offering placeholder values as saved configuration", async () => {
|
||||
let resolve!: (policy: WakePolicySettings) => void;
|
||||
calls.getWakePolicy.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||
page();
|
||||
expect(limit().disabled).toBe(true);
|
||||
expect(limit().value).toBe("");
|
||||
expect(screen.getByRole("status").textContent).toContain("Loading the saved policy");
|
||||
await act(async () => resolve(policy));
|
||||
expect(limit().disabled).toBe(false);
|
||||
expect(limit().value).toBe("0");
|
||||
});
|
||||
it("saves validated values with the read revision and preserves drafts on a conflict", async () => {
|
||||
page(); await waitFor(() => expect(limit().disabled).toBe(false));
|
||||
fireEvent.change(limit(), { target: { value: "2" } });
|
||||
fireEvent.change(cooldown(), { target: { value: "30" } });
|
||||
calls.setWakePolicy.mockRejectedValueOnce({ status: 409, code: "conflict", message: "changed" });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save and apply" }));
|
||||
await screen.findByRole("alert");
|
||||
expect(limit().value).toBe("2");
|
||||
expect(screen.getByRole("button", { name: "Reload" })).toHaveProperty("disabled", true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save and apply" }));
|
||||
await screen.findByText("Saved. The new policy is active without a restart.");
|
||||
expect(calls.setWakePolicy).toHaveBeenLastCalledWith({ maxRunningServers: 2, wakeCooldownSeconds: 30, revision: "initial" });
|
||||
fireEvent.change(limit(), { target: { value: "1.5" } });
|
||||
expect(screen.getByRole("button", { name: "Save and apply" })).toHaveProperty("disabled", true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Discard changes" }));
|
||||
expect(limit().value).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw, Save, Settings } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { MessageLine } from "@/components/MessageLine";
|
||||
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync, useUnsavedGuard } from "@/lib/hooks";
|
||||
import type { WakePolicySettings } from "@/lib/types";
|
||||
|
||||
export function PlatformSettingsPage() {
|
||||
const { t } = useTranslation("admin");
|
||||
const query = useAsync(api.getWakePolicy, []);
|
||||
const reauth = useReauth();
|
||||
const [saved, setSaved] = useState<WakePolicySettings | null>(null);
|
||||
const [limit, setLimit] = useState("");
|
||||
const [cooldown, setCooldown] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
const dirty = saved !== null && (limit !== String(saved.maxRunningServers) || cooldown !== String(saved.wakeCooldownSeconds));
|
||||
useUnsavedGuard(dirty);
|
||||
function load(policy: WakePolicySettings) {
|
||||
setSaved(policy);
|
||||
setLimit(String(policy.maxRunningServers));
|
||||
setCooldown(String(policy.wakeCooldownSeconds));
|
||||
}
|
||||
useEffect(() => {
|
||||
if (query.data) load(query.data);
|
||||
}, [query.data]);
|
||||
const busy = query.loading || saving;
|
||||
const valid = /^\d+$/.test(limit) && Number(limit) <= 10000 && /^\d+$/.test(cooldown) && Number(cooldown) <= 3600;
|
||||
async function save() {
|
||||
if (!saved || !dirty || !valid || busy) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const policy = await reauth.guard(() => api.setWakePolicy({ maxRunningServers: Number(limit), wakeCooldownSeconds: Number(cooldown), revision: saved.revision }));
|
||||
load(policy);
|
||||
setMessage({ kind: "success", text: t("platform_saved") });
|
||||
} catch (error) {
|
||||
if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) });
|
||||
} finally { setSaving(false); }
|
||||
}
|
||||
return <div className="space-y-6">
|
||||
<PageHeader icon={Settings} title={t("platform_title")} subtitle={t("platform_subtitle")} actions={<Button variant="outline" size="sm" disabled={dirty || busy} onClick={query.reload}><RefreshCw />{t("platform_reload")}</Button>} />
|
||||
<Card><CardHeader><CardTitle>{t("platform_wake_title")}</CardTitle></CardHeader><CardContent className="space-y-6">
|
||||
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2"><Label htmlFor="running-limit">{t("platform_limit")}</Label><Input id="running-limit" type="number" min={0} max={10000} step={1} value={limit} disabled={!saved || busy} onChange={(e) => { setLimit(e.target.value); setMessage(null); }} /><p className="text-xs leading-relaxed text-muted-foreground">{t("platform_limit_hint")}</p></div>
|
||||
<div className="space-y-2"><Label htmlFor="wake-cooldown">{t("platform_cooldown")}</Label><Input id="wake-cooldown" type="number" min={0} max={3600} step={1} value={cooldown} disabled={!saved || busy} onChange={(e) => { setCooldown(e.target.value); setMessage(null); }} /><p className="text-xs leading-relaxed text-muted-foreground">{t("platform_cooldown_hint")}</p></div>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{t("platform_scope")}</p>
|
||||
</CardContent></Card>
|
||||
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
|
||||
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{t(dirty ? "platform_unsaved" : "platform_live")}</p>
|
||||
<div className="flex gap-2">
|
||||
{dirty && <Button variant="outline" disabled={busy} onClick={() => { if (saved) load(saved); setMessage(null); }}>{t("platform_discard")}</Button>}
|
||||
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("platform_save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{reauth.dialog}
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user