fix(panel): 控制台、玩家页、权限页、文件页和仪表盘自动刷新状态,唤醒或停止后立即显示进行中
This commit is contained in:
24 files changed
+971
-116
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PHASE_COLOR, phaseColor, phaseVariant, startFailure } from "@/components/PhaseBadge";
|
||||
import { PHASE_COLOR, pendingPower, phaseColor, phaseVariant, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import type { Phase } from "@/lib/types";
|
||||
|
||||
// The closed lifecycle set felis-api can emit — the Go source of truth is
|
||||
@@ -59,3 +59,34 @@ describe("start failure", () => {
|
||||
expect(startFailure({ phase: "Failed", desiredState: "Running", startGaveUp: true })).toBe("gaveUp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a server asked to move", () => {
|
||||
// desiredState flips the moment a wake or stop is accepted; the phase follows
|
||||
// once the operator acts. Every pair the two can be in:
|
||||
const cases: [Phase, "Running" | "Stopped" | undefined, "start" | "stop" | null, Phase][] = [
|
||||
["Stopped", "Running", "start", "Starting"],
|
||||
["Unknown", "Running", "start", "Starting"],
|
||||
// Woken while going down: it comes back up once it is down.
|
||||
["Stopping", "Running", "start", "Starting"],
|
||||
["Starting", "Running", null, "Starting"],
|
||||
["Running", "Running", null, "Running"],
|
||||
// A failed start keeps its own badge and retry.
|
||||
["Failed", "Running", null, "Failed"],
|
||||
["Running", "Stopped", "stop", "Stopping"],
|
||||
["Starting", "Stopped", "stop", "Stopping"],
|
||||
["Stopping", "Stopped", null, "Stopping"],
|
||||
["Stopped", "Stopped", null, "Stopped"],
|
||||
["Failed", "Stopped", null, "Failed"],
|
||||
["Unknown", "Stopped", null, "Unknown"],
|
||||
// A view without desiredState shows the phase as it is.
|
||||
["Stopped", undefined, null, "Stopped"],
|
||||
["Running", undefined, null, "Running"],
|
||||
];
|
||||
|
||||
for (const [phase, desiredState, pending, shown] of cases) {
|
||||
it(`${phase} asked ${desiredState ?? "(unknown)"} is pending ${pending} and shows ${shown}`, () => {
|
||||
expect(pendingPower({ phase, desiredState })).toBe(pending);
|
||||
expect(shownPhase({ phase, desiredState })).toBe(shown);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -72,6 +72,29 @@ export function startFailure(s: {
|
||||
return s.startGaveUp ? "gaveUp" : "retrying";
|
||||
}
|
||||
|
||||
/** PendingPower is a server asked to move that has not got there yet: asked to
|
||||
* run with no pod up ("start"), or asked to stop while still up ("stop"). */
|
||||
export type PendingPower = "start" | "stop";
|
||||
|
||||
/** pendingPower compares the phase with desiredState, the lever a wake or stop
|
||||
* sets at once; the phase follows once the operator acts. Without desiredState
|
||||
* (a public view) nothing reads as pending. */
|
||||
export function pendingPower(s: { phase: Phase; desiredState?: string }): PendingPower | null {
|
||||
if (s.desiredState === "Running" && (s.phase === "Stopped" || s.phase === "Stopping" || s.phase === "Unknown")) {
|
||||
return "start";
|
||||
}
|
||||
if (s.desiredState === "Stopped" && (s.phase === "Running" || s.phase === "Starting")) return "stop";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** shownPhase is the phase a person should see: one already asked to start reads
|
||||
* Starting, one asked to stop reads Stopping, so a wake or stop shows at once
|
||||
* instead of the old phase lingering until the operator gets to it. */
|
||||
export function shownPhase(s: { phase: Phase; desiredState?: string }): Phase {
|
||||
const pending = pendingPower(s);
|
||||
return pending === "start" ? "Starting" : pending === "stop" ? "Stopping" : s.phase;
|
||||
}
|
||||
|
||||
export function PhaseBadge({
|
||||
phase,
|
||||
failure = null,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { PowerButton } from "./PowerButton";
|
||||
import type { Phase } from "@/lib/types";
|
||||
import { PowerButton, SUBMITTED_HOLD_MS, SUBMITTED_RECHECK_MS } from "./PowerButton";
|
||||
|
||||
const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
@@ -18,11 +19,16 @@ beforeEach(() => {
|
||||
stop.mockReset();
|
||||
});
|
||||
|
||||
// A fake-timer test that fails must not leave the clock frozen for the rest.
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("PowerButton", () => {
|
||||
it("wakes a stopped server and tells the parent", async () => {
|
||||
wake.mockResolvedValue(undefined);
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
|
||||
render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||
|
||||
@@ -33,7 +39,7 @@ describe("PowerButton", () => {
|
||||
it("stays on the page with the reason when the wake is refused", async () => {
|
||||
wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live={false} onChanged={onChanged} />);
|
||||
render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||
|
||||
@@ -45,7 +51,7 @@ describe("PowerButton", () => {
|
||||
it("stops an empty server without asking", async () => {
|
||||
stop.mockResolvedValue(undefined);
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live playersOnline={0} onChanged={onChanged} />);
|
||||
render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
|
||||
@@ -54,7 +60,7 @@ describe("PowerButton", () => {
|
||||
});
|
||||
|
||||
it("asks before disconnecting players, and cancel sends nothing", async () => {
|
||||
render(<PowerButton name="lobby" live playersOnline={3} onChanged={vi.fn()} />);
|
||||
render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={3} onChanged={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy();
|
||||
@@ -67,7 +73,7 @@ describe("PowerButton", () => {
|
||||
it("asks when the player count cannot be read", async () => {
|
||||
stop.mockResolvedValue(undefined);
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live playersOnline={0} playerCountUnknown onChanged={onChanged} />);
|
||||
render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} playerCountUnknown onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
@@ -81,7 +87,7 @@ describe("PowerButton", () => {
|
||||
it("keeps the confirmation open with the reason when the stop fails", async () => {
|
||||
stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
|
||||
const onChanged = vi.fn();
|
||||
render(<PowerButton name="lobby" live playersOnline={2} onChanged={onChanged} />);
|
||||
render(<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={2} onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
@@ -94,7 +100,7 @@ describe("PowerButton", () => {
|
||||
it("sends one call however fast it is clicked", async () => {
|
||||
let resolve!: () => void;
|
||||
wake.mockReturnValue(new Promise<void>((r) => (resolve = r)));
|
||||
render(<PowerButton name="lobby" live={false} onChanged={vi.fn()} />);
|
||||
render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: t("servers:wake") });
|
||||
await userEvent.click(button);
|
||||
@@ -107,7 +113,7 @@ describe("PowerButton", () => {
|
||||
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} />);
|
||||
render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={onChanged} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") }));
|
||||
@@ -121,7 +127,7 @@ describe("PowerButton", () => {
|
||||
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} />);
|
||||
render(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed playerCountUnknown onChanged={onChanged} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
|
||||
@@ -133,3 +139,119 @@ describe("PowerButton", () => {
|
||||
await vi.waitFor(() => expect(onChanged).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
|
||||
describe("PowerButton after an accepted call", () => {
|
||||
const ok = () => wake.mockResolvedValue(undefined);
|
||||
|
||||
it("keeps the wake in progress until the view shows it, then offers the stop", async () => {
|
||||
ok();
|
||||
const { rerender } = render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||
|
||||
// The parent's first reread can still carry the old status.
|
||||
rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true);
|
||||
expect(screen.queryByRole("button", { name: t("servers:wake") })).toBeNull();
|
||||
|
||||
rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Running" onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: t("servers:stop") })).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("asks the parent to reread until the view catches up, and no longer after", async () => {
|
||||
vi.useFakeTimers();
|
||||
ok();
|
||||
const onChanged = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={onChanged} />,
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||
});
|
||||
const afterClick = onChanged.mock.calls.length;
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 2));
|
||||
expect(onChanged.mock.calls.length).toBe(afterClick + 2);
|
||||
|
||||
rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={onChanged} />);
|
||||
const caughtUp = onChanged.mock.calls.length;
|
||||
await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_RECHECK_MS * 3));
|
||||
expect(onChanged.mock.calls.length).toBe(caughtUp);
|
||||
});
|
||||
|
||||
it("gives the button back when the view never catches up", async () => {
|
||||
vi.useFakeTimers();
|
||||
ok();
|
||||
render(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
|
||||
});
|
||||
expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy();
|
||||
|
||||
await act(() => vi.advanceTimersByTimeAsync(SUBMITTED_HOLD_MS - 1));
|
||||
expect(screen.getByRole("button", { name: t("servers:waking") })).toBeTruthy();
|
||||
await act(() => vi.advanceTimersByTimeAsync(1));
|
||||
expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("keeps a stop in progress until the server is down", async () => {
|
||||
stop.mockResolvedValue(undefined);
|
||||
const { rerender } = render(
|
||||
<PowerButton name="lobby" phase="Running" desiredState="Running" playersOnline={0} onChanged={vi.fn()} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
|
||||
expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true);
|
||||
|
||||
rerender(<PowerButton name="lobby" phase="Running" desiredState="Stopped" onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: t("servers:stopping") })).toHaveProperty("disabled", true);
|
||||
rerender(<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("keeps a retry in progress while the view still shows the failure", async () => {
|
||||
ok();
|
||||
const { rerender } = render(
|
||||
<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retry_start") }));
|
||||
|
||||
rerender(<PowerButton name="lobby" phase="Failed" desiredState="Running" failed onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: t("servers:waking") })).toHaveProperty("disabled", true);
|
||||
expect(screen.queryByRole("button", { name: t("servers:retry_start") })).toBeNull();
|
||||
|
||||
rerender(<PowerButton name="lobby" phase="Starting" desiredState="Running" onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: t("servers:stop") })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PowerButton on a server already moving", () => {
|
||||
const cases: {
|
||||
phase: Phase;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
shows: string;
|
||||
disabled: boolean;
|
||||
}[] = [
|
||||
{ phase: "Stopping", shows: "servers:stopping", disabled: true },
|
||||
{ phase: "Stopping", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
|
||||
{ phase: "Running", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
|
||||
{ phase: "Starting", desiredState: "Stopped", shows: "servers:stopping", disabled: true },
|
||||
// Woken while going down: it comes back up once it is down.
|
||||
{ phase: "Stopping", desiredState: "Running", shows: "servers:waking", disabled: true },
|
||||
// Asked to run, no pod yet: Stop is the way out if it never comes up.
|
||||
{ phase: "Stopped", desiredState: "Running", shows: "servers:stop", disabled: false },
|
||||
{ phase: "Unknown", desiredState: "Running", shows: "servers:stop", disabled: false },
|
||||
{ phase: "Starting", desiredState: "Running", shows: "servers:stop", disabled: false },
|
||||
// Without desiredState the phase alone decides.
|
||||
{ phase: "Starting", shows: "servers:stop", disabled: false },
|
||||
{ phase: "Unknown", shows: "servers:wake", disabled: false },
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
it(`${c.phase} asked ${c.desiredState ?? "(unknown)"} offers ${c.shows}`, () => {
|
||||
render(<PowerButton name="lobby" phase={c.phase} desiredState={c.desiredState} onChanged={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0].textContent).toBe(t(c.shows));
|
||||
expect(buttons[0]).toHaveProperty("disabled", c.disabled);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,14 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Play, RotateCcw, Square } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { pendingPower } from "@/components/PhaseBadge";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import type { Phase } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** How long a sent wake or stop shows as in progress when the view never
|
||||
* reflects it (the parent stopped polling, or someone reversed it at once). */
|
||||
export const SUBMITTED_HOLD_MS = 20_000;
|
||||
|
||||
/** How often a sent call asks the parent to refetch until its view shows it. */
|
||||
export const SUBMITTED_RECHECK_MS = 2_000;
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
/** A pod is up or on its way (Starting/Running/Stopping): offer Stop. */
|
||||
live: boolean;
|
||||
phase: Phase;
|
||||
/** What the server was last asked to be. Absent in a view without it; the
|
||||
* phase alone then decides. */
|
||||
desiredState?: "Running" | "Stopped";
|
||||
/** Its start Failed while meant to run: offer a retry and a stop. */
|
||||
failed?: boolean;
|
||||
playersOnline?: number;
|
||||
@@ -20,6 +31,10 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function isUp(phase: Phase): boolean {
|
||||
return phase === "Running" || phase === "Starting";
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -27,9 +42,17 @@ interface Props {
|
||||
// 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.
|
||||
//
|
||||
// An accepted call keeps its spinner, and keeps asking the parent to reread,
|
||||
// until the parent's view shows the server asked to move (the list and the
|
||||
// console read a cache that lags the write by a moment), so nobody presses Wake
|
||||
// again into a 429. A server on its way down offers nothing until it is down;
|
||||
// one asked to run with no pod yet offers Stop, which is the way out when it
|
||||
// never comes up.
|
||||
export function PowerButton({
|
||||
name,
|
||||
live,
|
||||
phase,
|
||||
desiredState,
|
||||
failed = false,
|
||||
playersOnline,
|
||||
playerCountUnknown,
|
||||
@@ -39,8 +62,34 @@ export function PowerButton({
|
||||
}: Props) {
|
||||
const { t } = useTranslation("servers");
|
||||
const [busy, setBusy] = useState<"wake" | "stop" | null>(null);
|
||||
const [submitted, setSubmitted] = useState<"wake" | "stop" | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const changed = useRef(onChanged);
|
||||
useEffect(() => {
|
||||
changed.current = onChanged;
|
||||
}, [onChanged]);
|
||||
|
||||
const pending = pendingPower({ phase, desiredState });
|
||||
const on = desiredState ? desiredState === "Running" : isUp(phase);
|
||||
|
||||
// A sent call is done once the view shows the lever moved: a wake when the
|
||||
// server is meant to run (a retry once it is no longer Failed), a stop when it
|
||||
// is meant to stop. The hold ends it anyway if the view never gets there.
|
||||
const caughtUp = submitted === "wake" ? on && !failed : submitted === "stop" ? !on : true;
|
||||
useEffect(() => {
|
||||
if (submitted === null) return;
|
||||
if (caughtUp) {
|
||||
setSubmitted(null);
|
||||
return;
|
||||
}
|
||||
const recheck = window.setInterval(() => changed.current(), SUBMITTED_RECHECK_MS);
|
||||
const hold = window.setTimeout(() => setSubmitted(null), SUBMITTED_HOLD_MS);
|
||||
return () => {
|
||||
window.clearInterval(recheck);
|
||||
window.clearTimeout(hold);
|
||||
};
|
||||
}, [submitted, caughtUp]);
|
||||
|
||||
async function run(kind: "wake" | "stop") {
|
||||
if (busy) return;
|
||||
@@ -49,6 +98,7 @@ export function PowerButton({
|
||||
try {
|
||||
await (kind === "wake" ? api.wake(name) : api.stop(name));
|
||||
setConfirming(false);
|
||||
setSubmitted(kind);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(humanizeError(e));
|
||||
@@ -66,9 +116,18 @@ export function PowerButton({
|
||||
{busy === "stop" ? t("stopping") : t("stop")}
|
||||
</Button>
|
||||
);
|
||||
// inProgress is a server on its way somewhere with nothing to press meanwhile.
|
||||
const inProgress = (kind: "wake" | "stop") => (
|
||||
<Button size={size} variant={kind === "stop" ? "outline" : "default"} disabled>
|
||||
{spinner}
|
||||
{kind === "wake" ? t("waking") : t("stopping")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
let control;
|
||||
if (failed) {
|
||||
if (submitted !== null) {
|
||||
control = inProgress(submitted);
|
||||
} else if (failed) {
|
||||
control = (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
|
||||
@@ -78,7 +137,12 @@ export function PowerButton({
|
||||
{stopButton("outline", () => void run("stop"))}
|
||||
</div>
|
||||
);
|
||||
} else if (!live) {
|
||||
} else if (pending === "stop" || (phase === "Stopping" && pending === null)) {
|
||||
control = inProgress("stop");
|
||||
} else if (pending === "start" && phase === "Stopping") {
|
||||
// Woken while stopping: the operator brings it back up once it is down.
|
||||
control = inProgress("wake");
|
||||
} else if (!on) {
|
||||
control = (
|
||||
<Button size={size} onClick={() => void run("wake")} disabled={busy !== null}>
|
||||
{busy === "wake" ? spinner : <Play />}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NotRunning, RefreshError } from "./States";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import type { Phase } from "@/lib/types";
|
||||
import type { StartFailure } from "./PhaseBadge";
|
||||
|
||||
const { wake, stop } = vi.hoisted(() => ({ wake: vi.fn(), stop: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, wake, stop } };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
wake.mockReset();
|
||||
stop.mockReset();
|
||||
});
|
||||
|
||||
function notRunning(
|
||||
phase: Phase,
|
||||
desiredState?: "Running" | "Stopped",
|
||||
failure: StartFailure | null = null,
|
||||
onWoken = vi.fn(),
|
||||
) {
|
||||
return (
|
||||
<NotRunning
|
||||
title="Server is asleep"
|
||||
body="Wake it to manage players."
|
||||
serverName="survival"
|
||||
phase={phase}
|
||||
desiredState={desiredState}
|
||||
failure={failure}
|
||||
autoRestarts={1}
|
||||
onWoken={onWoken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("NotRunning", () => {
|
||||
it("offers the wake for a server that is down and meant to stay down", () => {
|
||||
render(notRunning("Stopped", "Stopped"));
|
||||
expect(screen.getByText("Server is asleep")).toBeTruthy();
|
||||
expect(screen.getByText("Wake it to manage players.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Wake" })).toHaveProperty("disabled", false);
|
||||
});
|
||||
|
||||
it("says a woken server is starting and offers nothing to press", () => {
|
||||
for (const [phase, desired] of [
|
||||
["Stopped", "Running"],
|
||||
["Starting", "Running"],
|
||||
["Starting", undefined],
|
||||
] as const) {
|
||||
const { unmount } = render(notRunning(phase, desired));
|
||||
expect(screen.getByText("Server is starting"), `${phase}/${desired}`).toBeTruthy();
|
||||
expect(screen.getByText(/switches over by itself/), `${phase}/${desired}`).toBeTruthy();
|
||||
expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("says a server on its way down is shutting down", () => {
|
||||
for (const [phase, desired] of [
|
||||
["Stopping", undefined],
|
||||
["Stopping", "Stopped"],
|
||||
["Running", "Stopped"],
|
||||
] as const) {
|
||||
const { unmount } = render(notRunning(phase, desired));
|
||||
expect(screen.getByText("Server is shutting down"), `${phase}/${desired}`).toBeTruthy();
|
||||
expect(screen.queryByRole("button"), `${phase}/${desired}`).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a failed start its retry and stop", () => {
|
||||
render(notRunning("Failed", "Running", "gaveUp"));
|
||||
expect(screen.getByText("Server failed to start")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Retry start" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("counts the retries of a start the operator is still retrying", () => {
|
||||
render(notRunning("Failed", "Running", "retrying"));
|
||||
expect(screen.getByText("Start timed out — retrying")).toBeTruthy();
|
||||
expect(screen.getByText(/1 of 3 retries used/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("turns into the starting notice once the page rereads a wake sent from it", async () => {
|
||||
wake.mockResolvedValue(undefined);
|
||||
const onWoken = vi.fn();
|
||||
const { rerender } = render(notRunning("Stopped", "Stopped", null, onWoken));
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Wake" }));
|
||||
expect(wake).toHaveBeenCalledWith("survival");
|
||||
expect(onWoken).toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "Waking…" })).toHaveProperty("disabled", true);
|
||||
|
||||
rerender(notRunning("Stopped", "Running", null, onWoken));
|
||||
expect(screen.getByRole("status").textContent).toContain("Server is starting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RefreshError", () => {
|
||||
it("says the shown status is the last one read, and why the reread failed", () => {
|
||||
const err = { status: 429, code: "quota_exceeded", message: "raw" };
|
||||
render(<RefreshError error={err} />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("Couldn't refresh; what you see is the last status read.");
|
||||
expect(alert.textContent).toContain(humanizeError(err));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MAX_AUTO_RESTARTS, shownPhase, type StartFailure } from "@/components/PhaseBadge";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import type { Phase } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Loading({ label }: { label?: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -32,6 +35,26 @@ export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () =>
|
||||
);
|
||||
}
|
||||
|
||||
// RefreshError sits above a page whose last read still shows while a later one
|
||||
// failed: the page stays usable, and says the status may be out of date.
|
||||
export function RefreshError({ error, className }: { error: unknown; className?: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{t("refresh_failed")} {humanizeError(error)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
hint,
|
||||
@@ -141,23 +164,78 @@ export function NotYours({ title, body, linkTo = "/servers", linkLabel }: NotYou
|
||||
}
|
||||
|
||||
export interface NotRunningProps {
|
||||
/** What the page says about a server that is down, and why it needs it up. */
|
||||
title: string;
|
||||
body: string;
|
||||
/** The server to wake from here. */
|
||||
serverName: string;
|
||||
phase: Phase;
|
||||
desiredState?: "Running" | "Stopped";
|
||||
/** From startFailure: a Failed start gets its own copy and a retry. */
|
||||
failure?: StartFailure | null;
|
||||
autoRestarts?: number;
|
||||
/** Called once the wake was accepted, so the page refetches its status. */
|
||||
onWoken: () => void;
|
||||
}
|
||||
|
||||
export function NotRunning({ title, body, serverName, onWoken }: NotRunningProps) {
|
||||
// NotRunning stands in for a page that needs the server up. It follows where the
|
||||
// server is heading, so a wake from here reads as starting until the page's poll
|
||||
// finds it running and switches over, a server going down says so, and a failed
|
||||
// start offers the retry.
|
||||
export function NotRunning({
|
||||
title,
|
||||
body,
|
||||
serverName,
|
||||
phase,
|
||||
desiredState,
|
||||
failure = null,
|
||||
autoRestarts = 0,
|
||||
onWoken,
|
||||
}: NotRunningProps) {
|
||||
const { t } = useTranslation("servers");
|
||||
const shown = shownPhase({ phase, desiredState });
|
||||
const transitional = failure === null && (shown === "Starting" || shown === "Stopping");
|
||||
|
||||
let icon = <Moon className="h-8 w-8 text-muted-foreground/70" />;
|
||||
let copy = { title, body };
|
||||
if (failure !== null) {
|
||||
icon = <AlertTriangle className="h-8 w-8 text-destructive" />;
|
||||
copy =
|
||||
failure === "retrying"
|
||||
? {
|
||||
title: t("server_failed_retrying_title"),
|
||||
body: t("server_failed_retrying_body", { used: autoRestarts, max: MAX_AUTO_RESTARTS }),
|
||||
}
|
||||
: { title: t("server_failed_title"), body: t("server_failed_body") };
|
||||
} else if (transitional) {
|
||||
icon = <Loader2 className="h-8 w-8 animate-spin text-muted-foreground/70" />;
|
||||
copy =
|
||||
shown === "Starting"
|
||||
? { title: t("server_waking_title"), body: t("server_waking_body") }
|
||||
: { title: t("server_stopping_title"), body: t("server_stopping_body") };
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center">
|
||||
<Moon className="h-8 w-8 text-muted-foreground/70" />
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border bg-muted/20 py-16 text-center"
|
||||
>
|
||||
{icon}
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{body}</p>
|
||||
<p className="font-medium">{copy.title}</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-sm text-muted-foreground">{copy.body}</p>
|
||||
</div>
|
||||
<PowerButton name={serverName} live={false} onChanged={onWoken} className="items-center" />
|
||||
{!transitional && (
|
||||
<PowerButton
|
||||
name={serverName}
|
||||
phase={phase}
|
||||
desiredState={desiredState}
|
||||
failed={failure !== null}
|
||||
onChanged={onWoken}
|
||||
className="items-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,5 +37,6 @@
|
||||
"open_menu": "Open menu",
|
||||
"copy_address": "Copy address {{address}}",
|
||||
"address_copied": "Copied",
|
||||
"address_copy_failed": "Selected — copy it from the menu"
|
||||
"address_copy_failed": "Selected — copy it from the menu",
|
||||
"refresh_failed": "Couldn't refresh; what you see is the last status read."
|
||||
}
|
||||
@@ -30,6 +30,10 @@
|
||||
"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_waking_title": "Server is starting",
|
||||
"server_waking_body": "This usually takes a minute or two. The page switches over by itself once the server is running.",
|
||||
"server_stopping_title": "Server is shutting down",
|
||||
"server_stopping_body": "Once it is down you can wake it again from here.",
|
||||
"server_failed_title": "Server failed to start",
|
||||
"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",
|
||||
|
||||
@@ -37,5 +37,6 @@
|
||||
"open_menu": "打开菜单",
|
||||
"copy_address": "复制地址 {{address}}",
|
||||
"address_copied": "已复制",
|
||||
"address_copy_failed": "已选中,请用系统菜单复制"
|
||||
"address_copy_failed": "已选中,请用系统菜单复制",
|
||||
"refresh_failed": "刷新失败,下面是上次读到的状态。"
|
||||
}
|
||||
@@ -30,6 +30,10 @@
|
||||
"server_asleep_body": "启动后将拉起 Pod,实时控制台会自动连接。",
|
||||
"server_shutting_down_title": "服务器正在关闭",
|
||||
"server_shutting_down_body": "Pod 正在终止,实时控制台已断开。关闭完成后将进入休眠。",
|
||||
"server_waking_title": "服务器正在启动",
|
||||
"server_waking_body": "一般需要一两分钟。服务器运行后,这个页面会自动切换过来。",
|
||||
"server_stopping_title": "服务器正在关闭",
|
||||
"server_stopping_body": "关闭完成后可以在这里重新启动。",
|
||||
"server_failed_title": "服务器启动失败",
|
||||
"server_failed_body": "自动重试已用完。控制台里是最后一次启动的日志,找到原因修好后点「重试启动」。",
|
||||
"server_failed_retrying_title": "启动超时,正在自动重试",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useAsync, type AsyncOptions } from "./hooks";
|
||||
import { useAsync, usePolling, type AsyncOptions } from "./hooks";
|
||||
|
||||
// A request the test settles by hand, one per call, in call order.
|
||||
function requests() {
|
||||
@@ -100,3 +100,57 @@ describe("useAsync", () => {
|
||||
expect(h.result.current.data).toBe("server b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePolling", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value: false });
|
||||
});
|
||||
|
||||
function setHidden(hidden: boolean) {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value: hidden });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
it("reloads on every tick while the tab is in view, and at once when it comes back", () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn();
|
||||
renderHook(() => usePolling(reload, 1000));
|
||||
|
||||
vi.advanceTimersByTime(2500);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
setHidden(true);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
setHidden(false);
|
||||
expect(reload).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("follows a new interval and stops when paused or unmounted", () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn();
|
||||
const hook = renderHook(({ ms }: { ms: number | null }) => usePolling(reload, ms), {
|
||||
initialProps: { ms: 1000 as number | null },
|
||||
});
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
hook.rerender({ ms: 5000 });
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
hook.rerender({ ms: null });
|
||||
vi.advanceTimersByTime(20000);
|
||||
setHidden(false);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
|
||||
hook.rerender({ ms: 1000 });
|
||||
hook.unmount();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(reload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -112,3 +112,26 @@ export function useUnsavedGuard(dirty: boolean): void {
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [dirty]);
|
||||
}
|
||||
|
||||
/** How often a server page rereads its status: fast while it waits on the
|
||||
* server (coming up, going down, or needed up), slow while nothing is due. */
|
||||
export const STATUS_POLL_FAST_MS = 4_000;
|
||||
export const STATUS_POLL_SLOW_MS = 15_000;
|
||||
|
||||
/** usePolling re-runs `reload` every `ms` while the tab is visible, and at once
|
||||
* when the tab comes back into view, so a page watching a server follows it
|
||||
* without a manual refresh. `ms` null pauses it. */
|
||||
export function usePolling(reload: () => void, ms: number | null): void {
|
||||
useEffect(() => {
|
||||
if (ms === null) return;
|
||||
const tick = () => {
|
||||
if (!document.hidden) reload();
|
||||
};
|
||||
const id = window.setInterval(tick, ms);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [reload, ms]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
@@ -158,3 +158,41 @@ describe("Dashboard", () => {
|
||||
expect(calls.listImages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard following the fleet", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// The fleet grid names each server with its phase.
|
||||
const tile = () => screen.getByTitle(/^lobby · /).getAttribute("title");
|
||||
const phase = (p: string) => `lobby · ${i18next.t(`servers:phase_${p}`)}`;
|
||||
|
||||
it("rereads the servers on its own, and keeps the last read when a reread fails", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.myServers.mockResolvedValue([{ ...lobby, phase: "Stopped", desiredState: "Stopped", playersOnline: 0 }]);
|
||||
renderDashboard();
|
||||
await screen.findByTitle(/^lobby · /);
|
||||
expect(tile()).toBe(phase("stopped"));
|
||||
|
||||
calls.myServers.mockResolvedValue([lobby]);
|
||||
await act(() => vi.advanceTimersByTimeAsync(9_000));
|
||||
expect(calls.myServers).toHaveBeenCalledTimes(1);
|
||||
await act(() => vi.advanceTimersByTimeAsync(1_000));
|
||||
expect(calls.myServers).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() => expect(tile()).toBe(phase("running")));
|
||||
|
||||
calls.myServers.mockRejectedValue(failure("servers unavailable"));
|
||||
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("servers unavailable");
|
||||
expect(tile()).toBe(phase("running"));
|
||||
});
|
||||
|
||||
it("counts a server just woken as starting", async () => {
|
||||
calls.myServers.mockResolvedValue([{ ...lobby, phase: "Stopped", desiredState: "Running", playersOnline: 0 }]);
|
||||
renderDashboard();
|
||||
await screen.findByTitle(/^lobby · /);
|
||||
expect(tile()).toBe(phase("starting"));
|
||||
});
|
||||
});
|
||||
@@ -19,13 +19,14 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loading, ErrorState } from "@/components/States";
|
||||
import { Loading, ErrorState, RefreshError } from "@/components/States";
|
||||
import { shownPhase } from "@/components/PhaseBadge";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
import { FleetGrid } from "@/components/FleetGrid";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync, useConfig, type AsyncState } from "@/lib/hooks";
|
||||
import { useAsync, useConfig, usePolling, type AsyncState } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { lazyWithReload } from "@/lib/chunk";
|
||||
import { webglAvailable } from "@/lib/webgl";
|
||||
@@ -39,25 +40,39 @@ const VoxelFleet = lazyWithReload(() =>
|
||||
import("@/components/VoxelFleet").then((m) => ({ default: m.VoxelFleet })),
|
||||
);
|
||||
|
||||
// The dashboard follows the fleet the way the server list does.
|
||||
const SERVERS_POLL_MS = 10_000;
|
||||
|
||||
export function Dashboard() {
|
||||
const cfg = useConfig();
|
||||
const { isAdmin } = useTier();
|
||||
const servers = useAsync(() => api.myServers(), []);
|
||||
usePolling(servers.reload, SERVERS_POLL_MS);
|
||||
const link = useAsync(() => api.linkStatus(), []);
|
||||
// Only admins may list the image whitelist (a user would get a 403).
|
||||
const images = useAsync(() => (isAdmin ? api.listImages() : Promise.resolve([])), [isAdmin]);
|
||||
|
||||
const { t } = useTranslation("dashboard");
|
||||
|
||||
// A server just woken or stopped counts, colours and pulses as where it is
|
||||
// heading, the same as its badge in the list.
|
||||
const list = useMemo(
|
||||
() =>
|
||||
(servers.data ?? []).map((s) => ({
|
||||
...s,
|
||||
phase: shownPhase({ phase: s.phase ?? "Unknown", desiredState: s.desiredState }),
|
||||
})),
|
||||
[servers.data],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const list = servers.data ?? [];
|
||||
const by = (p: Phase) => list.filter((s) => s.phase === p).length;
|
||||
return {
|
||||
total: list.length,
|
||||
running: by("Running"),
|
||||
players: list.reduce((n, s) => n + (s.playersOnline ?? 0), 0),
|
||||
};
|
||||
}, [servers.data]);
|
||||
}, [list]);
|
||||
|
||||
// The page stands on the server list alone. Link status and the image
|
||||
// counts are side cards: when they fail only their card degrades, and each
|
||||
@@ -68,18 +83,21 @@ export function Dashboard() {
|
||||
|
||||
{servers.loading && !servers.data ? (
|
||||
<Loading />
|
||||
) : servers.error ? (
|
||||
) : servers.error && !servers.data ? (
|
||||
<ErrorState error={servers.error} onRetry={servers.reload} />
|
||||
) : !cfg ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<FleetView
|
||||
servers={servers.data ?? []}
|
||||
counts={counts}
|
||||
link={link}
|
||||
images={images.error ? null : images.data}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<>
|
||||
{servers.error && <RefreshError error={servers.error} className="mb-4" />}
|
||||
<FleetView
|
||||
servers={list}
|
||||
counts={counts}
|
||||
link={link}
|
||||
images={images.error ? null : images.data}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServerConsole } from "./ServerConsole";
|
||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
||||
|
||||
const calls = vi.hoisted(() => ({ status: vi.fn(), myServers: vi.fn(), listImages: vi.fn() }));
|
||||
vi.mock("@/lib/tier", () => ({
|
||||
@@ -94,3 +95,59 @@ describe("ServerConsole edit dialog", () => {
|
||||
expect((screen.getByLabelText("CPU Limit") as HTMLInputElement).value).toBe("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerConsole following the server", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const advance = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||
|
||||
it("picks up a server woken elsewhere without a reload", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
|
||||
renderConsole();
|
||||
expect(await screen.findByText("Server is asleep")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Wake" })).toBeTruthy();
|
||||
|
||||
// Nothing is due, so it rereads at the slow pace.
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
|
||||
await advance(STATUS_POLL_SLOW_MS - 1_000);
|
||||
expect(calls.status).toHaveBeenCalledTimes(1);
|
||||
await advance(1_000);
|
||||
expect(await screen.findByTestId("log-stream")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
expect(screen.queryByText("Server is asleep")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a wake at once and rereads fast until the pod is up", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Running" }));
|
||||
renderConsole();
|
||||
expect(await screen.findByText("Server is starting")).toBeTruthy();
|
||||
expect(screen.getByText("Starting")).toBeTruthy();
|
||||
expect(screen.queryByText("Stopped")).toBeNull();
|
||||
// Already asked to run: a second Wake would only be refused; Stop is the way out.
|
||||
expect(screen.queryByRole("button", { name: "Wake" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
|
||||
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running" }));
|
||||
await advance(STATUS_POLL_FAST_MS);
|
||||
expect(calls.status).toHaveBeenCalledTimes(2);
|
||||
expect(await screen.findByTestId("log-stream")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the console when a reread fails, and says the status may be old", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
|
||||
renderConsole();
|
||||
expect(await screen.findByTestId("log-stream")).toBeTruthy();
|
||||
|
||||
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());
|
||||
expect(screen.getByText(/status backend down/)).toBeTruthy();
|
||||
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,12 @@ import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archiv
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { MAX_AUTO_RESTARTS, PhaseBadge, startFailure, type StartFailure } from "@/components/PhaseBadge";
|
||||
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 } from "@/components/States";
|
||||
import { Loading, ErrorState, RefreshError } from "@/components/States";
|
||||
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
|
||||
import { useAsync, useConfig } from "@/lib/hooks";
|
||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { canManage } from "@/lib/ownership";
|
||||
import { joinAddress } from "@/lib/config";
|
||||
@@ -17,6 +17,7 @@ import { CopyAddress } from "@/components/CopyAddress";
|
||||
import type { Phase, AutostartPolicy } from "@/lib/types";
|
||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InlineError } from "@/components/MessageLine";
|
||||
|
||||
// notStreamingCopy explains why there is no live feed for a phase that has no
|
||||
@@ -24,9 +25,17 @@ import { InlineError } from "@/components/MessageLine";
|
||||
// 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 } {
|
||||
function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string; spin?: boolean } {
|
||||
const { t } = useTranslation("servers");
|
||||
switch (phase) {
|
||||
case "Starting":
|
||||
// Asked to start with no pod yet: the stream attaches once the pod is up.
|
||||
return {
|
||||
icon: Loader2,
|
||||
title: t("server_waking_title"),
|
||||
body: t("server_waking_body"),
|
||||
spin: true,
|
||||
};
|
||||
case "Stopped":
|
||||
return {
|
||||
icon: Moon,
|
||||
@@ -49,10 +58,10 @@ function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; b
|
||||
}
|
||||
|
||||
function NotStreaming({ phase }: { phase: Phase }) {
|
||||
const { icon: Icon, title, body } = useNotStreamingCopy(phase);
|
||||
const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase);
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<Icon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70" />
|
||||
<Icon className={cn("mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70", spin && "animate-spin")} />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
<p>{body}</p>
|
||||
@@ -218,6 +227,17 @@ export function ServerConsole() {
|
||||
() => api.status(name),
|
||||
[name],
|
||||
);
|
||||
// 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
|
||||
// wake or stop just sent, a pod starting, an automatic retry due), slow while
|
||||
// nothing is due, which still catches an idle stop or a wake from the lobby.
|
||||
const moving =
|
||||
!!data &&
|
||||
(data.phase === "Starting" ||
|
||||
data.phase === "Stopping" ||
|
||||
pendingPower(data) !== null ||
|
||||
startFailure(data) === "retrying");
|
||||
usePolling(reload, moving ? STATUS_POLL_FAST_MS : STATUS_POLL_SLOW_MS);
|
||||
const { data: mine } = useAsync(
|
||||
() => (isAdmin ? Promise.resolve([]) : api.myServers()),
|
||||
[isAdmin, name],
|
||||
@@ -242,20 +262,22 @@ export function ServerConsole() {
|
||||
|
||||
{loading && !data ? (
|
||||
<Loading />
|
||||
) : error ? (
|
||||
) : error && !data ? (
|
||||
<ErrorState error={error} onRetry={reload} />
|
||||
) : data ? (
|
||||
<>
|
||||
{error && <RefreshError error={error} />}
|
||||
<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={data.phase} failure={failure} autoRestarts={data.autoRestarts} />
|
||||
<PhaseBadge phase={shownPhase(data)} failure={failure} autoRestarts={data.autoRestarts} />
|
||||
<PowerButton
|
||||
name={name}
|
||||
live={data.phase === "Running" || data.phase === "Starting" || data.phase === "Stopping"}
|
||||
phase={data.phase}
|
||||
desiredState={data.desiredState}
|
||||
failed={failure !== null}
|
||||
playersOnline={data.playersOnline}
|
||||
playerCountUnknown={data.playerCountUnknown}
|
||||
@@ -273,7 +295,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={data.phase} />
|
||||
<NotStreaming phase={shownPhase(data)} />
|
||||
</div>
|
||||
) : cfg ? (
|
||||
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { ServerFiles } from "./ServerFiles";
|
||||
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), status: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
@@ -14,7 +15,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }),
|
||||
status: mocks.status,
|
||||
listServerFiles: () =>
|
||||
Promise.resolve({
|
||||
path: "",
|
||||
@@ -43,8 +44,15 @@ async function openEditor() {
|
||||
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
|
||||
}
|
||||
|
||||
const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredState: "Stopped", ready: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.writeServerFile.mockReset();
|
||||
mocks.status.mockReset();
|
||||
mocks.status.mockResolvedValue(stopped);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ServerFiles editor", () => {
|
||||
@@ -93,3 +101,29 @@ describe("ServerFiles editor", () => {
|
||||
expect(dirty.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFiles on a running server", () => {
|
||||
it("lists the files once the server has stopped, without a reload", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mocks.status.mockResolvedValue({ ...stopped, phase: "Running", desiredState: "Running", ready: true });
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/servers/lobby/files"]}>
|
||||
<Routes>
|
||||
<Route path="/servers/:name/files" element={<ServerFiles />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await screen.findByText(t("files:stopped_required_title"))).toBeTruthy();
|
||||
expect(screen.queryByText("server.properties")).toBeNull();
|
||||
|
||||
mocks.status.mockResolvedValue(stopped);
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||
expect(await screen.findByText("server.properties")).toBeTruthy();
|
||||
expect(screen.queryByText(t("files:stopped_required_title"))).toBeNull();
|
||||
|
||||
// Stopped is what it waited for: nothing more to reread.
|
||||
const reads = mocks.status.mock.calls.length;
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS * 3));
|
||||
expect(mocks.status.mock.calls.length).toBe(reads);
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
import { PhaseBadge, shownPhase } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync, useUnsavedGuard } from "@/lib/hooks";
|
||||
import { STATUS_POLL_FAST_MS, useAsync, usePolling, useUnsavedGuard } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { canManage, ownershipPending } from "@/lib/ownership";
|
||||
import { formatBytes, formatRelative } from "@/lib/format";
|
||||
@@ -137,12 +137,7 @@ export function ServerFiles() {
|
||||
|
||||
// While the server is not stopped, poll the phase so the first successful
|
||||
// stop flips the page from the notice to the listing without a manual reload.
|
||||
const reloadStatus = statusQ.reload;
|
||||
useEffect(() => {
|
||||
if (stopped) return;
|
||||
const id = setInterval(reloadStatus, 4000);
|
||||
return () => clearInterval(id);
|
||||
}, [stopped, reloadStatus]);
|
||||
usePolling(statusQ.reload, stopped ? null : STATUS_POLL_FAST_MS);
|
||||
|
||||
// Editor state. `editable` false marks a binary file (rendered read-only).
|
||||
// `sha256` is the hash the read returned: every save sends it back, so a file
|
||||
@@ -297,7 +292,7 @@ export function ServerFiles() {
|
||||
icon={FolderOpen}
|
||||
title={statusQ.data.displayName || statusQ.data.name}
|
||||
subtitle={t("title")}
|
||||
actions={<PhaseBadge phase={statusQ.data.phase} />}
|
||||
actions={<PhaseBadge phase={shownPhase(statusQ.data)} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { ServerLuckPerms } from "./ServerLuckPerms";
|
||||
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
@@ -39,6 +40,7 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
return i18next.changeLanguage("en-US");
|
||||
});
|
||||
|
||||
@@ -73,3 +75,34 @@ describe("ServerLuckPerms without LuckPerms", () => {
|
||||
expect(screen.queryByText(/success/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerLuckPerms on a server that is down", () => {
|
||||
it("switches over once the server is up, and reads who is online then", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Stopped", desiredState: "Stopped" });
|
||||
renderPage();
|
||||
expect(await screen.findByText("Server is asleep")).toBeTruthy();
|
||||
expect(calls.accessPlayers).not.toHaveBeenCalled();
|
||||
|
||||
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Running", desiredState: "Running" });
|
||||
calls.accessPlayers.mockResolvedValue({ name: "lobby", online: 1, max: 20, players: ["Alex"], output: "" });
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||
expect(await screen.findByRole("button", { name: /Alex/ })).toBeTruthy();
|
||||
expect(calls.accessPlayers).toHaveBeenCalledWith("lobby");
|
||||
expect(screen.queryByText("Server is asleep")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a server woken elsewhere as starting", async () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Stopped", desiredState: "Running" });
|
||||
renderPage();
|
||||
expect(await screen.findByText("Server is starting")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Wake" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says a server asked to stop is shutting down, before its pod is gone", async () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", displayName: "Lobby", phase: "Running", desiredState: "Stopped" });
|
||||
renderPage();
|
||||
expect(await screen.findByText("Server is shutting down")).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText("Steve")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
|
||||
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { STATUS_POLL_SLOW_MS, STATUS_POLL_FAST_MS, useAsync, usePolling } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { canManage, ownershipPending } from "@/lib/ownership";
|
||||
import type { Phase } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MC_NAME = /^[A-Za-z0-9_]{1,16}$/;
|
||||
@@ -61,6 +60,9 @@ export function ServerLuckPerms() {
|
||||
|
||||
// Server Status
|
||||
const { data, error, loading, reload } = useAsync(() => api.status(name), [name]);
|
||||
// The poll is what switches this page over once a wake lands, and back to
|
||||
// NotRunning when the server stops (idle stop, the console, a player's timeout).
|
||||
usePolling(reload, data && shownPhase(data) === "Running" ? STATUS_POLL_SLOW_MS : STATUS_POLL_FAST_MS);
|
||||
|
||||
// Ownership verification
|
||||
const {
|
||||
@@ -72,10 +74,12 @@ export function ServerLuckPerms() {
|
||||
[isAdmin, name]
|
||||
);
|
||||
|
||||
// Online Players
|
||||
// Online Players, read once the server is running: a page opened on a sleeping
|
||||
// server fills the list when the poll sees it come up.
|
||||
const running = data?.phase === "Running";
|
||||
const { data: playersData, loading: playersLoading } = useAsync(
|
||||
() => api.accessPlayers(name),
|
||||
[name]
|
||||
() => (running ? api.accessPlayers(name) : Promise.resolve(null)),
|
||||
[name, running]
|
||||
);
|
||||
const onlinePlayers = playersData?.players ?? [];
|
||||
|
||||
@@ -290,7 +294,7 @@ export function ServerLuckPerms() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
if (error && !data) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
@@ -302,14 +306,15 @@ export function ServerLuckPerms() {
|
||||
|
||||
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
|
||||
const owned = canManage(isAdmin, mine, name);
|
||||
const phase: Phase = data.phase;
|
||||
const phase = shownPhase(data);
|
||||
const failure = startFailure(data);
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
icon={Shield}
|
||||
title={data.displayName || data.name}
|
||||
subtitle={t("luckperms_desc")}
|
||||
actions={<PhaseBadge phase={phase} />}
|
||||
actions={<PhaseBadge phase={phase} failure={failure} autoRestarts={data.autoRestarts} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
);
|
||||
@@ -318,6 +323,7 @@ export function ServerLuckPerms() {
|
||||
<>
|
||||
{back}
|
||||
{header}
|
||||
{error && <RefreshError error={error} className="mb-4" />}
|
||||
{pending ? (
|
||||
<Loading />
|
||||
) : mineError ? (
|
||||
@@ -325,7 +331,16 @@ export function ServerLuckPerms() {
|
||||
) : !owned ? (
|
||||
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
||||
) : phase !== "Running" ? (
|
||||
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} serverName={name} onWoken={reload} />
|
||||
<NotRunning
|
||||
title={t("players_not_running_title")}
|
||||
body={t("players_not_running_body")}
|
||||
serverName={name}
|
||||
phase={data.phase}
|
||||
desiredState={data.desiredState}
|
||||
failure={failure}
|
||||
autoRestarts={data.autoRestarts}
|
||||
onWoken={reload}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||
{/* Left Directory Sidebar: Single card for Search + Online Players */}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { ServerPlayers } from "./ServerPlayers";
|
||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
||||
|
||||
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/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
// Each section reads over RCON on its own; here it only matters whether the page shows them.
|
||||
vi.mock("@/components/players/OnlineSection", () => ({ OnlineSection: () => <div data-testid="online" /> }));
|
||||
vi.mock("@/components/players/WhitelistSection", () => ({ WhitelistSection: () => <div /> }));
|
||||
vi.mock("@/components/players/BansSection", () => ({ BansSection: () => <div /> }));
|
||||
|
||||
const status = (over: Record<string, unknown>) => ({ name: "lobby", subdomain: "lobby", ready: false, ...over });
|
||||
|
||||
beforeEach(() => {
|
||||
calls.status.mockReset();
|
||||
calls.myServers.mockReset();
|
||||
calls.myServers.mockResolvedValue([]);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/servers/lobby/players"]}>
|
||||
<Routes>
|
||||
<Route path="/servers/:name/players" element={<ServerPlayers />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ServerPlayers following the server", () => {
|
||||
it("switches over once the server is up, and back when it stops", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
|
||||
renderPage();
|
||||
expect(await screen.findByText("Server is asleep")).toBeTruthy();
|
||||
|
||||
// Waiting on the server: the fast pace.
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||
expect(await screen.findByTestId("online")).toBeTruthy();
|
||||
|
||||
// Running: the slow pace, which still sees an idle stop.
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
|
||||
const reads = calls.status.mock.calls.length;
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||
expect(calls.status.mock.calls.length).toBe(reads);
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_SLOW_MS - STATUS_POLL_FAST_MS));
|
||||
expect(await screen.findByText("Server is asleep")).toBeTruthy();
|
||||
expect(screen.queryByTestId("online")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the page when a reread fails, and says the status may be old", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Running", ready: true }));
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("online")).toBeTruthy();
|
||||
|
||||
calls.status.mockRejectedValue({ status: 409, code: "test_failure", message: "status backend down" });
|
||||
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_SLOW_MS));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("status backend down");
|
||||
expect(screen.getByTestId("online")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerPlayers on a server asked to move", () => {
|
||||
it("says a woken server is starting and one asked to stop is shutting down", async () => {
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Running" }));
|
||||
const first = render(
|
||||
<MemoryRouter initialEntries={["/servers/lobby/players"]}>
|
||||
<Routes>
|
||||
<Route path="/servers/:name/players" element={<ServerPlayers />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(await screen.findByText("Server is starting")).toBeTruthy();
|
||||
first.unmount();
|
||||
|
||||
calls.status.mockResolvedValue(status({ phase: "Running", desiredState: "Stopped", ready: true }));
|
||||
renderPage();
|
||||
expect(await screen.findByText("Server is shutting down")).toBeTruthy();
|
||||
expect(screen.queryByTestId("online")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,16 @@ import { useParams } from "react-router-dom";
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BackLink } from "@/components/BackLink";
|
||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
|
||||
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import { Loading, ErrorState, NotYours, NotRunning, RefreshError } from "@/components/States";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { OnlineSection } from "@/components/players/OnlineSection";
|
||||
import { WhitelistSection } from "@/components/players/WhitelistSection";
|
||||
import { BansSection } from "@/components/players/BansSection";
|
||||
import { api } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import { STATUS_POLL_SLOW_MS, STATUS_POLL_FAST_MS, useAsync, usePolling } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { canManage, ownershipPending } from "@/lib/ownership";
|
||||
import type { Phase } from "@/lib/types";
|
||||
|
||||
/** ServerPlayers is the per-server player-management subpage (/servers/:name/players):
|
||||
* whitelist today, online roster and bans as they land. It owns its own gating —
|
||||
@@ -23,6 +22,9 @@ export function ServerPlayers() {
|
||||
const { t } = useTranslation("servers");
|
||||
const { isAdmin, loading: tierLoading } = useTier();
|
||||
const { data, error, loading, reload } = useAsync(() => api.status(name), [name]);
|
||||
// The poll is what switches this page over once a wake lands, and back to
|
||||
// NotRunning when the server stops (idle stop, the console, a player's timeout).
|
||||
usePolling(reload, data && shownPhase(data) === "Running" ? STATUS_POLL_SLOW_MS : STATUS_POLL_FAST_MS);
|
||||
const {
|
||||
data: mine,
|
||||
error: mineError,
|
||||
@@ -44,7 +46,7 @@ export function ServerPlayers() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
if (error && !data) {
|
||||
return (
|
||||
<>
|
||||
{back}
|
||||
@@ -62,14 +64,15 @@ export function ServerPlayers() {
|
||||
// NotYours, which would wrongly tell an owner the server isn't theirs on a blip.
|
||||
const pending = ownershipPending(tierLoading, isAdmin, mine, mineError);
|
||||
const owned = canManage(isAdmin, mine, name);
|
||||
const phase: Phase = data.phase;
|
||||
const phase = shownPhase(data);
|
||||
const failure = startFailure(data);
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
title={data.displayName || data.name}
|
||||
subtitle={t("players_title")}
|
||||
actions={<PhaseBadge phase={phase} />}
|
||||
actions={<PhaseBadge phase={phase} failure={failure} autoRestarts={data.autoRestarts} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -77,6 +80,7 @@ export function ServerPlayers() {
|
||||
<>
|
||||
{back}
|
||||
{header}
|
||||
{error && <RefreshError error={error} className="mb-4" />}
|
||||
{pending ? (
|
||||
<Loading />
|
||||
) : mineError ? (
|
||||
@@ -84,7 +88,16 @@ export function ServerPlayers() {
|
||||
) : !owned ? (
|
||||
<NotYours title={t("players_not_yours_title")} body={t("players_not_yours_body")} />
|
||||
) : phase !== "Running" ? (
|
||||
<NotRunning title={t("players_not_running_title")} body={t("players_not_running_body")} serverName={name} onWoken={reload} />
|
||||
<NotRunning
|
||||
title={t("players_not_running_title")}
|
||||
body={t("players_not_running_body")}
|
||||
serverName={name}
|
||||
phase={data.phase}
|
||||
desiredState={data.desiredState}
|
||||
failure={failure}
|
||||
autoRestarts={data.autoRestarts}
|
||||
onWoken={reload}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
|
||||
|
||||
@@ -163,3 +163,32 @@ describe("ServersPage failed starts", () => {
|
||||
expect(flaky.getByRole("button", { name: /Retry start/ })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServersPage servers asked to move", () => {
|
||||
it("shows a wake or stop at once, in the badge, the button and the counts", async () => {
|
||||
calls.fleet.mockResolvedValue([
|
||||
row("survival", { phase: "Stopped", desiredState: "Running", owned: true }),
|
||||
row("creative", { phase: "Running", desiredState: "Stopped", ready: true, owned: true }),
|
||||
]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ServersPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const survival = await tableRow("survival");
|
||||
expect(survival.getByText("Starting")).toBeTruthy();
|
||||
expect(survival.queryByText("Stopped")).toBeNull();
|
||||
// Stop stays offered: the way out of a start that never comes up.
|
||||
expect(survival.getByRole("button", { name: "Stop" })).toHaveProperty("disabled", false);
|
||||
|
||||
const creative = await tableRow("creative");
|
||||
expect(creative.getByText("Stopping")).toBeTruthy();
|
||||
expect(creative.getByRole("button", { name: "Stopping…" })).toHaveProperty("disabled", true);
|
||||
expect(creative.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||
|
||||
// The Running card counts neither: one is not up yet, the other is going down.
|
||||
const runningCard = screen.getByText("Running", { selector: "div.mt-1" });
|
||||
expect(runningCard.previousElementSibling?.textContent).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Network,
|
||||
@@ -30,16 +30,16 @@ import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { PhaseBadge, PHASE_KEY, PHASE_COLOR, startFailure } from "@/components/PhaseBadge";
|
||||
import { PhaseBadge, PHASE_KEY, PHASE_COLOR, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { Loading, ErrorState, EmptyState } from "@/components/States";
|
||||
import { Loading, ErrorState, EmptyState, RefreshError } from "@/components/States";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { CreateServerDialog } from "@/components/CreateServerDialog";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync, useConfig } from "@/lib/hooks";
|
||||
import { useAsync, useConfig, usePolling } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { joinAddress, type RuntimeConfig } from "@/lib/config";
|
||||
import { matchScore } from "@/lib/fuzzy";
|
||||
@@ -107,19 +107,7 @@ export function ServersPage() {
|
||||
const [phaseFilter, setPhaseFilter] = useState<Phase | "all">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
if (!document.hidden) reload();
|
||||
}, REFRESH_MS);
|
||||
const onVisible = () => {
|
||||
if (!document.hidden) reload();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, [reload]);
|
||||
usePolling(reload, REFRESH_MS);
|
||||
|
||||
const servers = useMemo<UnifiedServer[]>(() => {
|
||||
if (!data) return [];
|
||||
@@ -177,7 +165,8 @@ export function ServersPage() {
|
||||
let playersOnline = 0;
|
||||
let playersMax = 0;
|
||||
for (const s of servers) {
|
||||
if (counts[s.phase as Phase] !== undefined) counts[s.phase as Phase]++;
|
||||
const phase = shownPhase(s);
|
||||
if (counts[phase] !== undefined) counts[phase]++;
|
||||
else counts.Unknown++;
|
||||
playersOnline += s.playersOnline ?? 0;
|
||||
playersMax += s.playersMax ?? 0;
|
||||
@@ -187,7 +176,7 @@ export function ServersPage() {
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || s.phase === phaseFilter;
|
||||
const phaseOk = (s: UnifiedServer) => phaseFilter === "all" || shownPhase(s) === phaseFilter;
|
||||
if (terms.length === 0) return servers.filter(phaseOk);
|
||||
const scored: { s: UnifiedServer; score: number }[] = [];
|
||||
for (const s of servers) {
|
||||
@@ -242,12 +231,7 @@ export function ServersPage() {
|
||||
<Loading />
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div role="alert" className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
{humanizeError(error)}
|
||||
</div>
|
||||
)}
|
||||
{error && <RefreshError error={error} />}
|
||||
|
||||
{/* Stats Cards in a full grid row */}
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
@@ -468,7 +452,6 @@ function ServerActions({
|
||||
);
|
||||
}
|
||||
|
||||
const live = isLive(server.phase);
|
||||
return (
|
||||
<div className={cn("flex flex-col items-end gap-1", className)}>
|
||||
<div className="flex flex-wrap items-start justify-end gap-2 xl:flex-nowrap">
|
||||
@@ -514,7 +497,8 @@ function ServerActions({
|
||||
<>
|
||||
<PowerButton
|
||||
name={server.name}
|
||||
live={live}
|
||||
phase={server.phase}
|
||||
desiredState={server.desiredState}
|
||||
failed={startFailure(server) !== null}
|
||||
playersOnline={server.playersOnline}
|
||||
playerCountUnknown={server.playerCountUnknown}
|
||||
@@ -617,7 +601,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} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
|
||||
<PhaseBadge phase={shownPhase(server)} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
|
||||
</div>
|
||||
{address && <CopyAddress address={address} className="md:max-w-[16rem]" />}
|
||||
</td>
|
||||
@@ -676,7 +660,7 @@ function ServerMobileCard({
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<ServerName server={server} />
|
||||
</div>
|
||||
<PhaseBadge phase={server.phase} failure={startFailure(server)} autoRestarts={server.autoRestarts} />
|
||||
<PhaseBadge phase={shownPhase(server)} 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