feat(panel): implement admin maintenance window settings page

This commit is contained in:
Lemon-miaow committed 2026-07-03 02:46:50 +08:00
1 parent 55592ed123
commit 804459c08d
11 files changed
+462 -4

No files matched your search

+36
View File
@@ -55,6 +55,7 @@ interface MockState {
builds: Build[]; builds: Build[];
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>; passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
submissions: Submission[]; submissions: Submission[];
updateWindow: { start: string | null; end: string | null };
} }
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock // PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
@@ -333,6 +334,7 @@ function initialState(): MockState {
reviewed_at: new Date(Date.now() - 10700000).toISOString(), reviewed_at: new Date(Date.now() - 10700000).toISOString(),
}, },
], ],
updateWindow: { start: null, end: null },
}; };
} }
@@ -630,6 +632,40 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
async function handleSession(ctx: SessionContext): Promise<boolean> { async function handleSession(ctx: SessionContext): Promise<boolean> {
switch (route(ctx)) { switch (route(ctx)) {
case "GET updates/window":
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
sendJSON(ctx.res, 200, ctx.state.updateWindow);
return true;
case "PUT updates/window": {
if (ctx.account.role !== "admin") {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const body = await readJSON<{ start: string | null; end: string | null }>(ctx.req);
if (body.start === null && body.end === null) {
ctx.state.updateWindow = { start: null, end: null };
} else if (body.start === null || body.end === null) {
sendError(ctx.res, 400, "bad_request", "start and end must both be set or both be null");
return true;
} else {
const start = new Date(body.start);
const end = new Date(body.end);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
sendError(ctx.res, 400, "bad_request", "invalid start or end time format");
return true;
}
if (end <= start) {
sendError(ctx.res, 400, "bad_request", "end must be after start");
return true;
}
ctx.state.updateWindow = { start: body.start, end: body.end };
}
sendJSON(ctx.res, 200, ctx.state.updateWindow);
return true;
}
case "GET me/servers": case "GET me/servers":
sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) }); sendJSON(ctx.res, 200, { servers: visibleServers(ctx.state, ctx.account) });
return true; return true;
+2
View File
@@ -16,6 +16,7 @@ import { ImageAdmin } from "@/pages/admin/ImageAdmin";
import { ImageBuildPage } from "@/pages/admin/ImageBuildPage"; import { ImageBuildPage } from "@/pages/admin/ImageBuildPage";
import { SubmissionsPage } from "@/pages/admin/SubmissionsPage"; import { SubmissionsPage } from "@/pages/admin/SubmissionsPage";
import { MySubmissionsPage } from "@/pages/MySubmissionsPage"; import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES): // Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
// / User-Side — app-tier, every authenticated principal // / User-Side — app-tier, every authenticated principal
@@ -57,6 +58,7 @@ export default function App() {
<Route path="images" element={<ImageAdmin />} /> <Route path="images" element={<ImageAdmin />} />
<Route path="builds" element={<ImageBuildPage />} /> <Route path="builds" element={<ImageBuildPage />} />
<Route path="submissions" element={<SubmissionsPage />} /> <Route path="submissions" element={<SubmissionsPage />} />
<Route path="updates" element={<UpdatesPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+24 -1
View File
@@ -71,5 +71,28 @@
"no_submissions_hint": "There are currently no pending or reviewed modpack submissions.", "no_submissions_hint": "There are currently no pending or reviewed modpack submissions.",
"reviewer": "Reviewer", "reviewer": "Reviewer",
"reviewed_at": "Reviewed At", "reviewed_at": "Reviewed At",
"reject_reason": "Rejection Reason" "reject_reason": "Rejection Reason",
"updates_title": "Maintenance Window",
"updates_subtitle": "Configure the platform-wide maintenance window. A Scheduled auto-update component may only be applied by Felis within this window; outside it, updates are notify-only.",
"updates_current_title": "Current Setting",
"updates_current_unset": "No maintenance window set. Scheduled updates will degrade to notify-only and will not be applied automatically.",
"updates_current_set": "Felis may apply auto-updates between the following period:",
"updates_start_label": "Start Time",
"updates_end_label": "End Time",
"updates_set_title": "Configure Maintenance Window",
"updates_submit_btn": "Save Maintenance Window",
"updates_clear_btn": "Clear Maintenance Window",
"updates_save_success": "Maintenance window updated successfully.",
"updates_clear_success": "Maintenance window cleared successfully.",
"updates_validation_both": "Both start and end times must be set, or both cleared.",
"updates_validation_order": "End time must be strictly after start time.",
"updates_status_active": "Currently Active",
"updates_status_pending": "Scheduled (Future)",
"updates_status_expired": "Expired (Past)",
"updates_status_unset": "Unset",
"updates_timezone_hint": "Times are selected and displayed in your browser's local time zone ({{tz}}).",
"updates_stat_status": "System Status",
"updates_stat_start": "Start Time",
"updates_stat_end": "End Time",
"updates_stat_timezone": "Local Timezone"
} }
@@ -6,5 +6,6 @@
"admin_section": "Admin", "admin_section": "Admin",
"admin_images": "Images", "admin_images": "Images",
"admin_builds": "Build Pipeline", "admin_builds": "Build Pipeline",
"admin_submissions": "Submissions" "admin_submissions": "Submissions",
"admin_updates": "Maintenance Window"
} }
+24 -1
View File
@@ -71,5 +71,28 @@
"no_submissions_hint": "当前没有待审核或已审核的模组包提交。", "no_submissions_hint": "当前没有待审核或已审核的模组包提交。",
"reviewer": "审核人", "reviewer": "审核人",
"reviewed_at": "审核时间", "reviewed_at": "审核时间",
"reject_reason": "驳回理由" "reject_reason": "驳回理由",
"updates_title": "维护窗口",
"updates_subtitle": "配置全局系统维护窗口。在此窗口内,Felis 可以自动应用系统更新;在窗口外,更新将降级为仅通知,不会自动执行。",
"updates_current_title": "当前设置",
"updates_current_unset": "当前未设置维护窗口。自动更新将降级为仅通知,不会自动执行。",
"updates_current_set": "Felis 可在以下时间段内自动执行更新:",
"updates_start_label": "开始时间",
"updates_end_label": "结束时间",
"updates_set_title": "配置维护窗口",
"updates_submit_btn": "保存维护窗口",
"updates_clear_btn": "清除维护窗口",
"updates_save_success": "维护窗口更新成功。",
"updates_clear_success": "维护窗口已清除。",
"updates_validation_both": "开始时间和结束时间必须同时设置,或同时清空。",
"updates_validation_order": "结束时间必须在开始时间之后。",
"updates_status_active": "当前生效中",
"updates_status_pending": "已计划(未来)",
"updates_status_expired": "已过期(过去)",
"updates_status_unset": "未设置",
"updates_timezone_hint": "时间以您的浏览器本地时区 ({{tz}}) 选择和显示。",
"updates_stat_status": "系统状态",
"updates_stat_start": "维护开始时间",
"updates_stat_end": "维护结束时间",
"updates_stat_timezone": "本地时区"
} }
@@ -6,5 +6,6 @@
"admin_section": "管理", "admin_section": "管理",
"admin_images": "镜像", "admin_images": "镜像",
"admin_builds": "构建流水线", "admin_builds": "构建流水线",
"admin_submissions": "审核提交" "admin_submissions": "审核提交",
"admin_updates": "维护窗口"
} }
+26
View File
@@ -482,4 +482,30 @@ describe("image whitelist and builds wire shapes", () => {
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" }); expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" });
}); });
}); });
describe("updates maintenance window", () => {
it("getUpdateWindow GETs from /updates/window", async () => {
const win = { start: "2026-07-03T12:00:00Z", end: "2026-07-03T14:00:00Z" };
const fetchSpy = fakeFetch(win);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.getUpdateWindow();
expect(res).toEqual(win);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/updates/window");
expect((opts as RequestInit).method).toBe("GET");
});
it("setUpdateWindow PUTs UpdateWindow payload to /updates/window", async () => {
const win = { start: "2026-07-03T12:00:00Z", end: "2026-07-03T14:00:00Z" };
const fetchSpy = fakeFetch(win);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.setUpdateWindow(win);
expect(res).toEqual(win);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/updates/window");
expect((opts as RequestInit).method).toBe("PUT");
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/json" });
expect(JSON.parse((opts as RequestInit).body as string)).toEqual(win);
});
});
}); });
+5
View File
@@ -18,6 +18,7 @@ import type {
WhitelistImage, WhitelistImage,
WhitelistResult, WhitelistResult,
Submission, Submission,
UpdateWindow,
} from "./types"; } from "./types";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
import i18next from "i18next"; import i18next from "i18next";
@@ -301,6 +302,10 @@ export const api = {
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, { requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
"Content-Type": "application/x-gzip", "Content-Type": "application/x-gzip",
}), }),
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
setUpdateWindow: (window: UpdateWindow) => request<UpdateWindow>("PUT", "/updates/window", window),
}; };
/** /**
+2
View File
@@ -6,6 +6,7 @@ import {
Cpu, Cpu,
ClipboardCheck, ClipboardCheck,
Upload, Upload,
Clock,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -57,6 +58,7 @@ export const NAV_SECTIONS: NavSection[] = [
{ to: "/admin/images", key: "admin_images", icon: Boxes }, { to: "/admin/images", key: "admin_images", icon: Boxes },
{ to: "/admin/builds", key: "admin_builds", icon: Cpu }, { to: "/admin/builds", key: "admin_builds", icon: Cpu },
{ to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck }, { to: "/admin/submissions", key: "admin_submissions", icon: ClipboardCheck },
{ to: "/admin/updates", key: "admin_updates", icon: Clock },
], ],
}, },
]; ];
+5
View File
@@ -269,3 +269,8 @@ export interface Submission {
created_at: string; created_at: string;
reviewed_at?: string; reviewed_at?: string;
} }
export interface UpdateWindow {
start: string | null;
end: string | null;
}
+334
View File
@@ -0,0 +1,334 @@
import React, { useState, useEffect } from "react";
import { Clock, Loader2, Calendar, AlertCircle, CheckCircle2, Globe, LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute } from "@/lib/format";
import { cn } from "@/lib/utils";
function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string {
if (!dateOrStr) return "";
const d = new Date(dateOrStr);
if (isNaN(d.getTime())) return "";
const YYYY = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, "0");
const DD = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${YYYY}-${MM}-${DD}T${hh}:${mm}`;
}
function StatCard({
icon: Icon,
label,
value,
accentClass,
valueClass,
}: {
icon: LucideIcon;
label: string;
value: React.ReactNode;
accentClass?: string;
valueClass?: string;
}) {
return (
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn("rounded-md p-2 bg-muted/30", accentClass || "text-muted-foreground")}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className={cn("text-sm sm:text-base font-bold font-mono leading-tight truncate", valueClass || "text-foreground")} title={typeof value === "string" ? value : undefined}>
{value}
</div>
<div className="mt-1 text-[11px] text-muted-foreground font-medium">{label}</div>
</div>
</CardContent>
</Card>
);
}
export function UpdatesPage() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const { data, error, loading, reload } = useAsync(() => api.getUpdateWindow(), []);
const [startVal, setStartVal] = useState("");
const [endVal, setEndVal] = useState("");
const [submitting, setSubmitting] = useState(false);
const [clearing, setClearing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
useEffect(() => {
if (data) {
setStartVal(toLocalDatetimeString(data.start));
setEndVal(toLocalDatetimeString(data.end));
}
}, [data]);
const windowStatus = (() => {
if (!data || !data.start || !data.end) return "unset";
const now = new Date();
const start = new Date(data.start);
const end = new Date(data.end);
if (now < start) return "pending";
if (now >= start && now < end) return "active";
return "expired";
})();
async function handleClear() {
if (clearing) return;
setClearing(true);
setActionError(null);
setSuccessMsg(null);
try {
await api.setUpdateWindow({ start: null, end: null });
setStartVal("");
setEndVal("");
setSuccessMsg(t("updates_clear_success"));
reload();
} catch (err) {
setActionError(humanizeError(err));
} finally {
setClearing(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (submitting) return;
setActionError(null);
setSuccessMsg(null);
if (!startVal && !endVal) {
handleClear();
return;
}
if (!startVal || !endVal) {
setActionError(t("updates_validation_both"));
return;
}
const startD = new Date(startVal);
const endD = new Date(endVal);
if (endD <= startD) {
setActionError(t("updates_validation_order"));
return;
}
setSubmitting(true);
try {
await api.setUpdateWindow({
start: startD.toISOString(),
end: endD.toISOString(),
});
setSuccessMsg(t("updates_save_success"));
reload();
} catch (err) {
setActionError(humanizeError(err));
} finally {
setSubmitting(false);
}
}
if (loading && !data) {
return <Loading />;
}
if (error) {
return <ErrorState error={error} onRetry={reload} />;
}
const statusText = (() => {
switch (windowStatus) {
case "active":
return t("updates_status_active");
case "pending":
return t("updates_status_pending");
case "expired":
return t("updates_status_expired");
default:
return t("updates_status_unset");
}
})();
const statusColorClass = (() => {
switch (windowStatus) {
case "active":
return "text-emerald-500";
case "pending":
return "text-blue-500";
case "expired":
return "text-zinc-500";
default:
return "text-amber-500";
}
})();
const statusAccentClass = (() => {
switch (windowStatus) {
case "active":
return "text-emerald-500 bg-emerald-500/10";
case "pending":
return "text-blue-500 bg-blue-500/10";
case "expired":
return "text-zinc-500 bg-zinc-500/10";
default:
return "text-amber-500 bg-amber-500/10";
}
})();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Clock className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t("updates_title")}</h1>
<p className="text-sm text-muted-foreground">
{t("updates_subtitle")}
</p>
</div>
</div>
{/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard
icon={Clock}
label={t("updates_stat_status")}
value={statusText}
accentClass={statusAccentClass}
valueClass={statusColorClass}
/>
<StatCard
icon={Calendar}
label={t("updates_stat_start")}
value={data?.start ? formatAbsolute(data.start, locale) : "—"}
accentClass="text-primary bg-primary/10"
/>
<StatCard
icon={Calendar}
label={t("updates_stat_end")}
value={data?.end ? formatAbsolute(data.end, locale) : "—"}
accentClass="text-primary bg-primary/10"
/>
<StatCard
icon={Globe}
label={t("updates_stat_timezone")}
value={tz}
accentClass="text-primary bg-primary/10"
/>
</div>
{/* Configuration Card */}
<Card className="w-full">
<CardHeader>
<CardTitle className="text-base font-semibold">{t("updates_set_title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6 text-sm">
{/* Unset hint warning */}
{windowStatus === "unset" && (
<p className="text-muted-foreground bg-muted/15 border border-dashed border-border rounded-lg p-4 leading-relaxed">
{t("updates_current_unset")}
</p>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="startTime" className="text-xs font-semibold text-muted-foreground">{t("updates_start_label")} *</Label>
<Input
id="startTime"
type="datetime-local"
value={startVal}
onChange={(e) => setStartVal(e.target.value)}
disabled={submitting || clearing}
className="w-full"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="endTime" className="text-xs font-semibold text-muted-foreground">{t("updates_end_label")} *</Label>
<Input
id="endTime"
type="datetime-local"
value={endVal}
onChange={(e) => setEndVal(e.target.value)}
disabled={submitting || clearing}
className="w-full"
required
/>
</div>
</div>
<div className="text-[11px] text-muted-foreground leading-normal bg-muted/15 border border-border/40 rounded p-3">
{t("updates_timezone_hint", { tz })}
</div>
{actionError && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<p>{actionError}</p>
</div>
)}
{successMsg && (
<div className="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm text-emerald-500">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<p>{successMsg}</p>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<Button
type="submit"
disabled={submitting || clearing}
className="text-xs px-4"
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
{t("common:creating", "Saving...")}
</>
) : (
t("updates_submit_btn")
)}
</Button>
{windowStatus !== "unset" && (
<Button
type="button"
variant="outline"
className="text-xs px-4 text-destructive hover:text-destructive hover:bg-destructive/10 border-input hover:border-destructive/20"
disabled={submitting || clearing}
onClick={handleClear}
>
{clearing ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
{t("common:creating", "Processing...")}
</>
) : (
t("updates_clear_btn")
)}
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}