fix(felis): 启动失败的服可在面板重试或停止,玩家入服时直接告知启动失败
This commit is contained in:
28 files changed
+775
-75
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PHASE_COLOR, phaseColor, phaseVariant } from "@/components/PhaseBadge";
|
||||
import { PHASE_COLOR, phaseColor, phaseVariant, startFailure } from "@/components/PhaseBadge";
|
||||
import type { Phase } from "@/lib/types";
|
||||
|
||||
// The closed lifecycle set felis-api can emit — the Go source of truth is
|
||||
@@ -47,3 +47,15 @@ describe("unmodelled phase fallback", () => {
|
||||
expect(phaseVariant(future)).toBe(phaseVariant("Unknown"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("start failure", () => {
|
||||
// A Failed phase only reads as a failed start while the server still wants to
|
||||
// run; a stopped Failed server needs nothing from its owner.
|
||||
it("reads a failed start only while the server wants to run", () => {
|
||||
expect(startFailure({ phase: "Running", desiredState: "Running" })).toBeNull();
|
||||
expect(startFailure({ phase: "Failed", desiredState: "Stopped" })).toBeNull();
|
||||
expect(startFailure({ phase: "Failed" })).toBeNull();
|
||||
expect(startFailure({ phase: "Failed", desiredState: "Running" })).toBe("retrying");
|
||||
expect(startFailure({ phase: "Failed", desiredState: "Running", startGaveUp: true })).toBe("gaveUp");
|
||||
});
|
||||
});
|
||||
@@ -52,18 +52,54 @@ export function phaseVariant(phase: Phase): BadgeVariant {
|
||||
return VARIANT[phase] ?? VARIANT.Unknown;
|
||||
}
|
||||
|
||||
export function PhaseBadge({ phase }: { phase: Phase }) {
|
||||
/** MAX_AUTO_RESTARTS mirrors the operator's v1alpha1.MaxAutoRestarts: how often it
|
||||
* recreates the pod of a start that timed out before leaving it Failed. */
|
||||
export const MAX_AUTO_RESTARTS = 3;
|
||||
|
||||
export type StartFailure = "retrying" | "gaveUp";
|
||||
|
||||
/** startFailure reads how a Failed start stands from the owner detail. "retrying"
|
||||
* means the operator's next automatic attempt is coming; "gaveUp" means nothing
|
||||
* will start it again until a person does. A Failed server meant to stop, or a
|
||||
* public view without the owner detail, reads as null: there is no retry to
|
||||
* offer there, and the badge stays the plain Failed one. */
|
||||
export function startFailure(s: {
|
||||
phase?: Phase;
|
||||
desiredState?: string;
|
||||
startGaveUp?: boolean;
|
||||
}): StartFailure | null {
|
||||
if (s.phase !== "Failed" || s.desiredState !== "Running") return null;
|
||||
return s.startGaveUp ? "gaveUp" : "retrying";
|
||||
}
|
||||
|
||||
export function PhaseBadge({
|
||||
phase,
|
||||
failure = null,
|
||||
autoRestarts = 0,
|
||||
}: {
|
||||
phase: Phase;
|
||||
/** From startFailure: a Failed start between automatic retries pulses and says so. */
|
||||
failure?: StartFailure | null;
|
||||
autoRestarts?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const retrying = phase === "Failed" && failure === "retrying";
|
||||
const hint =
|
||||
phase !== "Failed" || failure === null
|
||||
? undefined
|
||||
: retrying
|
||||
? t("servers:server_failed_retrying_body", { used: autoRestarts, max: MAX_AUTO_RESTARTS })
|
||||
: t("servers:server_failed_body");
|
||||
return (
|
||||
<Badge variant={phaseVariant(phase)} className="gap-1.5 whitespace-nowrap">
|
||||
<Badge variant={phaseVariant(phase)} className="gap-1.5 whitespace-nowrap" title={hint}>
|
||||
<span
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
TRANSIENT.has(phase) && "animate-pulse",
|
||||
(TRANSIENT.has(phase) || retrying) && "animate-pulse",
|
||||
)}
|
||||
style={{ backgroundColor: phaseColor(phase) }}
|
||||
/>
|
||||
{t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}
|
||||
{retrying ? t("servers:phase_failed_retrying") : t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -103,4 +103,33 @@ describe("PowerButton", () => {
|
||||
|
||||
expect(wake).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("offers a failed server a retry, which is the wake", async () => {
|
||||
wake.mockResolvedValue(undefined);
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live={false} failed onChanged={onChanged} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") }));
|
||||
|
||||
expect(wake).toHaveBeenCalledWith("lobby");
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
expect(onChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops a failed server without asking, and only the pressed button spins", async () => {
|
||||
let resolve!: () => void;
|
||||
stop.mockReturnValue(new Promise<void>((r) => (resolve = r)));
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live={false} failed playerCountUnknown onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
|
||||
expect(stop).toHaveBeenCalledWith("lobby");
|
||||
expect(screen.queryByText(t("servers:stop_confirm_unknown"))).toBeNull();
|
||||
expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true);
|
||||
expect(screen.getByRole("button", { name: t("servers:retry_start") })).toHaveProperty("disabled", true);
|
||||
resolve();
|
||||
await vi.waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Loader2, Play, Square } from "lucide-react";
|
||||
import { Loader2, Play, RotateCcw, Square } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
@@ -9,6 +9,8 @@ interface Props {
|
||||
name: string;
|
||||
/** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */
|
||||
live: boolean;
|
||||
/** Its start Failed while meant to run: offer a retry and a stop. */
|
||||
failed?: boolean;
|
||||
playersOnline?: number;
|
||||
/** The operator cannot read the player count, so players may be online. */
|
||||
playerCountUnknown?: boolean;
|
||||
@@ -21,10 +23,14 @@ interface Props {
|
||||
// PowerButton starts or stops one server. It is busy while the call runs (no
|
||||
// double send), shows why a call was refused (quota, cooldown, a phase that
|
||||
// moved on), and asks before a stop that would disconnect players: the count
|
||||
// is in the question, and an unreadable count asks too.
|
||||
// is in the question, and an unreadable count asks too. A server whose start
|
||||
// failed gets both ways out: retry (the wake, which felis-api turns into a fresh
|
||||
// start) and stop. Nobody is on a server that never came up, so that stop does
|
||||
// not ask.
|
||||
export function PowerButton({
|
||||
name,
|
||||
live,
|
||||
failed = false,
|
||||
playersOnline,
|
||||
playerCountUnknown,
|
||||
onChanged,
|
||||
@@ -32,13 +38,13 @@ export function PowerButton({
|
||||
className,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busy, setBusy] = useState<"wake" | "stop" | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(kind: "wake" | "stop") {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
await (kind === "wake" ? api.wake(name) : api.stop(name));
|
||||
@@ -47,20 +53,36 @@ export function PowerButton({
|
||||
} catch (e) {
|
||||
setError(humanizeError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const players = playersOnline ?? 0;
|
||||
const askFirst = players > 0 || playerCountUnknown === true;
|
||||
const spinner = <Loader2 className="animate-spin" />;
|
||||
const stopButton = (variant: "destructive" | "outline", onClick: () => void) => (
|
||||
<Button size={size} variant={variant} onClick={onClick} disabled={busy !== null}>
|
||||
{busy === "stop" ? spinner : <Square />}
|
||||
{busy === "stop" ? t("stopping") : t("stop")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
let control;
|
||||
if (!live) {
|
||||
if (failed) {
|
||||
control = (
|
||||
<Button size={size} onClick={() => void run("wake")} disabled={busy}>
|
||||
{busy ? spinner : <Play />}
|
||||
{busy ? t("waking") : t("wake")}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
|
||||
{busy === "wake" ? spinner : <RotateCcw />}
|
||||
{busy === "wake" ? t("retrying_start") : t("retry_start")}
|
||||
</Button>
|
||||
{stopButton("outline", () => void run("stop"))}
|
||||
</div>
|
||||
);
|
||||
} else if (!live) {
|
||||
control = (
|
||||
<Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
|
||||
{busy === "wake" ? spinner : <Play />}
|
||||
{busy === "wake" ? t("waking") : t("wake")}
|
||||
</Button>
|
||||
);
|
||||
} else if (confirming) {
|
||||
@@ -71,27 +93,14 @@ export function PowerButton({
|
||||
? t("stop_confirm_players", { count: players })
|
||||
: t("stop_confirm_unknown")}
|
||||
</span>
|
||||
<Button size={size} variant="ghost" onClick={() => setConfirming(false)} disabled={busy}>
|
||||
<Button size={size} variant="ghost" onClick={() => setConfirming(false)} disabled={busy !== null}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button size={size} variant="destructive" onClick={() => void run("stop")} disabled={busy}>
|
||||
{busy ? spinner : <Square />}
|
||||
{busy ? t("stopping") : t("stop")}
|
||||
</Button>
|
||||
{stopButton("destructive", () => void run("stop"))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Button
|
||||
size={size}
|
||||
variant="destructive"
|
||||
onClick={() => (askFirst ? setConfirming(true) : void run("stop"))}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? spinner : <Square />}
|
||||
{busy ? t("stopping") : t("stop")}
|
||||
</Button>
|
||||
);
|
||||
control = stopButton("destructive", () => (askFirst ? setConfirming(true) : void run("stop")));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"phase_starting": "Starting",
|
||||
"phase_stopping": "Stopping",
|
||||
"phase_stopped": "Stopped",
|
||||
"phase_failed": "Failed",
|
||||
"phase_failed": "Failed to start",
|
||||
"phase_failed_retrying": "Timed out · retrying",
|
||||
"phase_unknown": "Unknown",
|
||||
"online": "online",
|
||||
"console": "Console",
|
||||
@@ -23,12 +24,16 @@
|
||||
"stopping": "Stopping…",
|
||||
"wake": "Wake",
|
||||
"waking": "Waking…",
|
||||
"retry_start": "Retry start",
|
||||
"retrying_start": "Retrying…",
|
||||
"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.",
|
||||
"server_failed_body": "Its automatic retries are spent. The console holds the log of the last attempt; fix the cause, then press Retry start.",
|
||||
"server_failed_retrying_title": "Start timed out — retrying",
|
||||
"server_failed_retrying_body": "The operator recreates the pod and tries again ({{used}} of {{max}} retries used). Press Retry start to go now instead.",
|
||||
"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",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"phase_stopping": "停止中",
|
||||
"phase_stopped": "已停止",
|
||||
"phase_failed": "启动失败",
|
||||
"phase_failed_retrying": "启动超时·重试中",
|
||||
"phase_unknown": "未知",
|
||||
"online": "人在线",
|
||||
"console": "控制台",
|
||||
@@ -23,12 +24,16 @@
|
||||
"stopping": "停止中…",
|
||||
"wake": "启动",
|
||||
"waking": "启动中…",
|
||||
"retry_start": "重试启动",
|
||||
"retrying_start": "重试中…",
|
||||
"server_asleep_title": "服务器已休眠",
|
||||
"server_asleep_body": "启动后将拉起 Pod,实时控制台会自动连接。",
|
||||
"server_shutting_down_title": "服务器正在关闭",
|
||||
"server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。",
|
||||
"server_failed_title": "服务器启动失败",
|
||||
"server_failed_body": "当前无运行中的 Pod,无法获取日志流。重新启动后控制台将自动连接。",
|
||||
"server_failed_body": "自动重试已用完。控制台里是最后一次启动的日志,找到原因修好后点「重试启动」。",
|
||||
"server_failed_retrying_title": "启动超时,正在自动重试",
|
||||
"server_failed_retrying_body": "operator 会重建 Pod 再试(已重试 {{used}}/{{max}} 次)。不想等可以点「重试启动」立即再来。",
|
||||
"console_offline_title": "控制台未连接",
|
||||
"console_offline_body": "服务器运行后,实时控制台将自动连接。",
|
||||
"my_servers_breadcrumb": "我的服务器",
|
||||
|
||||
@@ -167,7 +167,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Domain-autostart wake driven by velocity for a joining player (spec §9.1).
|
||||
* @description velocity holds no web Principal, so it drives the wake lever with its service token, identifying the player by online-mode UUID. Gated by the server's autostartPolicy and the per-server wake cooldown.
|
||||
* @description velocity holds no web Principal, so it drives the wake lever with its service token, identifying the player by online-mode UUID. Gated by the server's autostartPolicy and the per-server wake cooldown. A server whose start failed with its automatic retries spent answers 409 start_failed: a join never resets the retry budget, so velocity queues no one for it.
|
||||
*/
|
||||
post: operations["internalWake"];
|
||||
delete?: never;
|
||||
@@ -419,7 +419,10 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Wake your own server. */
|
||||
/**
|
||||
* Wake your own server.
|
||||
* @description On a server whose start Failed (phase Failed, desiredState Running) this is a retry: the operator recreates the pod and starts it over with a fresh automatic-restart budget. It is audited as retry_start.
|
||||
*/
|
||||
post: operations["wake"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -2398,6 +2401,13 @@ export interface components {
|
||||
autostartPolicy?: "ownerOnly" | "public" | "allowlist";
|
||||
/** @description Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players. */
|
||||
playerCountUnknown?: boolean;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Owned rows only. How often the operator recreated the pod of this start after it timed out.
|
||||
*/
|
||||
autoRestarts?: number;
|
||||
/** @description Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over. */
|
||||
startGaveUp?: boolean;
|
||||
};
|
||||
/** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
|
||||
BackupView: {
|
||||
@@ -3148,7 +3158,7 @@ export interface operations {
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
/** @description A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started. */
|
||||
/** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
|
||||
@@ -36,6 +36,10 @@ export interface MyServerView {
|
||||
autostartPolicy?: AutostartPolicy;
|
||||
/** True while the operator cannot read the player count; a stop may drop players. */
|
||||
playerCountUnknown?: boolean;
|
||||
/** How often the operator recreated the pod of this start after it timed out. */
|
||||
autoRestarts?: number;
|
||||
/** True for a Failed server no automatic retry will bring up. */
|
||||
startGaveUp?: boolean;
|
||||
}
|
||||
|
||||
/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServerConsole } from "./ServerConsole";
|
||||
|
||||
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn() }));
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
useTier: () => ({
|
||||
loading: false,
|
||||
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
|
||||
isAdmin: true,
|
||||
isOwner: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/config", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/config")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gamePort: 25570 }),
|
||||
};
|
||||
});
|
||||
vi.mock("@/lib/api", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
// The live log is a WebSocket; here it only matters whether the page shows it.
|
||||
vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="log-stream" /> }));
|
||||
|
||||
beforeEach(() => {
|
||||
calls.status.mockReset();
|
||||
calls.myServers.mockReset();
|
||||
calls.myServers.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function renderConsole() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/servers/survival"]}>
|
||||
<Routes>
|
||||
<Route path="/servers/:name" element={<ServerConsole />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function status(over: Record<string, unknown>) {
|
||||
return { name: "survival", subdomain: "survival", phase: "Failed", ready: false, playersOnline: 0, playersMax: 20, ...over };
|
||||
}
|
||||
|
||||
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 }));
|
||||
renderConsole();
|
||||
|
||||
const notice = await screen.findByRole("status", { name: "Server failed to start" });
|
||||
expect(within(notice).getByText(/automatic retries are spent/)).toBeTruthy();
|
||||
expect(await screen.findByTestId("log-stream")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Retry start/ })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("counts the retries used while the operator is still retrying", async () => {
|
||||
calls.status.mockResolvedValue(status({ desiredState: "Running", autoRestarts: 1 }));
|
||||
renderConsole();
|
||||
|
||||
const notice = await screen.findByRole("status", { name: "Start timed out — retrying" });
|
||||
expect(within(notice).getByText(/1 of 3 retries used/)).toBeTruthy();
|
||||
expect(screen.getByText("Timed out · retrying")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks nothing of the owner once a failed server is being stopped", async () => {
|
||||
calls.status.mockResolvedValue(status({ desiredState: "Stopped" }));
|
||||
renderConsole();
|
||||
|
||||
expect(await screen.findByTestId("log-stream")).toBeTruthy();
|
||||
expect(screen.queryByRole("status", { name: /failed to start|timed out/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Retry start/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react";
|
||||
import { useState, useRef, useCallback, useId, useLayoutEffect, type KeyboardEvent } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
import { MAX_AUTO_RESTARTS, PhaseBadge, startFailure, type StartFailure } from "@/components/PhaseBadge";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { LogConsole } from "@/components/LogConsole";
|
||||
import { Loading, ErrorState } from "@/components/States";
|
||||
@@ -39,12 +39,6 @@ function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; b
|
||||
title: t("server_shutting_down_title"),
|
||||
body: t("server_shutting_down_body"),
|
||||
};
|
||||
case "Failed":
|
||||
return {
|
||||
icon: ShieldAlert,
|
||||
title: t("server_failed_title"),
|
||||
body: t("server_failed_body"),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: HelpCircle,
|
||||
@@ -67,6 +61,34 @@ function NotStreaming({ phase }: { phase: Phase }) {
|
||||
);
|
||||
}
|
||||
|
||||
// FailedStartNotice heads the console of a server whose start failed: whether the
|
||||
// operator will try again on its own, and what the owner can do. The log below it
|
||||
// is the attempt that failed, which is what they need to find the cause.
|
||||
function FailedStartNotice({ failure, autoRestarts }: { failure: StartFailure; autoRestarts: number }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const retrying = failure === "retrying";
|
||||
const titleId = useId();
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-labelledby={titleId}
|
||||
className="flex items-start gap-3 border-b border-zinc-800 bg-red-950/40 px-4 py-3 text-sm text-zinc-300"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
|
||||
<div className="space-y-0.5">
|
||||
<p id={titleId} className="font-medium text-zinc-100">
|
||||
{retrying ? t("server_failed_retrying_title") : t("server_failed_title")}
|
||||
</p>
|
||||
<p>
|
||||
{retrying
|
||||
? t("server_failed_retrying_body", { used: autoRestarts, max: MAX_AUTO_RESTARTS })
|
||||
: t("server_failed_body")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function loadHistory(name: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY(name));
|
||||
@@ -206,8 +228,11 @@ export function ServerConsole() {
|
||||
// during BOTH Starting and Running — the operator marks Starting once the pod
|
||||
// is up but RCON is not yet reachable (it only flips to Running after RCON
|
||||
// readiness). Boot logs flow precisely in that Starting window, which is when a
|
||||
// read most wants them, so the gate streams for both, not Running alone.
|
||||
const streamable = data?.phase === "Running" || data?.phase === "Starting";
|
||||
// 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 failure = data ? startFailure(data) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:h-[calc(100vh-3.5rem)] lg:min-h-[35rem] gap-4 min-h-0">
|
||||
@@ -227,10 +252,11 @@ export function ServerConsole() {
|
||||
subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<PhaseBadge phase={data.phase} />
|
||||
<PhaseBadge phase={data.phase} failure={failure} autoRestarts={data.autoRestarts} />
|
||||
<PowerButton
|
||||
name={name}
|
||||
live={streamable || data.phase === "Stopping"}
|
||||
live={data.phase === "Running" || data.phase === "Starting" || data.phase === "Stopping"}
|
||||
failed={failure !== null}
|
||||
playersOnline={data.playersOnline}
|
||||
playerCountUnknown={data.playerCountUnknown}
|
||||
onChanged={reload}
|
||||
@@ -251,6 +277,9 @@ export function ServerConsole() {
|
||||
</div>
|
||||
) : cfg ? (
|
||||
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
||||
{failure && (
|
||||
<FailedStartNotice failure={failure} autoRestarts={data.autoRestarts ?? 0} />
|
||||
)}
|
||||
<LogConsole
|
||||
key={name}
|
||||
url={consoleStreamURL(cfg.apiBase, name)}
|
||||
|
||||
@@ -139,3 +139,27 @@ describe("ServersPage addresses", () => {
|
||||
expect(survival.getByRole("button", { name: "Copy address survival.example.test:25570" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServersPage failed starts", () => {
|
||||
it("offers a failed start a retry and a stop, and tells retrying from given up", async () => {
|
||||
calls.fleet.mockResolvedValue([
|
||||
row("broken", { phase: "Failed", desiredState: "Running", startGaveUp: true, autoRestarts: 3, owned: true }),
|
||||
row("flaky", { phase: "Failed", desiredState: "Running", autoRestarts: 1, owned: true }),
|
||||
]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ServersPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const broken = await tableRow("broken");
|
||||
expect(broken.getByText("Failed to start")).toBeTruthy();
|
||||
expect(broken.getByRole("button", { name: /Retry start/ })).toBeTruthy();
|
||||
expect(broken.getByRole("button", { name: /Stop/ })).toBeTruthy();
|
||||
expect(broken.queryByRole("button", { name: /Wake/ })).toBeNull();
|
||||
|
||||
const flaky = await tableRow("flaky");
|
||||
expect(flaky.getByText("Timed out · retrying")).toBeTruthy();
|
||||
expect(flaky.getByRole("button", { name: /Retry start/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { PhaseBadge, PHASE_KEY, PHASE_COLOR } from "@/components/PhaseBadge";
|
||||
import { PhaseBadge, PHASE_KEY, PHASE_COLOR, startFailure } from "@/components/PhaseBadge";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
@@ -81,6 +81,8 @@ interface UnifiedServer {
|
||||
playersOnline: number;
|
||||
playersMax: number;
|
||||
playerCountUnknown?: boolean;
|
||||
autoRestarts?: number;
|
||||
startGaveUp?: boolean;
|
||||
owner?: string;
|
||||
endpointAddress?: string | null;
|
||||
claimable?: boolean;
|
||||
@@ -133,6 +135,8 @@ export function ServersPage() {
|
||||
playersOnline: s.playersOnline,
|
||||
playersMax: s.playersMax,
|
||||
playerCountUnknown: s.playerCountUnknown,
|
||||
autoRestarts: s.autoRestarts,
|
||||
startGaveUp: s.startGaveUp,
|
||||
owner: s.owner,
|
||||
endpointAddress: s.endpointAddress,
|
||||
claimable: s.claimable,
|
||||
@@ -152,6 +156,8 @@ export function ServersPage() {
|
||||
playersOnline: s.playersOnline,
|
||||
playersMax: s.playersMax,
|
||||
playerCountUnknown: s.playerCountUnknown,
|
||||
autoRestarts: s.autoRestarts,
|
||||
startGaveUp: s.startGaveUp,
|
||||
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
|
||||
claimable: s.claimable,
|
||||
owned: s.owned,
|
||||
@@ -509,6 +515,7 @@ function ServerActions({
|
||||
<PowerButton
|
||||
name={server.name}
|
||||
live={live}
|
||||
failed={startFailure(server) !== null}
|
||||
playersOnline={server.playersOnline}
|
||||
playerCountUnknown={server.playerCountUnknown}
|
||||
onChanged={onChanged}
|
||||
@@ -610,7 +617,7 @@ function ServerRow({
|
||||
<td className="px-4 py-3 align-middle text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerName server={server} />
|
||||
<PhaseBadge phase={server.phase} />
|
||||
<PhaseBadge phase={server.phase} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
|
||||
</div>
|
||||
{address && <CopyAddress address={address} className="md:max-w-[16rem]" />}
|
||||
</td>
|
||||
@@ -669,7 +676,7 @@ function ServerMobileCard({
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<ServerName server={server} />
|
||||
</div>
|
||||
<PhaseBadge phase={server.phase} />
|
||||
<PhaseBadge phase={server.phase} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
|
||||
</div>
|
||||
{/* Its own line: the join address is what a player came for, so it
|
||||
gets the card's full width rather than sharing it with the badge. */}
|
||||
|
||||
Reference in new issue
Block a user