fix(allowlist): 唤醒名单可查看、可取消或恢复唤醒权限,换主人时清空
This commit is contained in:
23 files changed
+1190
-32
No files matched your search
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { WakeListSection } from "./WakeListSection";
|
||||
import type { AllowlistEntry } from "@/lib/types";
|
||||
|
||||
const calls = vi.hoisted(() => ({ serverAllowlist: vi.fn(), setAllowlistWake: vi.fn() }));
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...calls } };
|
||||
});
|
||||
|
||||
const STEVE = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
const GHOST = "7c9e6679-7425-40de-944b-e07fc1f90ae7";
|
||||
const ALEX = "16fd2706-8baf-433b-82eb-8c7fada847da";
|
||||
const joined = new Date(Date.now() - 3 * 86400_000).toISOString();
|
||||
const entry = (mc_uuid: string, can_wake: boolean, username?: string): AllowlistEntry => ({
|
||||
mc_uuid,
|
||||
username,
|
||||
added_at: joined,
|
||||
can_wake,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
calls.serverAllowlist.mockReset();
|
||||
calls.setAllowlistWake.mockReset();
|
||||
});
|
||||
|
||||
function rowOf(label: string) {
|
||||
return screen.getByText(label).closest("li") as HTMLElement;
|
||||
}
|
||||
|
||||
describe("WakeListSection", () => {
|
||||
it("lists who may wake the server, marks revoked players and names unlinked ones", async () => {
|
||||
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
|
||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
|
||||
const steve = await screen.findByText("Steve");
|
||||
expect(calls.serverAllowlist).toHaveBeenCalledWith("lobby");
|
||||
const steveRow = steve.closest("li") as HTMLElement;
|
||||
expect(within(steveRow).queryByText("Revoked")).toBeNull();
|
||||
expect(within(steveRow).getByText("0f8fad5b").getAttribute("title")).toBe(STEVE);
|
||||
expect(within(steveRow).getByText("first joined 3 days ago")).toBeTruthy();
|
||||
expect(within(steveRow).getByRole("button", { name: "Take away Steve's right to wake the server" }).textContent).toBe(
|
||||
"Revoke",
|
||||
);
|
||||
|
||||
const ghostRow = rowOf("Player without a linked account");
|
||||
expect(within(ghostRow).getByText("Revoked")).toBeTruthy();
|
||||
expect(
|
||||
within(ghostRow).getByRole("button", { name: "Allow Player without a linked account to wake the server again" })
|
||||
.textContent,
|
||||
).toBe("Allow again");
|
||||
// The list is in effect: no note about the policy.
|
||||
expect(screen.queryByText(/this list has no effect/)).toBeNull();
|
||||
});
|
||||
|
||||
it("says the list has no effect under the other two policies", async () => {
|
||||
calls.serverAllowlist.mockResolvedValue([]);
|
||||
const owner = render(<WakeListSection name="lobby" policy="ownerOnly" defaultOpen />);
|
||||
expect(await screen.findByText(/Owner only, so only you and admins/)).toBeTruthy();
|
||||
expect(screen.getByText("No player has joined this server yet.")).toBeTruthy();
|
||||
owner.unmount();
|
||||
|
||||
render(<WakeListSection name="lobby" policy="public" defaultOpen />);
|
||||
expect(await screen.findByText(/Public, so any player's join starts it/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("revokes and restores in place, reading the list again after each change", async () => {
|
||||
const user = userEvent.setup();
|
||||
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
|
||||
calls.setAllowlistWake.mockResolvedValue(undefined);
|
||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
await screen.findByText("Steve");
|
||||
|
||||
calls.serverAllowlist.mockResolvedValue([entry(STEVE, false, "Steve"), entry(GHOST, false)]);
|
||||
await user.click(screen.getByRole("button", { name: "Take away Steve's right to wake the server" }));
|
||||
expect(calls.setAllowlistWake).toHaveBeenCalledWith("lobby", STEVE, false);
|
||||
expect((await screen.findByRole("status")).textContent).toBe("Steve can no longer wake the server.");
|
||||
expect(await within(rowOf("Steve")).findByText("Revoked")).toBeTruthy();
|
||||
expect(calls.serverAllowlist).toHaveBeenCalledTimes(2);
|
||||
|
||||
calls.serverAllowlist.mockResolvedValue([entry(STEVE, false, "Steve"), entry(GHOST, true)]);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Allow Player without a linked account to wake the server again" }),
|
||||
);
|
||||
expect(calls.setAllowlistWake).toHaveBeenLastCalledWith("lobby", GHOST, true);
|
||||
expect((await screen.findByRole("status")).textContent).toBe(
|
||||
"Player without a linked account can wake the server again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a refused change and a failed read", async () => {
|
||||
const user = userEvent.setup();
|
||||
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve")]);
|
||||
calls.setAllowlistWake.mockRejectedValue({ status: 403, code: "forbidden", message: "forbidden" });
|
||||
const first = render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
await user.click(await screen.findByRole("button", { name: "Take away Steve's right to wake the server" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("You are not allowed to do that.");
|
||||
expect(within(rowOf("Steve")).queryByText("Revoked")).toBeNull();
|
||||
first.unmount();
|
||||
|
||||
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
|
||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe("Couldn't load the wake list.");
|
||||
});
|
||||
|
||||
it("finds a player by account name or by UUID once the list is long", async () => {
|
||||
const user = userEvent.setup();
|
||||
const many = Array.from({ length: 9 }, (_, i) =>
|
||||
entry(`00000000-0000-0000-0000-00000000000${i}`, true, `Player${i}`),
|
||||
);
|
||||
calls.serverAllowlist.mockResolvedValue([...many, entry(ALEX, true, "Alex")]);
|
||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
await screen.findByText("Player0");
|
||||
// Ten a page: Alex, tenth, is on the first page until a search narrows it.
|
||||
const search = screen.getByPlaceholderText("Search players…");
|
||||
|
||||
await user.type(search, "alex");
|
||||
expect(screen.getByText("Alex")).toBeTruthy();
|
||||
expect(screen.queryByText("Player0")).toBeNull();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "8baf-433b");
|
||||
expect(screen.getByText("Alex")).toBeTruthy();
|
||||
expect(screen.queryByText("Player1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WakeListSection while a change is in flight", () => {
|
||||
it("holds every row's button until the change lands, so two cannot cross", async () => {
|
||||
const user = userEvent.setup();
|
||||
calls.serverAllowlist.mockResolvedValue([entry(STEVE, true, "Steve"), entry(GHOST, false)]);
|
||||
let land: () => void = () => {};
|
||||
calls.setAllowlistWake.mockReturnValue(new Promise<void>((resolve) => (land = resolve)));
|
||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
await user.click(await screen.findByRole("button", { name: "Take away Steve's right to wake the server" }));
|
||||
|
||||
const other = within(rowOf("Player without a linked account")).getByRole("button");
|
||||
expect((other as HTMLButtonElement).disabled).toBe(true);
|
||||
land();
|
||||
await screen.findByRole("status");
|
||||
expect((other as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlarmClock, Loader2, RotateCw } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { formatAbsolute, formatRelative } from "@/lib/format";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import type { AllowlistEntry, AutostartPolicy } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CollapsibleSection, FeedbackLine, PagerFooter, SearchBox, usePagedNames, type Feedback } from "./shared";
|
||||
|
||||
/** WakeListSection shows who may wake the server while it sleeps under the "wake
|
||||
* list" autostart policy, and lets the owner take that right away or give it back.
|
||||
* Felis keeps this list itself, so unlike the other player blocks it works whether
|
||||
* the server is running or not — which is when it matters most. A player lands on
|
||||
* it by joining once; a revoked row stays (marked) so a rejoin cannot undo the
|
||||
* owner's choice, which is also why the action is a reversible toggle and needs
|
||||
* no confirm step. */
|
||||
export function WakeListSection({
|
||||
name,
|
||||
policy,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
name: string;
|
||||
policy?: AutostartPolicy;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("servers");
|
||||
const { data, error, loading, reload } = useAsync(() => api.serverAllowlist(name), [name]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [fb, setFb] = useState<Feedback>(null);
|
||||
|
||||
const entries = useMemo(() => data ?? [], [data]);
|
||||
const label = useCallback((e: AllowlistEntry) => e.username || t("wake_list_unlinked"), [t]);
|
||||
// The shared list engine filters strings; each entry is searchable by its account
|
||||
// name and its UUID, and the key maps a page back to its rows.
|
||||
const byKey = useMemo(
|
||||
() => new Map(entries.map((e) => [`${e.username ?? ""}\u0000${e.mc_uuid}`, e])),
|
||||
[entries],
|
||||
);
|
||||
const keys = useMemo(() => [...byKey.keys()], [byKey]);
|
||||
const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } =
|
||||
usePagedNames(keys);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (e: AllowlistEntry) => {
|
||||
const player = label(e);
|
||||
setFb(null);
|
||||
setBusy(e.mc_uuid);
|
||||
try {
|
||||
await api.setAllowlistWake(name, e.mc_uuid, !e.can_wake);
|
||||
setFb({
|
||||
kind: "ok",
|
||||
msg: t(e.can_wake ? "wake_list_revoked" : "wake_list_restored", { player }),
|
||||
});
|
||||
reload();
|
||||
} catch (err) {
|
||||
setFb({ kind: "err", msg: humanizeError(err) });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[name, label, reload, t],
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const inactive = policy === "ownerOnly" || policy === "public";
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
icon={<AlarmClock className="h-4 w-4" />}
|
||||
title={t("wake_list_title")}
|
||||
count={!loading && !error ? entries.length : undefined}
|
||||
defaultOpen={defaultOpen}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("wake_list_desc")}</p>
|
||||
{inactive && (
|
||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
{t(policy === "public" ? "wake_list_inactive_public" : "wake_list_inactive_owner")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("wake_list_load_error")}
|
||||
</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("wake_list_empty")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">{t("wake_list_empty_hint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={cn("space-y-2 transition-opacity", loading && "pointer-events-none opacity-60")}>
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="grid grid-cols-1 gap-2.5 md:grid-cols-2">
|
||||
{shown.length === 0 ? (
|
||||
<li className="col-span-full py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
pageItems.map((key) => {
|
||||
const e = byKey.get(key)!;
|
||||
const player = label(e);
|
||||
return (
|
||||
<li
|
||||
key={e.mc_uuid}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg border border-border p-2.5 transition-colors",
|
||||
e.can_wake ? "bg-card/25 hover:bg-accent/40" : "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm font-medium",
|
||||
(!e.username || !e.can_wake) && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{player}
|
||||
</span>
|
||||
{!e.can_wake && (
|
||||
<Badge variant="muted" className="shrink-0 font-normal">
|
||||
{t("wake_list_revoked_badge")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate text-[11px] text-muted-foreground">
|
||||
<span className="font-mono" title={e.mc_uuid}>
|
||||
{e.mc_uuid.slice(0, 8)}
|
||||
</span>
|
||||
{" · "}
|
||||
<span title={formatAbsolute(e.added_at, i18n.language)}>
|
||||
{t("wake_list_joined", { when: formatRelative(e.added_at, now, i18n.language) })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={e.can_wake ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className={cn("h-8 shrink-0", e.can_wake && "text-muted-foreground hover:text-destructive")}
|
||||
onClick={() => toggle(e)}
|
||||
disabled={busy !== null}
|
||||
aria-label={t(e.can_wake ? "wake_list_revoke_aria" : "wake_list_restore_aria", { player })}
|
||||
>
|
||||
{busy === e.mc_uuid && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t(e.can_wake ? "wake_list_revoke" : "wake_list_restore")}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={entries.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"fleet_endpoint_idle": "Not running",
|
||||
"policy_owneronly": "Owner only",
|
||||
"policy_public": "Public",
|
||||
"policy_allowlist": "Allowlist",
|
||||
"policy_allowlist": "Wake list",
|
||||
"fleet_empty_title": "No servers",
|
||||
"fleet_empty_hint": "No MinecraftServer exists in the cluster yet.",
|
||||
"fleet_no_match_title": "No matches",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"log_jump_latest": "Jump to latest",
|
||||
"players_title": "Player management",
|
||||
"players_link_title": "Player management",
|
||||
"players_link_desc": "Manage the whitelist, online players, and bans.",
|
||||
"players_link_desc": "Manage the whitelist, online players, bans, and the wake list.",
|
||||
"luckperms_link_title": "LuckPerms Permissions",
|
||||
"luckperms_link_desc": "Manage player groups and fine-grained permission nodes.",
|
||||
"backups_link_title": "Backups & restore",
|
||||
@@ -111,6 +111,22 @@
|
||||
"access_cancel": "Cancel",
|
||||
"access_banned": "Banned {{player}}.",
|
||||
"access_pardoned": "Pardoned {{player}}.",
|
||||
"wake_list_title": "Wake list",
|
||||
"wake_list_desc": "With the autostart policy set to Wake list, a sleeping server can be started only by you, admins, and the players here who may wake it. A player is added on their first join. Once you take someone's wake right away, rejoining does not give it back; you can allow them again here. The list is emptied when the server changes owner.",
|
||||
"wake_list_inactive_owner": "The autostart policy is Owner only, so only you and admins can start it while it sleeps and this list has no effect. Switch it to Wake list under Edit server configuration on the console to use it.",
|
||||
"wake_list_inactive_public": "The autostart policy is Public, so any player's join starts it and this list has no effect. Switch it to Wake list under Edit server configuration on the console so only the players here can start it.",
|
||||
"wake_list_load_error": "Couldn't load the wake list.",
|
||||
"wake_list_empty": "No player has joined this server yet.",
|
||||
"wake_list_empty_hint": "Players show up here after their first join.",
|
||||
"wake_list_unlinked": "Player without a linked account",
|
||||
"wake_list_joined": "first joined {{when}}",
|
||||
"wake_list_revoked_badge": "Revoked",
|
||||
"wake_list_revoke": "Revoke",
|
||||
"wake_list_restore": "Allow again",
|
||||
"wake_list_revoke_aria": "Take away {{player}}'s right to wake the server",
|
||||
"wake_list_restore_aria": "Allow {{player}} to wake the server again",
|
||||
"wake_list_revoked": "{{player}} can no longer wake the server.",
|
||||
"wake_list_restored": "{{player}} can wake the server again.",
|
||||
"create_server_btn": "New server",
|
||||
"create_server_title": "Create a server",
|
||||
"create_server_desc": "Pick from whitelisted images and sizes — the platform provisions the rest. No raw cluster config is exposed here.",
|
||||
@@ -131,7 +147,7 @@
|
||||
"create_server_policy": "Autostart policy",
|
||||
"create_server_policy_owner": "Owner only — wake from the panel",
|
||||
"create_server_policy_public": "Public — any player join wakes it",
|
||||
"create_server_policy_allowlist": "Allowlist — listed players wake it",
|
||||
"create_server_policy_allowlist": "Wake list — players who have joined wake it",
|
||||
"create_server_cancel": "Cancel",
|
||||
"create_server_submit": "Create",
|
||||
"edit_server_title": "Edit Server Config",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"fleet_endpoint_idle": "未运行",
|
||||
"policy_owneronly": "仅所有者",
|
||||
"policy_public": "公开",
|
||||
"policy_allowlist": "白名单",
|
||||
"policy_allowlist": "唤醒名单",
|
||||
"fleet_empty_title": "暂无服务器",
|
||||
"fleet_empty_hint": "集群中尚未创建任何 MinecraftServer。",
|
||||
"fleet_no_match_title": "无匹配结果",
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"log_jump_latest": "滚动到最新",
|
||||
"players_title": "玩家管理",
|
||||
"players_link_title": "玩家管理",
|
||||
"players_link_desc": "管理白名单、在线玩家与封禁。",
|
||||
"players_link_desc": "管理白名单、在线玩家、封禁与唤醒名单。",
|
||||
"luckperms_link_title": "LuckPerms 权限",
|
||||
"luckperms_link_desc": "管理玩家用户组与细粒度权限节点。",
|
||||
"backups_link_title": "备份与恢复",
|
||||
@@ -111,6 +111,22 @@
|
||||
"access_cancel": "取消",
|
||||
"access_banned": "已封禁 {{player}}。",
|
||||
"access_pardoned": "已解封 {{player}}。",
|
||||
"wake_list_title": "唤醒名单",
|
||||
"wake_list_desc": "自动启动策略设为「唤醒名单」时,服务器休眠后只有你、管理员和这里能唤醒的玩家可以把它启动。玩家第一次进服会自动加入;取消某人的唤醒权限后,他再进服也不会恢复,需要时在这里重新允许。服务器换主人时名单清空。",
|
||||
"wake_list_inactive_owner": "当前自动启动策略是「仅所有者」,休眠时只有你和管理员能启动,这份名单暂不生效。在控制台的「编辑服务器配置」里改成「唤醒名单」后生效。",
|
||||
"wake_list_inactive_public": "当前自动启动策略是「公开」,任何玩家进服都能启动,这份名单暂不生效。在控制台的「编辑服务器配置」里改成「唤醒名单」后,只有名单里能唤醒的玩家可以启动。",
|
||||
"wake_list_load_error": "无法加载唤醒名单。",
|
||||
"wake_list_empty": "还没有玩家进过这台服务器。",
|
||||
"wake_list_empty_hint": "玩家第一次进服后会出现在这里。",
|
||||
"wake_list_unlinked": "未绑定账号的玩家",
|
||||
"wake_list_joined": "{{when}}首次进服",
|
||||
"wake_list_revoked_badge": "已取消",
|
||||
"wake_list_revoke": "取消唤醒",
|
||||
"wake_list_restore": "重新允许",
|
||||
"wake_list_revoke_aria": "取消 {{player}} 的唤醒权限",
|
||||
"wake_list_restore_aria": "重新允许 {{player}} 唤醒服务器",
|
||||
"wake_list_revoked": "已取消 {{player}} 的唤醒权限。",
|
||||
"wake_list_restored": "已重新允许 {{player}} 唤醒服务器。",
|
||||
"create_server_btn": "新建服务器",
|
||||
"create_server_title": "创建服务器",
|
||||
"create_server_desc": "从白名单镜像及规格中选择,平台自动处理其余配置。不暴露原始集群配置。",
|
||||
@@ -131,7 +147,7 @@
|
||||
"create_server_policy": "自动启动策略",
|
||||
"create_server_policy_owner": "仅所有者——通过面板启动",
|
||||
"create_server_policy_public": "公开——任意玩家加入即启动",
|
||||
"create_server_policy_allowlist": "白名单——仅名单内玩家可启动",
|
||||
"create_server_policy_allowlist": "唤醒名单——进过服、未被取消唤醒的玩家可启动",
|
||||
"create_server_cancel": "取消",
|
||||
"create_server_submit": "创建",
|
||||
"edit_server_title": "编辑服务器配置",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AccessResult,
|
||||
AllowlistEntry,
|
||||
ApiError,
|
||||
AutostartPolicy,
|
||||
BackupView,
|
||||
@@ -461,6 +462,18 @@ export const api = rejectingSync({
|
||||
accessKick: (name: string, player: string) =>
|
||||
request<KickResult>("POST", urlPath`/servers/${name}/access/kick`, { player }),
|
||||
|
||||
// Wake allowlist (GET/PUT /servers/{name}/allowlist): Felis's own record of who
|
||||
// may wake the server under autostartPolicy "allowlist". Owner-or-admin gated,
|
||||
// and unlike the access routes above it needs no running server.
|
||||
serverAllowlist: (name: string) =>
|
||||
request<{ server: string; entries: AllowlistEntry[] }>(
|
||||
"GET",
|
||||
urlPath`/servers/${name}/allowlist`,
|
||||
).then((r) => r.entries ?? []),
|
||||
|
||||
setAllowlistWake: (name: string, mcUUID: string, canWake: boolean) =>
|
||||
request<void>("PUT", urlPath`/servers/${name}/allowlist/${mcUUID}`, { can_wake: canWake }),
|
||||
|
||||
accessLuckPermsInfo: (name: string, player: string) =>
|
||||
request<{
|
||||
player: string;
|
||||
|
||||
@@ -649,6 +649,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/allowlist": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* The server's wake allowlist, newest first. Owner/admin only.
|
||||
* @description Who may wake the server while it sleeps under autostartPolicy=allowlist. A player lands here by joining the server once; entries whose wake right was taken away stay listed with can_wake false, so it can be given back. Felis keeps this list itself, so it answers whether the server is running or not. A change of owner (claim, reaper release, account deletion) empties it.
|
||||
*/
|
||||
get: operations["listAllowlist"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/allowlist/{uuid}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Take a player's wake right away or give it back. Owner/admin only.
|
||||
* @description can_wake false keeps the entry on the list with its wake right revoked, so the player's next join does not restore it; true gives it back. Repeating either is harmless. Audited as allowlist.revoke / allowlist.restore.
|
||||
*/
|
||||
put: operations["setAllowlistWake"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2378,6 +2418,20 @@ export interface components {
|
||||
/** @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 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 */
|
||||
mc_uuid: string;
|
||||
/** @description The live Felis account the UUID is linked to; omitted when there is none (the account was closed or the link removed). */
|
||||
username?: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description The player's first join.
|
||||
*/
|
||||
added_at: string;
|
||||
/** @description False once the owner or an admin took the wake right away. */
|
||||
can_wake: boolean;
|
||||
};
|
||||
/** @description One row of the caller's server list (internal/api/repo.go MyServerView). */
|
||||
MyServerView: {
|
||||
name: string;
|
||||
@@ -4311,6 +4365,74 @@ export interface operations {
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
listAllowlist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The allowlist. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
server: string;
|
||||
entries: components["schemas"]["AllowlistEntry"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
setAllowlistWake: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
uuid: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
can_wake: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Changed. */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description No such server, or the UUID is not on its allowlist. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
status: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -34,6 +34,7 @@ type Holds<X extends true> = X;
|
||||
export type WireParity = [
|
||||
Holds<Parity<T.ServerStatus, S["ServerInfo"]>>,
|
||||
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
|
||||
Holds<Parity<T.AllowlistEntry, S["AllowlistEntry"]>>,
|
||||
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
|
||||
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
||||
Holds<Parity<T.Build, S["Build"]>>,
|
||||
|
||||
@@ -104,6 +104,17 @@ export interface BanlistResult {
|
||||
output: string;
|
||||
}
|
||||
|
||||
/** AllowlistEntry is one row of GET /servers/{name}/allowlist: a player who joined
|
||||
* the server, and so may wake it under autostartPolicy "allowlist" unless the owner
|
||||
* took that away (can_wake false; the row stays so a rejoin cannot undo it).
|
||||
* username is the live Felis account the UUID is linked to, absent when none. */
|
||||
export interface AllowlistEntry {
|
||||
mc_uuid: string;
|
||||
username?: string;
|
||||
added_at: string;
|
||||
can_wake: boolean;
|
||||
}
|
||||
|
||||
/** AccessResult is the common echo of a successful access mutation (whitelist add/
|
||||
* remove, ban/pardon): the server replays the structured action it ran plus the
|
||||
* raw RCON `output`, which the panel surfaces verbatim as confirmation. */
|
||||
|
||||
@@ -22,6 +22,12 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
vi.mock("@/components/players/OnlineSection", () => ({ OnlineSection: () => <div data-testid="online" /> }));
|
||||
vi.mock("@/components/players/WhitelistSection", () => ({ WhitelistSection: () => <div /> }));
|
||||
vi.mock("@/components/players/BansSection", () => ({ BansSection: () => <div /> }));
|
||||
// The wake list reads Postgres, so the page shows it whether the server runs or not.
|
||||
vi.mock("@/components/players/WakeListSection", () => ({
|
||||
WakeListSection: (p: { policy?: string; defaultOpen?: boolean }) => (
|
||||
<div data-testid="wake-list" data-policy={p.policy} data-open={String(!!p.defaultOpen)} />
|
||||
),
|
||||
}));
|
||||
|
||||
const status = (over: Record<string, unknown>) => ({ name: "lobby", subdomain: "lobby", ready: false, ...over });
|
||||
|
||||
@@ -47,14 +53,17 @@ function renderPage() {
|
||||
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" }));
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped", autostartPolicy: "allowlist" }));
|
||||
renderPage();
|
||||
expect(await screen.findByText("Server is asleep")).toBeTruthy();
|
||||
// Asleep, the wake list is the one block left, so it opens.
|
||||
expect(screen.getByTestId("wake-list").dataset).toMatchObject({ policy: "allowlist", open: "true" });
|
||||
|
||||
// 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();
|
||||
expect(screen.getByTestId("wake-list").dataset.open).toBe("false");
|
||||
|
||||
// Running: the slow pace, which still sees an idle stop.
|
||||
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
|
||||
|
||||
@@ -8,13 +8,15 @@ import { PageHeader } from "@/components/PageHeader";
|
||||
import { OnlineSection } from "@/components/players/OnlineSection";
|
||||
import { WhitelistSection } from "@/components/players/WhitelistSection";
|
||||
import { BansSection } from "@/components/players/BansSection";
|
||||
import { WakeListSection } from "@/components/players/WakeListSection";
|
||||
import { api } from "@/lib/api";
|
||||
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";
|
||||
|
||||
/** 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 —
|
||||
* the online roster, whitelist and bans over RCON while the server runs, and the wake
|
||||
* list, which Felis keeps itself, at any time. It owns its own gating —
|
||||
* ownership (from /me/servers, since GET status never carries `owned`) and server
|
||||
* readiness — because as a route it can be reached directly, not just from a link. */
|
||||
export function ServerPlayers() {
|
||||
@@ -88,23 +90,30 @@ 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}
|
||||
phase={data.phase}
|
||||
desiredState={data.desiredState}
|
||||
failure={failure}
|
||||
autoRestarts={data.autoRestarts}
|
||||
onWoken={reload}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<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}
|
||||
/>
|
||||
{/* The wake list is Felis's own record, so it stays manageable while the
|
||||
server sleeps, which is when it decides who may start it. */}
|
||||
<WakeListSection name={name} policy={data.autostartPolicy} defaultOpen />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Ordered as a who-may-be-here gradient: who is on right now → who may
|
||||
join → who may NOT. Each collapses to an index row (shared.tsx). */}
|
||||
join → who may NOT, then who may wake it while it sleeps. Each
|
||||
collapses to an index row (shared.tsx). */}
|
||||
<OnlineSection name={name} />
|
||||
<WhitelistSection name={name} />
|
||||
<BansSection name={name} />
|
||||
<WakeListSection name={name} policy={data.autostartPolicy} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user