fix(servers): 主人可放弃服务器、管理员可删除服务器,由 reaper 归档世界后释放或移除

This commit is contained in:
Lemon-miaow committed 2026-09-27 03:49:49 +08:00
1 parent 4a26bf4ca0
commit 0e08fa7ced
55 files changed
+2774 -119

No files matched your search

+52 -2
View File
@@ -761,7 +761,7 @@ function fleetView(state: MockState, accountInfo: MockAccount): FleetServer[] {
playersOnline: s.ready ? s.playersOnline : 0,
owner: owner ? state.accounts[owner].email : "",
owned: owner === accountInfo.id,
claimable: owner === null,
claimable: owner === null && !s.retiring,
};
});
}
@@ -775,7 +775,7 @@ function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServe
name: serverInfo.name,
subdomain: serverInfo.subdomain,
owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned,
claimable: serverInfo.owner === null && !serverInfo.retiring && accountInfo.linked && !owned,
phase: serverInfo.phase,
playersOnline: serverInfo.playersOnline,
playersMax: serverInfo.playersMax,
@@ -784,6 +784,7 @@ function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServe
desiredState: serverInfo.desiredState,
autostartPolicy: serverInfo.autostartPolicy,
playerCountUnknown: serverInfo.playerCountUnknown,
retiring: serverInfo.retiring,
}),
};
}
@@ -2234,6 +2235,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.retiring) {
sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
return true;
}
setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
return true;
@@ -2255,6 +2260,10 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
handleCommandMock(ctx, serverInfo);
return true;
}
if (ctx.parts[4] === "retirement" && (is("PUT", ctx) || is("DELETE", ctx))) {
await handleRetirementMock(ctx, serverInfo);
return true;
}
if (is("POST", ctx) && ctx.parts[4] === "claim") {
claimServer(ctx, serverInfo);
return true;
@@ -2695,6 +2704,47 @@ function mockCommandReply(cmd: string): string {
return `[mock] command "${cmd}" executed`;
}
// handleRetirementMock mirrors handlers_retire.go: owner-or-admin, delete is an
// admin's, the typed name must match, a system server is refused. The mock has no
// reaper, so the request just sits until cancelled; the cancel of a deletion is an
// admin's too.
async function handleRetirementMock(ctx: SessionContext, serverInfo: MockServer): Promise<void> {
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return;
}
const admin = isAdmin(ctx.account.role);
if (is("DELETE", ctx)) {
if (serverInfo.retiring?.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can cancel the deletion of a server");
return;
}
serverInfo.retiring = undefined;
ctx.res.statusCode = 204;
ctx.res.end();
return;
}
const body = await readJSON<{ confirm?: string; delete?: boolean }>(ctx.req);
if (body.delete && !admin) {
sendError(ctx.res, 403, "forbidden", "only an administrator can delete a server");
return;
}
if (body.confirm !== serverInfo.name) {
sendError(ctx.res, 400, "confirm_mismatch", "type the server's name to confirm");
return;
}
if (serverInfo.reaperExempt) {
sendError(ctx.res, 409, "system_server", "a system server cannot be given up or deleted");
return;
}
setPhase(serverInfo, "Stopped");
serverInfo.retiring = {
requested_at: serverInfo.retiring?.requested_at ?? new Date().toISOString(),
delete: (serverInfo.retiring?.delete ?? false) || body.delete === true,
};
sendJSON(ctx.res, 202, { name: serverInfo.name, retiring: serverInfo.retiring });
}
function claimServer(ctx: SessionContext, serverInfo: MockServer): void {
if (serverInfo.owner !== null) {
sendError(ctx.res, 409, "already_claimed", "server is already claimed");
+18
View File
@@ -255,3 +255,21 @@ describe("PowerButton on a server already moving", () => {
});
}
});
describe("PowerButton on a server given up or being deleted", () => {
it("offers nothing to press, only the badge saying why", () => {
const requested_at = new Date().toISOString();
const { rerender } = render(
<PowerButton name="lobby" phase="Stopped" desiredState="Stopped" retiring={{ requested_at, delete: false }} onChanged={vi.fn()} />,
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText(t("servers:retiring_badge_release"))).toBeTruthy();
// A failed start would otherwise offer its retry.
rerender(
<PowerButton name="lobby" phase="Failed" desiredState="Running" failed retiring={{ requested_at, delete: true }} onChanged={vi.fn()} />,
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText(t("servers:retiring_badge_delete"))).toBeTruthy();
});
});
+10 -2
View File
@@ -3,8 +3,9 @@ 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 { RetiringBadge } from "@/components/Retirement";
import { api, humanizeError } from "@/lib/api";
import type { Phase } from "@/lib/types";
import type { Phase, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils";
/** How long a sent wake or stop shows as in progress when the view never
@@ -25,6 +26,9 @@ interface Props {
playersOnline?: number;
/** The operator cannot read the player count, so players may be online. */
playerCountUnknown?: boolean;
/** A pending retirement: nothing starts the server until it is cancelled, so
* the control gives way to a badge saying why. */
retiring?: RetireState;
/** Called after the wake or stop was accepted, so the parent refetches. */
onChanged: () => void;
size?: "sm" | "default";
@@ -49,6 +53,7 @@ function isUp(phase: Phase): boolean {
// 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.
// A server given up or being deleted offers nothing to press, only why.
export function PowerButton({
name,
phase,
@@ -56,6 +61,7 @@ export function PowerButton({
failed = false,
playersOnline,
playerCountUnknown,
retiring,
onChanged,
size = "sm",
className,
@@ -125,7 +131,9 @@ export function PowerButton({
);
let control;
if (submitted !== null) {
if (retiring) {
control = <RetiringBadge retiring={retiring} />;
} else if (submitted !== null) {
control = inProgress(submitted);
} else if (failed) {
control = (
+126
View File
@@ -0,0 +1,126 @@
// @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 i18next from "i18next";
import { RetireCard, RetireNotice } from "./Retirement";
const calls = vi.hoisted(() => ({ retireServer: vi.fn(), cancelRetire: vi.fn() }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
const hourAgo = () => new Date(Date.now() - 3600_000).toISOString();
beforeEach(() => {
calls.retireServer.mockReset();
calls.cancelRetire.mockReset();
});
describe("RetireCard", () => {
it("lets an owner give the server up once its name is typed out, and never offers a deletion", async () => {
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: false } });
const onChanged = vi.fn();
render(<RetireCard name="survival" label="Survival World" isAdmin={false} onChanged={onChanged} />);
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_release") }));
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain(t("servers:retire_release_title", { name: "Survival World" }));
expect(dialog.textContent).toContain(t("servers:retire_step_cancel_owner"));
const confirm = screen.getByRole("button", { name: t("servers:retire_confirm_release") });
const input = screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false });
// The display name, a different case or a prefix is not the server's name.
for (const wrong of ["Survival World", "Survival", "surviva"]) {
await userEvent.clear(input);
await userEvent.type(input, wrong + "{Enter}");
expect(confirm).toHaveProperty("disabled", true);
}
expect(calls.retireServer).not.toHaveBeenCalled();
await userEvent.clear(input);
await userEvent.type(input, "survival");
expect(confirm).toHaveProperty("disabled", false);
await userEvent.click(confirm);
expect(calls.retireServer).toHaveBeenCalledExactlyOnceWith("survival", { confirm: "survival", delete: false });
expect(onChanged).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).toBeNull();
});
it("lets an admin delete the server, and says only an admin can take that back", async () => {
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: true } });
const onChanged = vi.fn();
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
const dialog = screen.getByRole("dialog");
expect(dialog.textContent).toContain(t("servers:retire_step_delete"));
expect(dialog.textContent).toContain(t("servers:retire_step_cancel_admin"));
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival{Enter}");
expect(calls.retireServer).toHaveBeenCalledExactlyOnceWith("survival", { confirm: "survival", delete: true });
expect(onChanged).toHaveBeenCalledOnce();
});
it("keeps the dialog open with the reason when the request is refused", async () => {
calls.retireServer.mockRejectedValue({ status: 409, code: "world_volume_orphaned", message: "raw" });
const onChanged = vi.fn();
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_confirm_delete") }));
expect((await screen.findByRole("alert")).textContent).toBe(t("errors:world_volume_orphaned"));
expect(screen.getByRole("dialog")).toBeTruthy();
expect(onChanged).not.toHaveBeenCalled();
});
});
describe("RetireNotice", () => {
it("lets the owner take back giving the server up", async () => {
calls.cancelRetire.mockResolvedValue(undefined);
const onChanged = vi.fn();
render(
<RetireNotice name="survival" retiring={{ requested_at: hourAgo(), delete: false }} isAdmin={false} onChanged={onChanged} />,
);
expect(screen.getByRole("status").textContent).toContain(t("servers:retiring_title_release"));
expect(screen.getByRole("status").textContent).toContain("1 hour ago");
await userEvent.click(screen.getByRole("button", { name: t("servers:retiring_cancel") }));
expect(calls.cancelRetire).toHaveBeenCalledExactlyOnceWith("survival");
expect(onChanged).toHaveBeenCalledOnce();
});
it("offers the owner no cancel of an admin's deletion, and says who can", () => {
render(
<RetireNotice name="survival" retiring={{ requested_at: hourAgo(), delete: true }} isAdmin={false} onChanged={vi.fn()} />,
);
expect(screen.getByRole("status").textContent).toContain(t("servers:retiring_title_delete"));
expect(screen.getByRole("status").textContent).toContain(t("servers:retiring_cancel_admin_only"));
expect(screen.queryByRole("button", { name: t("servers:retiring_cancel") })).toBeNull();
});
it("lets an admin cancel a deletion, and shows why a cancel failed", async () => {
calls.cancelRetire.mockRejectedValue({ status: 403, code: "forbidden", message: "raw" });
const onChanged = vi.fn();
render(
<RetireNotice name="survival" retiring={{ requested_at: hourAgo(), delete: true }} isAdmin onChanged={onChanged} />,
);
expect(screen.getByRole("status").textContent).not.toContain(t("servers:retiring_cancel_admin_only"));
await userEvent.click(screen.getByRole("button", { name: t("servers:retiring_cancel") }));
expect(calls.cancelRetire).toHaveBeenCalledWith("survival");
expect(await screen.findByRole("alert")).toBeTruthy();
expect(onChanged).not.toHaveBeenCalled();
});
});
+244
View File
@@ -0,0 +1,244 @@
import { useState } from "react";
import { Hourglass, Loader2, PackageX } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ConfirmFooter } from "@/components/ConfirmFooter";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { formatAbsolute, formatRelative } from "@/lib/format";
import type { RetireState } from "@/lib/types";
import { cn } from "@/lib/utils";
// A server leaves its owner, or the platform, through a retirement
// (internal/api/handlers_retire.go): the owner gives it up, or an admin deletes
// it. felis-api stops the server and records the request; the reaper archives the
// world and carries it out on its next daily run. Until then the server cannot be
// started or claimed, and the request can be cancelled: a give-up by its owner or
// an admin, a deletion by an admin only.
/** RetiringBadge stands in for the start/stop control of a server with a pending
* retirement: nothing can start it until the request is cancelled or done. */
export function RetiringBadge({ retiring, className }: { retiring: RetireState; className?: string }) {
const { t } = useTranslation("servers");
return (
<span
title={t("retiring_badge_hint")}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300",
className,
)}
>
<Hourglass className="h-3 w-3" />
{retiring.delete ? t("retiring_badge_delete") : t("retiring_badge_release")}
</span>
);
}
/** RetireNotice heads the console of a server with a pending retirement: what
* happens at the next reclaim run, and the way back while there still is one. */
export function RetireNotice({
name,
retiring,
isAdmin,
onChanged,
}: {
name: string;
retiring: RetireState;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t, i18n } = useTranslation("servers");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const canCancel = isAdmin || !retiring.delete;
const when = formatRelative(retiring.requested_at, Date.now(), i18n.language);
async function cancel() {
setBusy(true);
setError(null);
try {
await api.cancelRetire(name);
onChanged();
} catch (e) {
setError(humanizeError(e));
} finally {
setBusy(false);
}
}
return (
<div
role="status"
className="flex shrink-0 flex-col gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 text-sm sm:flex-row sm:items-start"
>
<Hourglass className="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-foreground">
{retiring.delete ? t("retiring_title_delete") : t("retiring_title_release")}
</p>
<p className="text-muted-foreground" title={formatAbsolute(retiring.requested_at, i18n.language)}>
{retiring.delete ? t("retiring_body_delete", { when }) : t("retiring_body_release", { when })}
</p>
{!canCancel && <p className="text-xs text-muted-foreground">{t("retiring_cancel_admin_only")}</p>}
{error && <MessageLine kind="error" message={error} compact />}
</div>
{canCancel && (
<Button size="sm" variant="outline" className="shrink-0 self-start" onClick={() => void cancel()} disabled={busy}>
{busy && <Loader2 className="animate-spin" />}
{t("retiring_cancel")}
</Button>
)}
</div>
);
}
type Mode = "release" | "delete";
/** RetireCard is the console sidebar's way to give a server up (its owner or an
* admin) or delete it (an admin). Either asks for the server's name typed out,
* the same confirmation felis-api requires, before anything is sent. */
export function RetireCard({
name,
label,
isAdmin,
onChanged,
}: {
name: string;
/** What the dialog calls the server: its display name, else its name. */
label: string;
isAdmin: boolean;
onChanged: () => void;
}) {
const { t } = useTranslation("servers");
const [mode, setMode] = useState<Mode | null>(null);
return (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-card p-4">
<div className="flex items-start gap-3">
<PackageX className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{isAdmin ? t("retire_card_title_admin") : t("retire_card_title")}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isAdmin ? t("retire_card_desc_admin") : t("retire_card_desc")}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => setMode("release")}
>
{t("retire_release")}
</Button>
{isAdmin && (
<Button size="sm" variant="destructive" onClick={() => setMode("delete")}>
{t("retire_delete")}
</Button>
)}
</div>
{mode && (
<RetireDialog
name={name}
label={label}
mode={mode}
isAdmin={isAdmin}
onClose={() => setMode(null)}
onDone={onChanged}
/>
)}
</div>
);
}
function RetireDialog({
name,
label,
mode,
isAdmin,
onClose,
onDone,
}: {
name: string;
label: string;
mode: Mode;
isAdmin: boolean;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("servers");
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const del = mode === "delete";
function setOpen(open: boolean) {
if (!open && !busy) onClose();
}
async function confirm() {
if (typed !== name || busy) return;
setBusy(true);
setError(null);
try {
await api.retireServer(name, { confirm: typed, delete: del });
setBusy(false);
onClose();
onDone();
} catch (e) {
setError(humanizeError(e));
setBusy(false);
}
}
return (
<Dialog open onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{del ? t("retire_delete_title", { name: label }) : t("retire_release_title", { name: label })}</DialogTitle>
<DialogDescription asChild>
<ul className="list-disc space-y-1 pl-5 text-left">
<li>{t("retire_step_stop")}</li>
<li>{del ? t("retire_step_delete") : t("retire_step_release")}</li>
<li>{isAdmin ? t("retire_step_cancel_admin") : t("retire_step_cancel_owner")}</li>
</ul>
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label htmlFor="retire-confirm" className="text-sm">
{t("retire_confirm_label")} <code className="rounded bg-muted px-1 font-mono text-xs">{name}</code>
</label>
<Input
id="retire-confirm"
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void confirm();
}}
autoComplete="off"
spellCheck={false}
className="font-mono"
/>
</div>
{error && <MessageLine kind="error" message={error} compact />}
<ConfirmFooter
onCancel={() => setOpen(false)}
onConfirm={() => void confirm()}
disabled={typed !== name}
loading={busy}
cancelLabel={t("access_cancel")}
confirmLabel={del ? t("retire_confirm_delete") : t("retire_confirm_release")}
/>
</DialogContent>
</Dialog>
);
}
+16
View File
@@ -39,6 +39,22 @@ function notRunning(
}
describe("NotRunning", () => {
it("offers no wake for a server given up or being deleted, only why", () => {
render(
<NotRunning
title="Server is asleep"
body="Wake it to manage players."
serverName="survival"
phase="Stopped"
desiredState="Stopped"
retiring={{ requested_at: new Date().toISOString(), delete: false }}
onWoken={vi.fn()}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
expect(screen.getByText("Given up")).toBeTruthy();
});
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();
+5 -1
View File
@@ -4,7 +4,7 @@ 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 type { Phase, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils";
export function Loading({ label }: { label?: string }) {
@@ -174,6 +174,8 @@ export interface NotRunningProps {
/** From startFailure: a Failed start gets its own copy and a retry. */
failure?: StartFailure | null;
autoRestarts?: number;
/** A pending retirement: the page offers no wake, only why. */
retiring?: RetireState;
/** Called once the wake was accepted, so the page refetches its status. */
onWoken: () => void;
}
@@ -190,6 +192,7 @@ export function NotRunning({
desiredState,
failure = null,
autoRestarts = 0,
retiring,
onWoken,
}: NotRunningProps) {
const { t } = useTranslation("servers");
@@ -232,6 +235,7 @@ export function NotRunning({
phase={phase}
desiredState={desiredState}
failed={failure !== null}
retiring={retiring}
onChanged={onWoken}
className="items-center"
/>
@@ -7,6 +7,7 @@
"latest_title": "Latest backup",
"history_note": "Backups can be restored until they expire, then they are cleaned up automatically. Each server keeps only its most recent manual backups and its most recent scheduled backups (older ones of the same kind are removed once a new one finishes), and its last 3 pre-restore snapshots.",
"reason_inactive": "Idle archive",
"reason_released": "Archived when given up or deleted",
"reason_manual": "Manual backup",
"reason_pre_restore": "Pre-restore snapshot",
"reason_scheduled": "Scheduled backup",
@@ -13,6 +13,10 @@
"subdomain_taken": "That subdomain is already in use.",
"already_exists": "A server with that name already exists.",
"world_volume_exists": "An earlier server with that name left its world volume behind. Choose another name, or ask the operator to delete the old volume.",
"server_retiring": "This server has been given up or is being deleted, which happens at the next daily reclaim run. It can't be started or claimed unless that is cancelled.",
"confirm_mismatch": "The name you typed doesn't match the server's name.",
"system_server": "A system server is managed by the platform and can't be given up or deleted.",
"world_volume_orphaned": "This server's MinecraftServer was deleted by hand but its world volume is still there. Ask the operator to archive and remove that volume first, then delete the server.",
"cooldown": "Wake is cooling down — try again shortly.",
"not_running": "The server isn't running — wake it before managing access.",
"console_unavailable": "Can't reach the server console right now — try again shortly.",
+26 -1
View File
@@ -219,5 +219,30 @@
"idle_stop_hours": "{{count}} h",
"stop_confirm_players_one": "1 player is online and will be disconnected. Stop anyway?",
"stop_confirm_players_other": "{{count}} players are online and will be disconnected. Stop anyway?",
"stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?"
"stop_confirm_unknown": "The player count can't be read, so players may be online. Stop anyway?",
"retire_card_title": "Give up server",
"retire_card_title_admin": "Give up or delete server",
"retire_card_desc": "Hand it back when you no longer need it: the world is archived, then the server is freed for someone else to claim.",
"retire_card_desc_admin": "Giving up archives the world and frees the server; deleting archives the world and removes the server, freeing its name and subdomain.",
"retire_release": "Give up",
"retire_delete": "Delete",
"retire_release_title": "Give up \"{{name}}\"?",
"retire_delete_title": "Delete \"{{name}}\"?",
"retire_step_stop": "The server stops now and can't be started until this is done.",
"retire_step_release": "At the next daily reclaim run the world is archived (kept 90 days by default), its storage deleted, and the server freed for someone else to claim.",
"retire_step_delete": "At the next daily reclaim run the world is archived (kept 90 days by default), then its storage and this server are deleted, freeing the name and subdomain.",
"retire_step_cancel_owner": "You can cancel from the console until the run; until then it still counts toward your quota.",
"retire_step_cancel_admin": "An administrator can cancel from the console until the run.",
"retire_confirm_label": "Type the server name to confirm:",
"retire_confirm_release": "Give up server",
"retire_confirm_delete": "Delete server",
"retiring_title_release": "This server has been given up",
"retiring_title_delete": "This server is about to be deleted",
"retiring_body_release": "Requested {{when}}. At the next daily reclaim run the world is archived, then its storage is deleted and the server freed. Until then it stays stopped, can't be started and still counts toward the quota.",
"retiring_body_delete": "Requested {{when}}. At the next daily reclaim run the world is archived, then its storage and this server are deleted. Until then it stays stopped and can't be started.",
"retiring_cancel": "Cancel request",
"retiring_cancel_admin_only": "An administrator asked for the deletion, so only an administrator can cancel it.",
"retiring_badge_release": "Given up",
"retiring_badge_delete": "Deleting",
"retiring_badge_hint": "Given up or being deleted; handled at the next daily reclaim run, and it can't be started until then."
}
@@ -7,6 +7,7 @@
"latest_title": "最新备份",
"history_note": "历史备份在过期前均可用于恢复,到期后自动清理。手动备份和定时备份每台服务器各只保留最近几份,新备份完成后会自动删除更早的同类备份;恢复前快照保留最近 3 份。",
"reason_inactive": "闲置自动回收",
"reason_released": "放弃或删除时归档",
"reason_manual": "手动备份",
"reason_pre_restore": "恢复前快照",
"reason_scheduled": "定时备份",
@@ -13,6 +13,10 @@
"subdomain_taken": "该子域名已被占用。",
"already_exists": "同名服务器已存在。",
"world_volume_exists": "同名的旧服务器留下了世界卷。请换一个名称,或请运维删除旧的世界卷。",
"server_retiring": "这台服务器已被放弃或正在删除,下一次每日回收时处理。撤销之前它不能启动或被认领。",
"confirm_mismatch": "输入的名称和服务器名称不一致。",
"system_server": "系统服务器由平台管理,不能放弃或删除。",
"world_volume_orphaned": "这台服务器的 MinecraftServer 已被手动删除,但世界卷还在。请运维先归档并删除这个世界卷,再删除服务器。",
"cooldown": "启动冷却中——请稍后再试。",
"not_running": "服务器未在运行——请先启动它再管理访问权限。",
"console_unavailable": "暂时无法连接服务器控制台,请稍后重试。",
+26 -1
View File
@@ -218,5 +218,30 @@
"idle_stop_minutes": "{{count}} 分钟",
"idle_stop_hours": "{{count}} 小时",
"stop_confirm_players": "{{count}} 名玩家在线,停服会断开他们。确定停止?",
"stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?"
"stop_confirm_unknown": "读不到在线人数,可能有玩家在线。确定停止?",
"retire_card_title": "放弃服务器",
"retire_card_title_admin": "放弃或删除服务器",
"retire_card_desc": "不再需要时交还给平台:世界归档后,服务器释放给其他人认领。",
"retire_card_desc_admin": "放弃会归档世界并释放服务器;删除会归档世界并移除服务器,名称和子域名可以重新使用。",
"retire_release": "放弃服务器",
"retire_delete": "删除服务器",
"retire_release_title": "放弃「{{name}}」?",
"retire_delete_title": "删除「{{name}}」?",
"retire_step_stop": "服务器会立即停止,处理完之前不能启动。",
"retire_step_release": "下一次每日回收运行时,世界先归档(默认保留 90 天),然后删除世界存储,服务器释放给其他人认领。",
"retire_step_delete": "下一次每日回收运行时,世界先归档(默认保留 90 天),然后删除世界存储和这台服务器,名称和子域名释放出来。",
"retire_step_cancel_owner": "回收运行之前可以在控制台撤销;在此之前它仍计入你的配额。",
"retire_step_cancel_admin": "回收运行之前管理员可以在控制台撤销。",
"retire_confirm_label": "输入服务器名称以确认:",
"retire_confirm_release": "放弃服务器",
"retire_confirm_delete": "删除服务器",
"retiring_title_release": "这台服务器已被放弃",
"retiring_title_delete": "这台服务器即将被删除",
"retiring_body_release": "{{when}}提出。下一次每日回收运行时会归档世界,然后删除世界存储并释放服务器。在此之前它保持停止、不能启动,仍计入配额。",
"retiring_body_delete": "{{when}}提出。下一次每日回收运行时会归档世界,然后删除世界存储和这台服务器。在此之前它保持停止、不能启动。",
"retiring_cancel": "撤销",
"retiring_cancel_admin_only": "删除由管理员提出,只有管理员可以撤销。",
"retiring_badge_release": "等待回收",
"retiring_badge_delete": "等待删除",
"retiring_badge_hint": "已放弃或正在删除,下一次每日回收时处理;在此之前不能启动。"
}
+23
View File
@@ -273,6 +273,29 @@ describe("api access-control wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("retireServer PUTs the typed name and the delete flag to the retirement path", async () => {
const requested_at = "2026-09-27T12:00:00Z";
const fetchSpy = fakeFetch({ name: "survival", retiring: { requested_at, delete: true } }, { status: 202 });
vi.stubGlobal("fetch", fetchSpy);
const res = await api.retireServer("survival", { confirm: "survival", delete: true });
expect(res.retiring).toEqual({ requested_at, delete: true });
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/retirement");
expect((opts as RequestInit).method).toBe("PUT");
expect(JSON.parse(String((opts as RequestInit).body))).toEqual({ confirm: "survival", delete: true });
});
it("cancelRetire DELETEs the retirement path", async () => {
const fetchSpy = vi.fn(async () => ({ ok: true, status: 204, statusText: "No Content", text: async () => "" })) as unknown as typeof fetch;
vi.stubGlobal("fetch", fetchSpy);
await api.cancelRetire("survival");
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/retirement");
expect((opts as RequestInit).method).toBe("DELETE");
});
it("accessWhitelistList GETs the whitelist path and returns players + raw output", async () => {
const fetchSpy = fakeFetch({
name: "survival",
+23
View File
@@ -21,6 +21,7 @@ import type {
PlayersResult,
QuotaInput,
QuotaView,
RetireState,
ServerFileEntry,
ServerJob,
MyServerView,
@@ -415,6 +416,16 @@ export const api = rejectingSync({
claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
// Retirement (PUT/DELETE /servers/{name}/retirement): the owner gives the server
// up, or an admin deletes it (delete: true, admin only). felis-api stops the
// server and records the request; the reaper archives the world and carries it
// out on its next daily run. confirm must repeat the server's name. The cancel
// is the owner's for a give-up and an admin's for a deletion (403 otherwise).
retireServer: (name: string, body: { confirm: string; delete: boolean }) =>
request<{ name: string; retiring: RetireState }>("PUT", urlPath`/servers/${name}/retirement`, body),
cancelRetire: (name: string) => request<void>("DELETE", urlPath`/servers/${name}/retirement`),
/** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
* The backend strips a leading "/", rejects control characters (newline → 400)
* and caps the command at 1000 bytes. The reply is the server's plain-text
@@ -1044,6 +1055,18 @@ export function humanizeError(e: unknown): string {
// new server cannot mount the old world.
case "world_volume_exists":
return t("world_volume_exists");
// Retirement (internal/api/handlers_retire.go): a server given up or being
// deleted cannot be woken or claimed until that is cancelled or done; the
// request repeats the server's name; a system server is never retired; a
// deletion refuses while a hand-deleted server's world volume is left.
case "server_retiring":
return t("server_retiring");
case "confirm_mismatch":
return t("confirm_mismatch");
case "system_server":
return t("system_server");
case "world_volume_orphaned":
return t("world_volume_orphaned");
case "cooldown":
return t("cooldown");
// Access control (spec §7): the server must be Running for any RCON-backed
+136 -5
View File
@@ -689,6 +689,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/retirement": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Give the server up (owner) or delete it (admin). The reaper carries it out.
* @description The server is stopped and the request recorded; the reaper, the one component that deletes a world, carries it out on its next daily run. It archives the world as a released backup (kept for the reaper's retention, recorded against the owner), deletes the world volume and releases the server for anyone to claim; with delete it also removes the server, which frees its name and subdomain. Until then the server cannot be woken or claimed and still counts against the owner's quota, and the request can be cancelled. Repeating it keeps the first request time, and a deletion stays a deletion. confirm must repeat the server's name. Audited as server.release / server.delete.
*/
put: operations["retireServer"];
post?: never;
/**
* Cancel a pending retirement. Only an admin cancels a deletion.
* @description The server stays stopped; its owner starts it again when they want it. Cancelling when nothing is pending changes nothing. Audited as server.retire_cancel.
*/
delete: operations["cancelRetire"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/status": {
parameters: {
query?: never;
@@ -2404,6 +2428,10 @@ export interface components {
autoRestarts?: number;
/** @description Present and true for a Failed server no automatic retry will bring up: its start timed out with the retries spent, or its spec is invalid. A Failed server without it is still in its restart backoff and may come up on its own. */
startGaveUp?: boolean;
/** @description Present and true for a system server the reaper never touches; it cannot be given up or deleted. */
reaperExempt?: boolean;
/** @description Owner and staff only. Present while the owner has given the server up or an admin is deleting it; the reaper carries that out on its next run. */
retiring?: components["schemas"]["RetireState"];
};
/** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */
FleetServer: components["schemas"]["ServerInfo"] & {
@@ -2411,13 +2439,19 @@ export interface components {
owner?: string;
/** @description True when the caller claimed this server, decided by account id so an owner without an email is still recognized. */
owned: boolean;
/** @description True for a live, unclaimed, non-system server, the same rule the claim route enforces. False whenever ownership is unknown. */
/** @description True for a live, unclaimed, non-system server with no pending deletion, the same rule the claim route enforces. False whenever ownership is unknown. */
claimable: boolean;
/** @description Present and true when the owner lookup failed, so an absent owner says nothing about whether the server is claimed. */
ownerUnknown?: boolean;
/** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
system?: boolean;
};
/** @description A pending retirement (internal/api/repo.go RetireState): the owner gave the server up, or with delete an admin is deleting it. The reaper carries it out on its next daily run: it archives the world as a released backup, deletes the world volume and releases the server, and for a deletion also removes it. Until then the server stays stopped and cannot be woken or claimed. */
RetireState: {
/** Format: date-time */
requested_at: string;
delete: boolean;
};
/** @description One player on a server's wake allowlist (internal/api/repo.go AllowlistEntry): someone who joined the server, and so may wake it under autostartPolicy=allowlist unless the owner took that away. */
AllowlistEntry: {
/** Format: uuid */
@@ -2468,6 +2502,8 @@ export interface components {
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 Owned rows only. Present while the server is given up or being deleted. */
retiring?: components["schemas"]["RetireState"];
};
/** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
BackupView: {
@@ -2477,7 +2513,7 @@ export interface components {
former_owner?: string;
/** Format: int64 */
size_bytes: number;
/** @description inactive_15d (idle reclaim), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops). */
/** @description inactive_15d (idle reclaim), released (the world of a server its owner gave up or an admin deleted), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops). */
reason: string;
status: string;
/** Format: date-time */
@@ -3218,7 +3254,7 @@ export interface operations {
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @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. */
/** @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. server_retiring: the owner gave the server up or an admin is deleting it; it stays down until the reaper archives it. */
409: {
headers: {
[name: string]: unknown;
@@ -3789,7 +3825,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. server_retiring: the server is given up or being deleted (ServerInfo.retiring). */
409: {
headers: {
[name: string]: unknown;
@@ -3882,7 +3918,7 @@ export interface operations {
};
};
404: components["responses"]["NotFound"];
/** @description Already claimed. */
/** @description already_claimed: someone else owns it. server_retiring: the server is being deleted. */
409: {
headers: {
[name: string]: unknown;
@@ -4433,6 +4469,101 @@ export interface operations {
};
};
};
retireServer: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The server's name */
confirm: string;
/** @description Delete the server (admin only). Default false gives it up. */
delete?: boolean;
};
};
};
responses: {
/** @description Recorded; the server is stopped. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
name: string;
retiring: components["schemas"]["RetireState"];
};
};
};
/** @description A malformed body or name, or confirm does not match the name (confirm_mismatch). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
/** @description Neither the owner nor an admin, or an owner asking to delete. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
/** @description system_server: a system server is never given up or deleted. world_volume_orphaned: the server is gone from the cluster but its world volume remains, which an operator archives and removes by hand. */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
cancelRetire: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Nothing is pending any more. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
/** @description Neither the owner nor an admin, or an owner cancelling an admin's deletion. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
};
};
status: {
parameters: {
query?: never;
+17
View File
@@ -17,6 +17,16 @@ export type Phase =
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
/** RetireState is a pending retirement (Go RetireState): the owner gave the
* server up, or with `delete` an admin is deleting it. The reaper carries it out
* on its next daily run (archive the world, delete its volume, release the
* server, and for a deletion remove it); until then the server stays stopped and
* cannot be woken or claimed. Only the owner and admins are shown it. */
export interface RetireState {
requested_at: string;
delete: boolean;
}
/** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
* Only this projection says whether the caller owns or may claim a server.
* The live fields come from the CRD best-effort; desiredState, autostartPolicy
@@ -40,6 +50,8 @@ export interface MyServerView {
autoRestarts?: number;
/** True for a Failed server no automatic retry will bring up. */
startGaveUp?: boolean;
/** A pending retirement; present on the caller's own rows only. */
retiring?: RetireState;
}
/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
@@ -77,6 +89,11 @@ export interface ServerStatus {
/** True for a Failed server no automatic retry will bring up; a Failed server
* without it is still in its restart backoff. */
startGaveUp?: boolean;
/** A platform system server the reaper never touches, so it cannot be given
* up or deleted. Owner and admin view only. */
reaperExempt?: boolean;
/** A pending retirement. Owner and admin view only. */
retiring?: RetireState;
}
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
+6
View File
@@ -99,6 +99,12 @@ describe("ServerBackups", () => {
expect(ops.map((el) => el.textContent)).toEqual(["Scheduled backup", "Backup"]);
});
it("names the archive the reaper leaves when a server is given up or deleted", async () => {
calls.listBackups.mockResolvedValue({ backups: [{ ...backup("bk-rel", 4), reason: "released" }], total: 1 });
renderPage();
expect((await screen.findByText("4 hours ago")).closest("tr")?.textContent).toContain("Archived when given up or deleted");
});
it("shows no pager when the server's backups fit on one page", async () => {
calls.listBackups.mockResolvedValue({ backups: [backup("bk-1", 3)], total: 1 });
renderPage();
+2
View File
@@ -71,6 +71,8 @@ function BackupRow({
const reasonLabel =
b.reason === "inactive_15d"
? t("reason_inactive")
: b.reason === "released"
? t("reason_released")
: b.reason === "manual"
? t("reason_manual")
: b.reason === "pre_restore"
+68 -7
View File
@@ -7,14 +7,13 @@ 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", () => ({
useTier: () => ({
loading: false,
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
isAdmin: true,
isOwner: false,
}),
const tier = vi.hoisted(() => ({
loading: false,
identity: { user_id: "admin-1", email: "[email protected]", role: "admin" },
isAdmin: true,
isOwner: false,
}));
vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
vi.mock("@/lib/config", async (importActual) => {
const actual = await importActual<typeof import("@/lib/config")>();
return {
@@ -30,6 +29,7 @@ vi.mock("@/lib/api", async (importActual) => {
vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="log-stream" /> }));
beforeEach(() => {
tier.isAdmin = true;
calls.status.mockReset();
calls.myServers.mockReset();
calls.myServers.mockResolvedValue([]);
@@ -151,3 +151,64 @@ describe("ServerConsole following the server", () => {
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
});
});
describe("ServerConsole retirement", () => {
const stopped = (over: Record<string, unknown> = {}) => status({ phase: "Stopped", desiredState: "Stopped", ...over });
const mine = (owned: boolean) => [
{ name: "survival", subdomain: "survival", owned, claimable: false, playersOnline: 0, playersMax: 20 },
];
const giveUp = () => screen.queryByRole("button", { name: "Give up" });
const del = () => screen.queryByRole("button", { name: "Delete" });
it("offers the owner a give-up and no deletion", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue(mine(true));
calls.status.mockResolvedValue(stopped());
renderConsole();
expect(await screen.findByRole("button", { name: "Give up" })).toBeTruthy();
expect(del()).toBeNull();
expect(screen.getByRole("button", { name: "Wake" })).toBeTruthy();
});
it("offers an admin both", async () => {
calls.status.mockResolvedValue(stopped());
renderConsole();
expect(await screen.findByRole("button", { name: "Delete" })).toBeTruthy();
expect(giveUp()).toBeTruthy();
});
it("offers someone who does not own it neither", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue(mine(false));
calls.status.mockResolvedValue(stopped());
renderConsole();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
await waitFor(() => expect(calls.myServers).toHaveBeenCalled());
expect(giveUp()).toBeNull();
});
it("offers nothing for a system server", async () => {
calls.status.mockResolvedValue(stopped({ reaperExempt: true }));
renderConsole();
expect(await screen.findByText("Server is asleep")).toBeTruthy();
expect(giveUp()).toBeNull();
expect(del()).toBeNull();
});
it("shows a pending give-up and the way back in place of the card and the wake", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue(mine(true));
calls.status.mockResolvedValue(stopped({ retiring: { requested_at: new Date().toISOString(), delete: false } }));
renderConsole();
expect(await screen.findByText("This server has been given up")).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel request" })).toBeTruthy();
expect(screen.getByText("Given up")).toBeTruthy();
expect(screen.queryByRole("button", { name: "Wake" })).toBeNull();
expect(giveUp()).toBeNull();
});
});
+13
View File
@@ -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 { RetireCard, RetireNotice } from "@/components/Retirement";
import { cn } from "@/lib/utils";
import { InlineError } from "@/components/MessageLine";
@@ -281,6 +282,7 @@ export function ServerConsole() {
failed={failure !== null}
playersOnline={data.playersOnline}
playerCountUnknown={data.playerCountUnknown}
retiring={data.retiring}
onChanged={reload}
/>
</div>
@@ -288,6 +290,10 @@ export function ServerConsole() {
className="mb-6"
/>
{data.retiring && (
<RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} />
)}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 flex-1 lg:min-h-0 min-h-0">
{/* Left/Main column: Console */}
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
@@ -397,6 +403,13 @@ export function ServerConsole() {
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
{/* Giving the server up (owner) or deleting it (admin) closes the
sidebar. A system server is never retired, and one already on its
way out shows the notice above with the way back instead. */}
{owned && !data.reaperExempt && !data.retiring && (
<RetireCard name={name} label={data.displayName || data.name} isAdmin={isAdmin} onChanged={reload} />
)}
</div>
</div>
</>
+1
View File
@@ -392,6 +392,7 @@ export function ServerLuckPerms() {
desiredState={data.desiredState}
failure={failure}
autoRestarts={data.autoRestarts}
retiring={data.retiring}
onWoken={reload}
/>
) : (
+1
View File
@@ -99,6 +99,7 @@ export function ServerPlayers() {
desiredState={data.desiredState}
failure={failure}
autoRestarts={data.autoRestarts}
retiring={data.retiring}
onWoken={reload}
/>
{/* The wake list is Felis's own record, so it stays manageable while the
@@ -192,3 +192,54 @@ describe("ServersPage servers asked to move", () => {
expect(runningCard.previousElementSibling?.textContent).toBe("0");
});
});
describe("ServersPage retiring servers", () => {
it("shows a server given up or being deleted with its badge and no start", async () => {
const requested_at = new Date().toISOString();
calls.fleet.mockResolvedValue([
row("survival", { owner: "steve-mc", retiring: { requested_at, delete: false } }),
row("creative", { owner: "[email protected]", retiring: { requested_at, delete: true } }),
row("skyblock", { owner: "[email protected]" }),
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const survival = await tableRow("survival");
expect(survival.getByText("Given up")).toBeTruthy();
expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull();
const creative = await tableRow("creative");
expect(creative.getByText("Deleting")).toBeTruthy();
expect(creative.queryByRole("button", { name: /Wake/ })).toBeNull();
const skyblock = await tableRow("skyblock");
expect(skyblock.getByRole("button", { name: /Wake/ })).toBeTruthy();
});
it("marks the owner's own server given up in their list", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue([
{
name: "survival",
subdomain: "survival",
owned: true,
claimable: false,
phase: "Stopped",
desiredState: "Stopped",
playersOnline: 0,
playersMax: 20,
retiring: { requested_at: new Date().toISOString(), delete: false },
} satisfies MyServerView,
]);
render(
<MemoryRouter>
<ServersPage />
</MemoryRouter>,
);
const survival = await tableRow("survival");
expect(survival.getByText("Given up")).toBeTruthy();
expect(survival.queryByRole("button", { name: /Wake/ })).toBeNull();
});
});
+6 -1
View File
@@ -43,7 +43,7 @@ import { useAsync, useConfig, usePolling } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { joinAddress, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy";
import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types";
import type { AutostartPolicy, FleetServer, Phase, MyServerView, RetireState } from "@/lib/types";
import { cn } from "@/lib/utils";
const REFRESH_MS = 10_000;
@@ -90,6 +90,8 @@ interface UnifiedServer {
/** The fleet's owner lookup failed, so an absent owner proves nothing. */
ownerUnknown?: boolean;
system?: boolean;
/** A pending retirement (owner and admin views): the row offers no start. */
retiring?: RetireState;
}
export function ServersPage() {
@@ -131,6 +133,7 @@ export function ServersPage() {
owned: s.owned,
ownerUnknown: s.ownerUnknown,
system: s.system,
retiring: s.retiring,
}));
} else {
return (data as MyServerView[]).map((s) => ({
@@ -149,6 +152,7 @@ export function ServersPage() {
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable,
owned: s.owned,
retiring: s.retiring,
}));
}
}, [data, isAdmin, t]);
@@ -502,6 +506,7 @@ function ServerActions({
failed={startFailure(server) !== null}
playersOnline={server.playersOnline}
playerCountUnknown={server.playerCountUnknown}
retiring={server.retiring}
onChanged={onChanged}
/>
{(server.owned || isAdmin) && (