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 (
|
||||
|
||||
Reference in new issue
Block a user