feat(schedules): 每台服务器可设计划任务,按星期和时区定时发命令、重启、停服、开服或备份,重启和备份前可在游戏内提醒

This commit is contained in:
Lemon-miaow committed 2026-09-27 22:48:49 +08:00
1 parent e8ff8f2fe5
commit 34b81ee8fe
32 files changed
+6011 -27

No files matched your search

+4
View File
@@ -34,6 +34,9 @@ const ServerBackups = lazyWithReload(() =>
const ServerFiles = lazyWithReload(() =>
import("@/pages/ServerFiles").then((m) => ({ default: m.ServerFiles })),
);
const ServerSchedules = lazyWithReload(() =>
import("@/pages/ServerSchedules").then((m) => ({ default: m.ServerSchedules })),
);
const ServerLuckPerms = lazyWithReload(() =>
import("@/pages/ServerLuckPerms").then((m) => ({ default: m.ServerLuckPerms })),
);
@@ -107,6 +110,7 @@ export default function App() {
<Route path="players" element={<ServerPlayers />} />
<Route path="backups" element={<ServerBackups />} />
<Route path="files" element={<ServerFiles />} />
<Route path="schedules" element={<ServerSchedules />} />
<Route path="luckperms" element={<ServerLuckPerms />} />
</Route>
<Route path="submissions" element={<MySubmissionsPage />} />
+4
View File
@@ -13,6 +13,7 @@ import enNavigation from "./resources/en-US/navigation.json";
import enBackups from "./resources/en-US/backups.json";
import enSubmissions from "./resources/en-US/submissions.json";
import enFiles from "./resources/en-US/files.json";
import enSchedules from "./resources/en-US/schedules.json";
import zhCommon from "./resources/zh-CN/common.json";
import zhAuth from "./resources/zh-CN/auth.json";
import zhDashboard from "./resources/zh-CN/dashboard.json";
@@ -25,6 +26,7 @@ import zhNavigation from "./resources/zh-CN/navigation.json";
import zhBackups from "./resources/zh-CN/backups.json";
import zhSubmissions from "./resources/zh-CN/submissions.json";
import zhFiles from "./resources/zh-CN/files.json";
import zhSchedules from "./resources/zh-CN/schedules.json";
import { panelLanguage, SUPPORTED_LANGUAGES } from "./language";
// Exported so a test can boot a fresh instance with the same detection.
@@ -43,6 +45,7 @@ export const i18nOptions: InitOptions = {
backups: enBackups,
submissions: enSubmissions,
files: enFiles,
schedules: enSchedules,
},
"zh-CN": {
common: zhCommon,
@@ -57,6 +60,7 @@ export const i18nOptions: InitOptions = {
backups: zhBackups,
submissions: zhSubmissions,
files: zhFiles,
schedules: zhSchedules,
},
},
supportedLngs: [...SUPPORTED_LANGUAGES],
+6 -1
View File
@@ -118,5 +118,10 @@
"bad_request": "The request was not accepted: {{detail}}",
"restore_in_progress": "Another backup is still being restored to this server's world. Try again once it finishes.",
"uploads_full": "The upload store is full. An admin has to delete reviewed submissions before new uploads fit.",
"unsupported_media_type": "The request was sent in a format the server does not accept. Reload the page and try again."
"unsupported_media_type": "The request was sent in a format the server does not accept. Reload the page and try again.",
"schedules_unavailable": "Scheduled tasks aren't available right now.",
"schedule_limit": "This server already has as many scheduled tasks as it can hold. Delete one first.",
"schedule_running": "This task is running right now. Try again once the run finishes.",
"schedule_stale": "The server has a new owner since this task was saved. Save the task again before running it.",
"bad_schedule": "The task was not saved: {{detail}}"
}
@@ -0,0 +1,159 @@
{
"title": "Scheduled tasks",
"subtitle": "Restart, stop, start or back up this server, or send its console a command, at set times.",
"back_to_console": "Back to console",
"not_yours_title": "No permission to manage scheduled tasks",
"not_yours_body": "Only the owner or an admin can view and change this server's scheduled tasks.",
"new_task": "New task",
"count": "{{count}} of {{limit}} tasks",
"limit_reached": "A server holds at most {{limit}} tasks. Delete one to add another.",
"unavailable_title": "Scheduled tasks aren't available",
"unavailable_body": "This felis-api runs without a schedule store, so tasks can't be listed or saved here. Ask an administrator to check the felis-api setup.",
"empty_title": "No scheduled tasks yet",
"empty_hint": "Start from a common one, or build your own with New task.",
"template_restart": "Restart every night at 04:00",
"template_backup": "Back up every night at 05:00",
"template_announce": "Announce in chat every 30 minutes",
"template_announce_command": "say Welcome to the server!",
"action_command": "Console command",
"action_command_desc": "Send one command to the server console, as if typed there. Runs only while the server is running.",
"action_restart": "Restart",
"action_restart_desc": "Stop the server and start it again. Online players are disconnected. Skipped while it is stopped.",
"action_stop": "Stop",
"action_stop_desc": "Stop the server. Online players are disconnected.",
"action_start": "Start",
"action_start_desc": "Start the server when it is stopped.",
"action_backup": "Backup",
"action_backup_desc": "Back up the world. A running server is stopped first and started again afterwards.",
"days_all": "Every day",
"days_weekdays": "Weekdays",
"days_weekend": "Weekends",
"day_sep": ", ",
"day_0": "Sun",
"day_1": "Mon",
"day_2": "Tue",
"day_3": "Wed",
"day_4": "Thu",
"day_5": "Fri",
"day_6": "Sat",
"day_chip_0": "Sun",
"day_chip_1": "Mon",
"day_chip_2": "Tue",
"day_chip_3": "Wed",
"day_chip_4": "Thu",
"day_chip_5": "Fri",
"day_chip_6": "Sat",
"day_full_0": "Sunday",
"day_full_1": "Monday",
"day_full_2": "Tuesday",
"day_full_3": "Wednesday",
"day_full_4": "Thursday",
"day_full_5": "Friday",
"day_full_6": "Saturday",
"first_day": "0",
"when_daily": "{{days}} at {{time}}",
"when_interval_days": "{{every}} · {{days}}",
"every_minutes_one": "Every minute",
"every_minutes_other": "Every {{count}} minutes",
"every_hours_one": "Every hour",
"every_hours_other": "Every {{count}} hours",
"opt_minutes_one": "{{count}} minute",
"opt_minutes_other": "{{count}} minutes",
"opt_hours_one": "{{count}} hour",
"opt_hours_other": "{{count}} hours",
"create_title": "New scheduled task",
"edit_title": "Edit scheduled task",
"dialog_desc": "Choose what runs and when.",
"create": "Create",
"save": "Save",
"field_action": "What to do",
"field_command": "Command",
"command_placeholder": "say The server restarts at 04:00",
"command_hint": "One console command. The leading / is optional.",
"field_when": "When",
"mode_daily": "Once a day",
"mode_interval": "Repeat",
"field_time": "Time",
"field_every": "Repeat every",
"interval_preview": "Counted from 00:00 each day: {{times}}",
"interval_hourly_limit": "A restart, stop, start or backup repeats at most once an hour.",
"field_days": "Days",
"field_timezone": "Time zone",
"timezone_hint": "Times are read in this zone. Your browser is in {{zone}}.",
"timezone_use_browser": "Use the browser's zone",
"field_warn": "Warn players",
"warn_none": "No warning",
"warn_option_one": "{{count}} minute before",
"warn_option_other": "{{count}} minutes before",
"warn_hint": "Posts a message in game chat ahead of the stop, in Chinese and English, while the server is running.",
"backup_note": "A backup of a running server stops it (online players are disconnected), backs up the world and starts it again, which usually takes a few minutes. A stopped server is backed up and stays stopped.",
"field_label": "Name (optional)",
"label_placeholder": "Nightly restart",
"field_enabled": "Enabled",
"enabled_hint": "Runs on schedule. Switch it off to keep the task without running it.",
"err_command_required": "Enter a command.",
"err_command_long": "The command is longer than {{max}} bytes.",
"err_time_required": "Pick a time.",
"err_days": "Pick at least one day.",
"err_timezone_required": "Enter a time zone.",
"err_timezone": "Unknown time zone. Use a name such as Asia/Shanghai.",
"err_label_long": "The name is longer than {{max}} characters.",
"next_run": "Next run {{when}}",
"next_run_off": "Off: it runs only when you press Run now",
"never_run": "Hasn't run yet",
"last_run": "Last run {{when}}",
"run_started_at": "Started {{when}}",
"result_ok": "Succeeded",
"result_skipped": "Skipped",
"result_failed": "Failed",
"result_missed": "Missed",
"reply": "Reply: {{text}}",
"run_state_claimed": "Running…",
"run_state_stopping": "Stopping the server…",
"run_state_backing_up": "Backing up…",
"run_state_starting": "Starting the server…",
"warn_badge": "Warns {{count}} min before",
"off_badge": "Off",
"tz_differs": "Times are in {{zone}}; your browser is in {{browser}}.",
"toggle_label": "Run {{name}} on schedule",
"run_now": "Run now",
"edit": "Edit",
"delete": "Delete",
"busy_hint": "This task is running. Wait for the run to finish.",
"owner_changed": "The server changed owner, so this task switched itself off. Check it, then save it or switch it on to use it again.",
"detail_not_running": "The server was not running.",
"detail_already_stopped": "The server was already stopped.",
"detail_already_running": "The server was already running.",
"detail_gone": "The server no longer exists.",
"detail_no_world": "The server has no world yet.",
"detail_missed": "felis-api was not running at the scheduled time.",
"detail_started_meanwhile": "Someone started the server before the run finished.",
"detail_interrupted": "felis-api stopped in the middle of this run.",
"detail_store_full": "The backup store is full. Ask an administrator to free space.",
"detail_console": "The server console could not be reached.",
"detail_cap": "The cluster is at its running-server cap.",
"detail_retiring": "The server is being given up or deleted.",
"run_title": "Run “{{name}}” now?",
"run_confirm": "Run now",
"run_body_command": "Sends this command to the server console now (skipped while the server is stopped):",
"run_body_restart": "Stops the server now and starts it again.",
"run_body_stop": "Stops the server now.",
"run_body_start": "Starts the server now if it is stopped.",
"run_body_backup": "Backs up the world now. A running server is stopped, backed up and started again; a stopped server stays stopped.",
"run_kick": "Online players are disconnected right away: a run started by hand skips the players' warning.",
"run_keeps_next": "The next scheduled run stays as planned.",
"delete_title": "Delete “{{name}}”?",
"delete_body": "The task is removed and won't run again.",
"delete_confirm": "Delete",
"created": "Created “{{name}}”.",
"saved": "Saved “{{name}}”.",
"deleted": "Deleted “{{name}}”.",
"run_started": "Started “{{name}}”.",
"toggled_on": "“{{name}}” is on.",
"toggled_off": "“{{name}}” is off.",
"notes_title": "How scheduled tasks run",
"note_timing": "felis-api checks every 15 seconds, so a task starts within about 15 seconds of its time. A run more than 10 minutes late (felis-api was down) is recorded as missed, and the next one runs as usual.",
"note_state": "A command, restart or stop runs only while the server is running, and a start only while it is stopped; otherwise the run is skipped.",
"note_owner": "When the server changes owner, its tasks switch off. Saving a task hands it to the current owner.",
"note_run_now": "Run now also works on a task that is off. It skips the players' warning and keeps the next scheduled run."
}
@@ -61,6 +61,8 @@
"backups_link_desc": "View and restore world backups for this server.",
"files_link_title": "Server files",
"files_link_desc": "Browse and repair files in the world volume.",
"schedules_link_title": "Scheduled tasks",
"schedules_link_desc": "Restart, back up or run commands at set times.",
"system_service": "System service",
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
"players_back_to_console": "Back to console",
+6 -1
View File
@@ -118,5 +118,10 @@
"bad_request": "请求未被接受:{{detail}}",
"restore_in_progress": "这台服务器的世界正在恢复另一份备份,请等它完成后再试。",
"uploads_full": "上传存储已满,需要管理员删除已审核的提交后才能继续上传。",
"unsupported_media_type": "请求格式不被服务器接受,请刷新页面后重试。"
"unsupported_media_type": "请求格式不被服务器接受,请刷新页面后重试。",
"schedules_unavailable": "计划任务当前不可用。",
"schedule_limit": "这台服务器的计划任务数量已达上限,请先删除一个。",
"schedule_running": "这个任务正在执行,请等本次执行结束后再试。",
"schedule_stale": "保存这个任务后服务器换了所有者,请先重新保存任务再执行。",
"bad_schedule": "计划任务未保存:{{detail}}"
}
@@ -0,0 +1,154 @@
{
"title": "计划任务",
"subtitle": "按设定的时间重启、关闭、启动或备份这台服务器,或向它的控制台发送命令。",
"back_to_console": "返回控制台",
"not_yours_title": "无权管理计划任务",
"not_yours_body": "只有所有者或管理员才能查看和修改该服务器的计划任务。",
"new_task": "新建任务",
"count": "{{count}} / {{limit}} 个任务",
"limit_reached": "每台服务器最多 {{limit}} 个计划任务,删除一个后才能再添加。",
"unavailable_title": "计划任务不可用",
"unavailable_body": "当前 felis-api 未配置计划任务存储,这里无法查看或保存任务。请联系管理员检查 felis-api 的配置。",
"empty_title": "还没有计划任务",
"empty_hint": "可以从常用任务开始,也可以点“新建任务”自己设置。",
"template_restart": "每天 04:00 重启",
"template_backup": "每天 05:00 备份",
"template_announce": "每 30 分钟在聊天栏发公告",
"template_announce_command": "say 欢迎来到服务器!",
"action_command": "控制台命令",
"action_command_desc": "向服务器控制台发送一条命令,效果和在控制台里输入一样。仅在服务器运行时执行。",
"action_restart": "重启",
"action_restart_desc": "关闭服务器后再启动,在线玩家会被断开。服务器已关闭时跳过。",
"action_stop": "关闭",
"action_stop_desc": "关闭服务器,在线玩家会被断开。",
"action_start": "启动",
"action_start_desc": "服务器关闭时启动它。",
"action_backup": "备份",
"action_backup_desc": "备份世界。运行中的服务器会先关闭,备份完成后再启动。",
"days_all": "每天",
"days_weekdays": "工作日",
"days_weekend": "周末",
"day_sep": "、",
"day_0": "周日",
"day_1": "周一",
"day_2": "周二",
"day_3": "周三",
"day_4": "周四",
"day_5": "周五",
"day_6": "周六",
"day_chip_0": "日",
"day_chip_1": "一",
"day_chip_2": "二",
"day_chip_3": "三",
"day_chip_4": "四",
"day_chip_5": "五",
"day_chip_6": "六",
"day_full_0": "星期日",
"day_full_1": "星期一",
"day_full_2": "星期二",
"day_full_3": "星期三",
"day_full_4": "星期四",
"day_full_5": "星期五",
"day_full_6": "星期六",
"first_day": "1",
"when_daily": "{{days}} {{time}}",
"when_interval_days": "{{every}} · {{days}}",
"every_minutes": "每 {{count}} 分钟",
"every_hours": "每 {{count}} 小时",
"opt_minutes": "{{count}} 分钟",
"opt_hours": "{{count}} 小时",
"create_title": "新建计划任务",
"edit_title": "编辑计划任务",
"dialog_desc": "选择执行什么、什么时候执行。",
"create": "创建",
"save": "保存",
"field_action": "执行什么",
"field_command": "命令",
"command_placeholder": "say 服务器将在 04:00 重启",
"command_hint": "一条控制台命令,开头的 / 可加可不加。",
"field_when": "什么时候",
"mode_daily": "每天一次",
"mode_interval": "循环执行",
"field_time": "时间",
"field_every": "间隔",
"interval_preview": "每天从 00:00 起算:{{times}}",
"interval_hourly_limit": "重启、关闭、启动和备份最多每小时执行一次。",
"field_days": "哪几天",
"field_timezone": "时区",
"timezone_hint": "按这个时区计算时间。你的浏览器时区是 {{zone}}。",
"timezone_use_browser": "改用浏览器时区",
"field_warn": "提醒玩家",
"warn_none": "不提醒",
"warn_option": "提前 {{count}} 分钟",
"warn_hint": "服务器运行时,在关闭前于游戏聊天栏发一条中英双语提醒。",
"backup_note": "备份运行中的服务器时会先关闭它(在线玩家会被断开),备份世界后再启动,通常需要几分钟。已关闭的服务器备份后保持关闭。",
"field_label": "名称(可选)",
"label_placeholder": "每晚重启",
"field_enabled": "启用",
"enabled_hint": "按计划自动执行。关闭后任务保留,但不再自动执行。",
"err_command_required": "请输入命令。",
"err_command_long": "命令超过 {{max}} 字节。",
"err_time_required": "请选择时间。",
"err_days": "至少选择一天。",
"err_timezone_required": "请输入时区。",
"err_timezone": "无法识别的时区,请填写类似 Asia/Shanghai 的名称。",
"err_label_long": "名称超过 {{max}} 个字符。",
"next_run": "下次执行:{{when}}",
"next_run_off": "已停用,只在点“立即执行”时运行",
"never_run": "尚未执行",
"last_run": "上次执行:{{when}}",
"run_started_at": "开始于:{{when}}",
"result_ok": "成功",
"result_skipped": "已跳过",
"result_failed": "失败",
"result_missed": "错过",
"reply": "回复:{{text}}",
"run_state_claimed": "执行中…",
"run_state_stopping": "正在关闭服务器…",
"run_state_backing_up": "正在备份…",
"run_state_starting": "正在启动服务器…",
"warn_badge": "提前 {{count}} 分钟提醒",
"off_badge": "已停用",
"tz_differs": "按 {{zone}} 时区计算,你的浏览器时区是 {{browser}}。",
"toggle_label": "按计划执行「{{name}}」",
"run_now": "立即执行",
"edit": "编辑",
"delete": "删除",
"busy_hint": "任务正在执行,请等本次执行结束。",
"owner_changed": "服务器更换了所有者,这个任务已自动停用。检查无误后保存或重新启用即可恢复。",
"detail_not_running": "服务器未在运行。",
"detail_already_stopped": "服务器已经是关闭状态。",
"detail_already_running": "服务器已经在运行。",
"detail_gone": "服务器已不存在。",
"detail_no_world": "服务器还没有世界存档。",
"detail_missed": "计划时间点 felis-api 未在运行。",
"detail_started_meanwhile": "执行结束前有人启动了服务器。",
"detail_interrupted": "felis-api 在执行过程中停止了。",
"detail_store_full": "备份存储已满,请联系管理员清理空间。",
"detail_console": "无法连接服务器控制台。",
"detail_cap": "集群运行中的服务器数量已达上限。",
"detail_retiring": "服务器正在被放弃或删除。",
"run_title": "立即执行「{{name}}」?",
"run_confirm": "立即执行",
"run_body_command": "立即向服务器控制台发送下面这条命令,服务器关闭时跳过:",
"run_body_restart": "立即关闭服务器,然后重新启动。",
"run_body_stop": "立即关闭服务器。",
"run_body_start": "服务器关闭时立即启动它。",
"run_body_backup": "立即备份世界。运行中的服务器会先关闭,备份后再启动;已关闭的服务器备份后保持关闭。",
"run_kick": "在线玩家会被立即断开:手动执行不发送提醒。",
"run_keeps_next": "下次计划执行时间保持不变。",
"delete_title": "删除「{{name}}」?",
"delete_body": "任务会被删除,之后不再执行。",
"delete_confirm": "删除",
"created": "已创建「{{name}}」。",
"saved": "已保存「{{name}}」。",
"deleted": "已删除「{{name}}」。",
"run_started": "已开始执行「{{name}}」。",
"toggled_on": "已启用「{{name}}」。",
"toggled_off": "已停用「{{name}}」。",
"notes_title": "计划任务如何执行",
"note_timing": "felis-api 每 15 秒检查一次,任务会在设定时间后约 15 秒内开始。晚于计划 10 分钟以上的执行(例如 felis-api 当时未运行)会记为错过,下一次照常执行。",
"note_state": "命令、重启和关闭仅在服务器运行时执行,启动仅在服务器关闭时执行,其余情况会跳过。",
"note_owner": "服务器更换所有者后,它的任务会自动停用;重新保存即可交给当前所有者。",
"note_run_now": "“立即执行”对已停用的任务同样有效,不发送提醒,也不影响下次计划执行。"
}
@@ -61,6 +61,8 @@
"backups_link_desc": "查看并恢复该服务器的世界备份。",
"files_link_title": "服务器文件",
"files_link_desc": "浏览并修复世界卷中的文件。",
"schedules_link_title": "计划任务",
"schedules_link_desc": "定时重启、备份或执行命令。",
"system_service": "系统服务",
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
"players_back_to_console": "返回控制台",
+108
View File
@@ -1440,6 +1440,95 @@ describe("server file manager wire shapes", () => {
});
});
describe("scheduled task wire shapes", () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.unstubAllGlobals());
function sent(fetchSpy: typeof fetch): [string, RequestInit] {
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
return [String(url), opts as RequestInit];
}
const input = {
label: "Nightly",
action: "restart" as const,
command: "",
every_minutes: 0 as const,
minute_of_day: 240,
weekdays: 127,
timezone: "Asia/Shanghai",
warn_minutes: 5 as const,
enabled: true,
};
const saved = { ...input, id: 7, server: "survival", next_run_at: "2026-09-28T04:00:00+08:00", run_state: "" };
it("listSchedules GETs /servers/{name}/schedules and keeps the limit", async () => {
const fetchSpy = fakeFetch({ server: "survival", schedules: [saved], limit: 20 });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.listSchedules("survival")).toEqual({ schedules: [saved], limit: 20 });
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/schedules");
expect(opts.method).toBe("GET");
});
it("listSchedules reads a missing list as none", async () => {
vi.stubGlobal("fetch", fakeFetch({ server: "survival", limit: 20 }));
expect(await api.listSchedules("survival")).toEqual({ schedules: [], limit: 20 });
});
it("createSchedule POSTs the input as it is", async () => {
const fetchSpy = fakeFetch(saved, { status: 201 });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.createSchedule("survival", input)).toEqual(saved);
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/schedules");
expect(opts.method).toBe("POST");
expect(JSON.parse(opts.body as string)).toEqual(input);
});
it("updateSchedule PUTs the whole input to the schedule's id", async () => {
const fetchSpy = fakeFetch(saved);
vi.stubGlobal("fetch", fetchSpy);
expect(await api.updateSchedule("survival", 7, { ...input, enabled: false })).toEqual(saved);
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/schedules/7");
expect(opts.method).toBe("PUT");
expect(JSON.parse(opts.body as string)).toEqual({ ...input, enabled: false });
});
it("deleteSchedule DELETEs the schedule with no body and takes the 204", async () => {
const fetchSpy = vi.fn(async () => ({
ok: true,
status: 204,
statusText: "No Content",
text: async () => "",
})) as unknown as typeof fetch;
vi.stubGlobal("fetch", fetchSpy);
expect(await api.deleteSchedule("survival", 7)).toBeNull();
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/schedules/7");
expect(opts.method).toBe("DELETE");
expect(opts.body).toBeUndefined();
});
it("runSchedule POSTs to .../run with no body and parses the 202", async () => {
const fetchSpy = fakeFetch({ ...saved, run_state: "stopping" }, { status: 202 });
vi.stubGlobal("fetch", fetchSpy);
expect((await api.runSchedule("survival", 7)).run_state).toBe("stopping");
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/schedules/7/run");
expect(opts.method).toBe("POST");
expect(opts.body).toBeUndefined();
});
it("refuses a server name that is not one path segment before sending anything", async () => {
const fetchSpy = fakeFetch({});
vi.stubGlobal("fetch", fetchSpy);
await expect(api.runSchedule("..", 7)).rejects.toMatchObject({ code: "bad_path_param" });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// The API's generic codes carry an English developer message ("user not found",
// "invalid request"); the panel words them itself so a Chinese UI never shows it.
describe("copy for the generic server codes", () => {
@@ -1470,6 +1559,25 @@ describe("copy for the generic server codes", () => {
expect(humanizeError({ status: 411, code: "length_required", message: "raw" })).toMatch(/did not say how large it is/);
});
it("words the scheduled tasks' refusals itself, and keeps the reason a schedule was refused", () => {
expect(humanizeError({ status: 503, code: "schedules_unavailable", message: "raw" })).toBe(
"Scheduled tasks aren't available right now.",
);
expect(humanizeError({ status: 409, code: "schedule_limit", message: "raw" })).toBe(
"This server already has as many scheduled tasks as it can hold. Delete one first.",
);
expect(humanizeError({ status: 409, code: "schedule_running", message: "raw" })).toBe(
"This task is running right now. Try again once the run finishes.",
);
expect(humanizeError({ status: 409, code: "schedule_stale", message: "raw" })).toBe(
"The server has a new owner since this task was saved. Save the task again before running it.",
);
expect(
humanizeError({ status: 400, code: "bad_schedule", message: "a restart can repeat at most every 60 minutes" }),
).toBe("The task was not saved: a restart can repeat at most every 60 minutes");
expect(humanizeError({ status: 400, code: "bad_schedule", message: "" })).toBe("Something went wrong.");
});
it("reads a full upload store as full, not as an outage", () => {
expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/);
});
+48
View File
@@ -22,6 +22,8 @@ import type {
QuotaInput,
QuotaView,
RetireState,
Schedule,
ScheduleInput,
ServerFileEntry,
ServerJob,
MyServerView,
@@ -681,6 +683,41 @@ export const api = rejectingSync({
urlPath`/servers/${name}/jobs`,
).then((r) => r.jobs ?? []),
// Scheduled tasks of one server (GET/POST /servers/{name}/schedules, PUT/DELETE
// .../{id}, POST .../{id}/run). Owner-or-admin gated server-side; 503
// schedules_unavailable while the schedule store is not wired. A server holds
// at most `limit` of them (409 schedule_limit past it).
listSchedules: (name: string) =>
request<{ server: string; schedules: Schedule[]; limit: number }>(
"GET",
urlPath`/servers/${name}/schedules`,
).then((r) => ({ schedules: r.schedules ?? [], limit: r.limit })),
// createSchedule saves a new schedule; it belongs to the server's current
// owner. Validation failures are 400 bad_request / bad_schedule with the reason
// in the message.
createSchedule: (name: string, input: ScheduleInput) =>
request<Schedule>("POST", urlPath`/servers/${name}/schedules`, input),
// updateSchedule replaces a schedule's settings (the whole input, not a patch)
// and passes it to the server's current owner, which is how a schedule the
// runner disabled after an owner change comes back. 409 schedule_running while
// it runs.
updateSchedule: (name: string, id: number, input: ScheduleInput) =>
request<Schedule>("PUT", urlPath`/servers/${name}/schedules/${String(id)}`, input),
// deleteSchedule answers 204; 409 schedule_running while it runs.
deleteSchedule: (name: string, id: number) =>
request<null>("DELETE", urlPath`/servers/${name}/schedules/${String(id)}`),
// runSchedule runs a schedule now, without the players' warning; its next
// scheduled run stays where it is. The reply is 202 with the schedule after the
// run's first step: a restart or backup goes on in the background and shows in
// run_state. 409 schedule_running mid-run, 409 schedule_stale when the server
// changed owner since the schedule was saved.
runSchedule: (name: string, id: number) =>
request<Schedule>("POST", urlPath`/servers/${name}/schedules/${String(id)}/run`),
// Server file manager (spec §7). Every route is owner-or-admin gated and
// refuses with 409 not_stopped unless the server is fully stopped (the world
// volume is RWO), so callers gate on phase === "Stopped". The path travels as a
@@ -1344,10 +1381,21 @@ export function humanizeError(e: unknown): string {
return t("uploads_full");
case "unsupported_media_type":
return t("unsupported_media_type");
// Scheduled tasks (internal/api/handlers_schedules.go).
case "schedules_unavailable":
return t("schedules_unavailable");
case "schedule_limit":
return t("schedule_limit");
case "schedule_running":
return t("schedule_running");
case "schedule_stale":
return t("schedule_stale");
// The detail says which field was wrong; the server writes it in English,
// so it rides inside a localized sentence.
case "bad_request":
return err.message ? t("bad_request", { detail: err.message }) : t("generic");
case "bad_schedule":
return err.message ? t("bad_schedule", { detail: err.message }) : t("generic");
default:
if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden");
+335
View File
@@ -1382,6 +1382,71 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/schedules": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List a server's scheduled tasks (owner-or-admin). */
get: operations["listServerSchedules"];
put?: never;
/**
* Add a scheduled task to a server (owner-or-admin).
* @description The task belongs to the server's current owner: once the server has another owner felis-api disables it instead of running it, until somebody saves it again. felis-api checks the tasks every 15 seconds; a run it was down for is started late, up to 10 minutes, and dropped as missed after that. A command runs only on a running server, a restart only restarts a running one, and a start goes through the running-server cap and a pending retirement like a wake. Audited as schedule.create; each run as schedule.run by scheduler.
*/
post: operations["createServerSchedule"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/schedules/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Change a scheduled task (owner-or-admin).
* @description Replaces the task's settings and recomputes its next run. The task passes to the server's current owner. Refused while a run is in progress. Audited as schedule.update.
*/
put: operations["updateServerSchedule"];
post?: never;
/**
* Remove a scheduled task (owner-or-admin).
* @description Refused while a run is in progress. Audited as schedule.delete.
*/
delete: operations["deleteServerSchedule"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/schedules/{id}/run": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Run a scheduled task now (owner-or-admin).
* @description Starts a run at once, without the players' warning, whether the task is enabled or not; its next scheduled run stays where it was. The answer is the task after the run's first step: a command, stop or start has finished, and a restart or backup goes on in the background (run_state). Audited as schedule.run_now, and the run itself as schedule.run.
*/
post: operations["runServerSchedule"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/users": {
parameters: {
query?: never;
@@ -2639,6 +2704,76 @@ export interface components {
/** @description Owned rows only. Present while the server is given up or being deleted. */
retiring?: components["schemas"]["RetireState"];
};
/** @description One scheduled task of a server (internal/api/schedules.go Schedule). It runs at minute_of_day on the weekdays, or with every_minutes set at every multiple of it since midnight on those days, in timezone. */
Schedule: {
/** Format: int64 */
id: number;
server: string;
/** @description Free text naming the task; may be empty. */
label: string;
/**
* @description backup of a running server stops it, takes a backup (pruned with the daily restore points, [archive] scheduled_keep) and starts it again; of a stopped server it leaves the server stopped.
* @enum {string}
*/
action: "command" | "restart" | "stop" | "start" | "backup";
/** @description The console command of a command task */
command: string;
/**
* @description 0 runs once a day at minute_of_day. A restart, stop, start or backup repeats at most every 60 minutes.
* @enum {integer}
*/
every_minutes: 0 | 15 | 30 | 60 | 120 | 180 | 240 | 360 | 480 | 720;
/** @description Minutes after local midnight; 0 when every_minutes is set. */
minute_of_day: number;
/** @description Bitmask of the days it runs on: bit 0 Sunday to bit 6 Saturday. */
weekdays: number;
/** @description IANA zone the times are in */
timezone: string;
/**
* @description How long before a restart, stop or backup the players on the server are told (say); 0 for none, and always 0 for a command or start.
* @enum {integer}
*/
warn_minutes: 0 | 1 | 5 | 10 | 15 | 30;
enabled: boolean;
/**
* Format: date-time
* @description Null while disabled.
*/
next_run_at: string | null;
/**
* @description What a run in progress is doing; empty when none is.
* @enum {string}
*/
run_state: "" | "claimed" | "stopping" | "backing_up" | "starting";
/** Format: date-time */
last_run_at: string | null;
/**
* @description How the last run ended; empty before the first and during a run. missed is a run felis-api was down for, dropped once it was 10 minutes late.
* @enum {string}
*/
last_result: "" | "ok" | "skipped" | "failed" | "missed";
/** @description What happened */
last_detail: string;
created_by: string;
/** Format: date-time */
created_at: string;
};
ScheduleInput: {
label?: string;
/** @enum {string} */
action: "command" | "restart" | "stop" | "start" | "backup";
/** @description Required for a command task and refused for the others. One line; a leading slash is dropped. */
command?: string;
/** @enum {integer} */
every_minutes?: 0 | 15 | 30 | 60 | 120 | 180 | 240 | 360 | 480 | 720;
minute_of_day?: number;
weekdays: number;
timezone: string;
/** @enum {integer} */
warn_minutes?: 0 | 1 | 5 | 10 | 15 | 30;
/** @description Default true. */
enabled?: boolean;
};
/** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
BackupView: {
id: string;
@@ -6640,6 +6775,206 @@ export interface operations {
};
};
};
listServerSchedules: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The server's tasks, oldest first, and how many it may have. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
server: string;
schedules: components["schemas"]["Schedule"][];
limit: number;
};
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
503: components["responses"]["ServiceUnavailable"];
};
};
createServerSchedule: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ScheduleInput"];
};
};
responses: {
/** @description Created. */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Schedule"];
};
};
/** @description A malformed body or name, or settings out of range (bad_schedule, bad_request for the command). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description The server already has 20 tasks (schedule_limit). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
updateServerSchedule: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ScheduleInput"];
};
};
responses: {
/** @description Saved. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Schedule"];
};
};
/** @description A malformed body, name or id, or settings out of range (bad_schedule). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description A run is in progress (schedule_running). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
deleteServerSchedule: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Removed. */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description A run is in progress (schedule_running). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
runServerSchedule: {
parameters: {
query?: never;
header?: never;
path: {
name: string;
id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Started. */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Schedule"];
};
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description A run is already in progress (schedule_running), or the server has another owner since the task was saved (schedule_stale). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
listUsers: {
parameters: {
query?: {
+2
View File
@@ -36,6 +36,8 @@ export type WireParity = [
Holds<Parity<T.MyServerView, S["MyServerView"]>>,
Holds<Parity<T.AllowlistEntry, S["AllowlistEntry"]>>,
Holds<Parity<T.FleetServer, S["FleetServer"]>>,
Holds<Parity<T.Schedule, S["Schedule"]>>,
Holds<Parity<T.ScheduleInput, S["ScheduleInput"]>>,
Holds<Parity<T.BackupView, S["BackupView"]>>,
Holds<Parity<T.Build, S["Build"]>>,
Holds<Parity<T.BuildScan, S["BuildScan"]>>,
+56
View File
@@ -247,6 +247,62 @@ export interface ServerJob {
scheduled?: boolean;
}
// ---- Scheduled tasks (internal/api/schedules.go Schedule, scheduleInput) ----
export type ScheduleAction = "command" | "restart" | "stop" | "start" | "backup";
/** 0 runs once a day at minute_of_day; the rest divide a day, so the runs sit at
* the same clock times every day. A restart, stop, start or backup repeats at
* most every 60 minutes. */
export type ScheduleEveryMinutes = 0 | 15 | 30 | 60 | 120 | 180 | 240 | 360 | 480 | 720;
/** How long before a restart, stop or backup the players are told in game. */
export type ScheduleWarnMinutes = 0 | 1 | 5 | 10 | 15 | 30;
/** The step of a run in progress; empty when none is. */
export type ScheduleRunState = "" | "claimed" | "stopping" | "backing_up" | "starting";
/** How the last run ended; empty before the first and during a run. */
export type ScheduleResult = "" | "ok" | "skipped" | "failed" | "missed";
/** Schedule is one scheduled task of a server (GET /servers/{name}/schedules).
* It runs at minute_of_day on the weekdays (bit 0 Sunday … bit 6 Saturday), or
* with every_minutes set at every multiple of it since midnight on those days,
* in timezone. last_detail is the backend's English. */
export interface Schedule {
id: number;
server: string;
label: string;
action: ScheduleAction;
/** The console command of a command task, without a slash; empty otherwise. */
command: string;
every_minutes: ScheduleEveryMinutes;
minute_of_day: number;
weekdays: number;
timezone: string;
warn_minutes: ScheduleWarnMinutes;
enabled: boolean;
/** Null while disabled. */
next_run_at: string | null;
run_state: ScheduleRunState;
last_run_at: string | null;
last_result: ScheduleResult;
last_detail: string;
created_by: string;
created_at: string;
}
/** ScheduleInput is the body of a create (POST) or a save (PUT). The server trims
* the label, drops a command's leading slash, and refuses a command on any other
* action and a warning on a command or start. enabled defaults to true. */
export interface ScheduleInput {
label?: string;
action: ScheduleAction;
command?: string;
every_minutes?: ScheduleEveryMinutes;
minute_of_day?: number;
weekdays: number;
timezone: string;
warn_minutes?: ScheduleWarnMinutes;
enabled?: boolean;
}
/** WhitelistImage is one row of GET /images (the create-form dropdown source). */
export interface WhitelistImage {
image_ref: string;
+10
View File
@@ -98,6 +98,16 @@ describe("ServerConsole edit dialog", () => {
});
});
describe("ServerConsole doorways", () => {
it("links to the server's scheduled tasks while it is stopped", async () => {
calls.status.mockResolvedValue(status({ phase: "Stopped", desiredState: "Stopped" }));
renderConsole();
const link = await screen.findByRole("link", { name: /^Scheduled tasks/ });
expect(link.getAttribute("href")).toBe("/servers/survival/schedules");
});
});
describe("ServerConsole following the server", () => {
afterEach(() => {
vi.useRealTimers();
+16 -1
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useId, useLayoutEffect, type KeyboardEvent } from "react";
import { Link, useParams } from "react-router-dom";
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react";
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, CalendarClock, ChevronRight, type LucideIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card";
import { BackLink } from "@/components/BackLink";
@@ -411,6 +411,21 @@ export function ServerConsole() {
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</Link>
{/* Scheduled tasks — its own subpage. A schedule runs whatever phase the
server is in (a start wakes it, a backup stops it first), so the
doorway shows at every phase and the page owns its gating. */}
<Link
to={`/servers/${name}/schedules`}
className="group flex items-center gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/40 hover:bg-accent"
>
<CalendarClock className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("schedules_link_title")}</p>
<p className="text-sm text-muted-foreground">{t("schedules_link_desc")}</p>
</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. */}
+544
View File
@@ -0,0 +1,544 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { ServerSchedules } from "./ServerSchedules";
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
import type { Schedule } from "@/lib/types";
const calls = vi.hoisted(() => ({
status: vi.fn(),
myServers: vi.fn(),
listSchedules: vi.fn(),
createSchedule: vi.fn(),
updateSchedule: vi.fn(),
deleteSchedule: vi.fn(),
runSchedule: vi.fn(),
}));
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", () => ({ loadConfig: () => Promise.resolve({}) }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
// Radix Select opens with pointer capture and scrolls the picked item into view,
// neither of which jsdom implements.
beforeAll(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
});
const BROWSER = Intl.DateTimeFormat().resolvedOptions().timeZone;
const OTHER = BROWSER === "Pacific/Chatham" ? "Pacific/Kiritimati" : "Pacific/Chatham";
const KICK = "Online players are disconnected right away: a run started by hand skips the players' warning.";
function hoursFromNow(h: number): string {
return new Date(Date.now() + h * 3600_000 + 60_000).toISOString();
}
function schedule(over: Partial<Schedule> = {}): Schedule {
return {
id: 1,
server: "survival",
label: "",
action: "restart",
command: "",
every_minutes: 0,
minute_of_day: 240,
weekdays: 127,
timezone: BROWSER,
warn_minutes: 5,
enabled: true,
next_run_at: hoursFromNow(3),
run_state: "",
last_run_at: null,
last_result: "",
last_detail: "",
created_by: "user:u1",
created_at: "2026-09-01T00:00:00Z",
...over,
};
}
function listing(schedules: Schedule[], limit = 20) {
calls.listSchedules.mockResolvedValue({ schedules, limit });
}
beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset();
tier.isAdmin = true;
calls.status.mockResolvedValue({ name: "survival", displayName: "Survival", phase: "Running" });
calls.myServers.mockResolvedValue([]);
listing([]);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
return i18next.changeLanguage("en-US");
});
function renderPage() {
return render(
<MemoryRouter initialEntries={["/servers/survival/schedules"]}>
<Routes>
<Route path="/servers/:name/schedules" element={<ServerSchedules />} />
</Routes>
</MemoryRouter>,
);
}
async function rowOf(title: string): Promise<HTMLElement> {
return (await screen.findByText(title, { selector: "p" })).closest("li") as HTMLElement;
}
async function openCreate() {
await userEvent.click(await screen.findByRole("button", { name: "New task" }));
return screen.findByRole("dialog");
}
async function pick(dialog: HTMLElement, box: string, option: string) {
await userEvent.click(within(dialog).getByRole("combobox", { name: box }));
await userEvent.click(await screen.findByRole("option", { name: option }));
}
describe("ServerSchedules", () => {
it("shows a non-owner NotYours and never reads the list", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue([{ name: "survival", owned: false }]);
renderPage();
expect(await screen.findByText("No permission to manage scheduled tasks")).toBeTruthy();
expect(calls.listSchedules).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "New task" })).toBeNull();
});
it("shows an owner who is not an admin the list", async () => {
tier.isAdmin = false;
calls.myServers.mockResolvedValue([{ name: "survival", owned: true }]);
listing([schedule({ label: "Nightly" })]);
renderPage();
expect(await rowOf("Nightly")).toBeTruthy();
expect(calls.listSchedules).toHaveBeenCalledWith("survival");
});
it("lists each task with its summary, next run and last result", async () => {
listing([
schedule({ id: 1, label: "Nightly", last_result: "ok", last_run_at: hoursFromNow(-22) }),
schedule({
id: 2,
action: "command",
command: "say hi",
every_minutes: 30,
minute_of_day: 0,
weekdays: 62,
timezone: OTHER,
warn_minutes: 0,
last_result: "failed",
last_run_at: hoursFromNow(-2),
last_detail: "the command failed: rcon: connection refused",
}),
schedule({ id: 3, action: "backup", weekdays: 42, minute_of_day: 330, warn_minutes: 0, enabled: false, next_run_at: null }),
]);
renderPage();
const nightly = await rowOf("Nightly");
expect(within(nightly).getByText("Every day at 04:00")).toBeTruthy();
expect(within(nightly).getByText("Next run in 3 hours")).toBeTruthy();
expect(within(nightly).getByText("Succeeded")).toBeTruthy();
expect(within(nightly).getByText("Last run 22 hours ago")).toBeTruthy();
expect(within(nightly).getByText("Warns 5 min before")).toBeTruthy();
expect(within(nightly).getByText("Restart")).toBeTruthy();
expect(within(nightly).queryByText(BROWSER)).toBeNull();
const command = await rowOf("Console command");
expect(within(command).getByText("Every 30 minutes · Weekdays")).toBeTruthy();
expect(within(command).getByText("say hi", { selector: "code" })).toBeTruthy();
expect(within(command).getByText(OTHER)).toBeTruthy();
expect(within(command).getByText("Failed")).toBeTruthy();
expect(within(command).getByText("the command failed: rcon: connection refused")).toBeTruthy();
expect(within(command).queryByText(/^Warns/)).toBeNull();
const backup = await rowOf("Backup");
expect(within(backup).getByText("Mon, Wed, Fri at 05:30")).toBeTruthy();
expect(within(backup).getByText("Off: it runs only when you press Run now")).toBeTruthy();
expect(within(backup).getByText("Off")).toBeTruthy();
expect(within(backup).getByText("Hasn't run yet")).toBeTruthy();
expect(screen.getByText("3 of 20 tasks")).toBeTruthy();
});
it("starts the week on Monday in Chinese", async () => {
await i18next.changeLanguage("zh-CN");
listing([schedule({ id: 1, label: "夜间重启", weekdays: 3 })]);
renderPage();
expect(within(await rowOf("夜间重启")).getByText("周一、周日 04:00")).toBeTruthy();
});
it("says in the panel's words why a run was skipped, and shows a command's reply", async () => {
listing([
schedule({
id: 1,
label: "Nightly",
enabled: false,
next_run_at: null,
last_result: "skipped",
last_run_at: hoursFromNow(-1),
last_detail: "the server has a new owner since this schedule was saved; save it again to use it",
}),
schedule({
id: 2,
label: "Who",
action: "command",
command: "list",
warn_minutes: 0,
last_result: "ok",
last_run_at: hoursFromNow(-1),
last_detail: "There are 0 of a max of 20 players online",
}),
]);
renderPage();
const nightly = await rowOf("Nightly");
expect(within(nightly).getByText("Skipped")).toBeTruthy();
expect(
within(nightly).getByText(
"The server changed owner, so this task switched itself off. Check it, then save it or switch it on to use it again.",
),
).toBeTruthy();
expect(within(await rowOf("Who")).getByText("Reply: There are 0 of a max of 20 players online")).toBeTruthy();
});
it("creates a daily restart that warns the players", async () => {
calls.createSchedule.mockResolvedValue(schedule({ id: 9, label: "Nightly" }));
renderPage();
const dialog = await openCreate();
const restart = within(dialog).getByRole("radio", { name: "Restart" }) as HTMLInputElement;
expect(restart.checked).toBe(true);
// A command typed before switching back to a restart is not sent with it.
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
await userEvent.type(within(dialog).getByLabelText("Command"), "say bye");
await userEvent.click(restart);
fireEvent.change(within(dialog).getByLabelText("Time"), { target: { value: "03:30" } });
await pick(dialog, "Warn players", "10 minutes before");
await userEvent.type(within(dialog).getByLabelText("Name (optional)"), "Nightly");
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
expect(calls.createSchedule).toHaveBeenCalledWith("survival", {
label: "Nightly",
action: "restart",
command: "",
every_minutes: 0,
minute_of_day: 210,
weekdays: 127,
timezone: BROWSER,
warn_minutes: 10,
enabled: true,
});
expect(await screen.findByText("Created “Nightly”.")).toBeTruthy();
expect(screen.queryByRole("dialog")).toBeNull();
expect(calls.listSchedules).toHaveBeenCalledTimes(2);
});
it("creates a repeating console command as typed, on weekdays", async () => {
calls.createSchedule.mockResolvedValue(schedule({ id: 9, action: "command" }));
renderPage();
const dialog = await openCreate();
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
await userEvent.type(within(dialog).getByLabelText("Command"), "/say hi");
await userEvent.click(within(dialog).getByRole("radio", { name: "Repeat" }));
await pick(dialog, "Repeat every", "30 minutes");
await userEvent.click(within(dialog).getByRole("button", { name: "Weekdays" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
expect(calls.createSchedule).toHaveBeenCalledWith("survival", {
label: "",
action: "command",
command: "/say hi",
every_minutes: 30,
minute_of_day: 0,
weekdays: 62,
timezone: BROWSER,
warn_minutes: 0,
enabled: true,
});
});
it("drops the warning for a command and keeps a server action hourly at most", async () => {
renderPage();
const dialog = await openCreate();
await pick(dialog, "Warn players", "10 minutes before");
expect(within(dialog).getByRole("combobox", { name: "Warn players" }).textContent).toBe("10 minutes before");
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
expect(within(dialog).queryByRole("combobox", { name: "Warn players" })).toBeNull();
await userEvent.click(within(dialog).getByRole("radio", { name: "Repeat" }));
await pick(dialog, "Repeat every", "15 minutes");
await userEvent.click(within(dialog).getByRole("radio", { name: "Stop" }));
expect(within(dialog).getByRole("combobox", { name: "Warn players" }).textContent).toBe("No warning");
expect(within(dialog).getByRole("combobox", { name: "Repeat every" }).textContent).toBe("1 hour");
await userEvent.click(within(dialog).getByRole("combobox", { name: "Repeat every" }));
expect(await screen.findByRole("option", { name: "1 hour" })).toBeTruthy();
expect(screen.queryByRole("option", { name: "30 minutes" })).toBeNull();
});
it("will not save a task on no day", async () => {
renderPage();
const dialog = await openCreate();
for (const day of ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]) {
await userEvent.click(within(dialog).getByRole("button", { name: day }));
}
expect(within(dialog).getByText("Pick at least one day.")).toBeTruthy();
const create = within(dialog).getByRole("button", { name: "Create" }) as HTMLButtonElement;
expect(create.disabled).toBe(true);
await userEvent.click(create);
expect(calls.createSchedule).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Wednesday" }));
expect(within(dialog).queryByText("Pick at least one day.")).toBeNull();
expect(create.disabled).toBe(false);
});
it("checks the command, name and time zone the way felis-api does", async () => {
renderPage();
const dialog = await openCreate();
const create = within(dialog).getByRole("button", { name: "Create" }) as HTMLButtonElement;
await userEvent.click(within(dialog).getByRole("radio", { name: "Console command" }));
const command = within(dialog).getByLabelText("Command");
expect(within(dialog).getByText("Enter a command.")).toBeTruthy();
// A lone slash is dropped, leaving nothing to run.
fireEvent.change(command, { target: { value: " / " } });
expect(within(dialog).getByText("Enter a command.")).toBeTruthy();
expect(create.disabled).toBe(true);
// The limit counts the command after its slash goes.
fireEvent.change(command, { target: { value: "/" + "x".repeat(1024) } });
expect(create.disabled).toBe(false);
fireEvent.change(command, { target: { value: "x".repeat(1025) } });
expect(within(dialog).getByText("The command is longer than 1024 bytes.")).toBeTruthy();
expect(create.disabled).toBe(true);
// Bytes of UTF-8, as felis-api counts: 342 characters, 1026 bytes.
fireEvent.change(command, { target: { value: "中".repeat(342) } });
expect(within(dialog).getByText("The command is longer than 1024 bytes.")).toBeTruthy();
fireEvent.change(command, { target: { value: "say hi" } });
// Characters, not UTF-16 units, and the surrounding space does not count.
const label = within(dialog).getByLabelText("Name (optional)");
fireEvent.change(label, { target: { value: ` ${"𠀀".repeat(64)} ` } });
expect(create.disabled).toBe(false);
fireEvent.change(label, { target: { value: "𠀀".repeat(65) } });
expect(within(dialog).getByText("The name is longer than 64 characters.")).toBeTruthy();
expect(create.disabled).toBe(true);
fireEvent.change(label, { target: { value: "" } });
const zone = within(dialog).getByLabelText("Time zone") as HTMLInputElement;
fireEvent.change(zone, { target: { value: "Mars/Olympus" } });
expect(within(dialog).getByText("Unknown time zone. Use a name such as Asia/Shanghai.")).toBeTruthy();
expect(create.disabled).toBe(true);
fireEvent.change(zone, { target: { value: " " } });
expect(within(dialog).getByText("Enter a time zone.")).toBeTruthy();
await userEvent.click(within(dialog).getByRole("button", { name: "Use the browser's zone" }));
expect(zone.value).toBe(BROWSER);
expect(create.disabled).toBe(false);
});
it("keeps the dialog open with the server's reason when a save is refused", async () => {
calls.createSchedule.mockRejectedValue({
status: 409,
code: "schedule_limit",
message: "a server can have at most 20 scheduled tasks",
});
renderPage();
const dialog = await openCreate();
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
expect((await within(dialog).findByRole("alert")).textContent).toBe(
"This server already has as many scheduled tasks as it can hold. Delete one first.",
);
expect(screen.getByRole("dialog")).toBe(dialog);
expect((within(dialog).getByRole("button", { name: "Create" }) as HTMLButtonElement).disabled).toBe(false);
});
it("starts a nightly backup from its template", async () => {
calls.createSchedule.mockResolvedValue(schedule({ id: 9, action: "backup" }));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Back up every night at 05:00" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText(/A backup of a running server stops it/)).toBeTruthy();
await userEvent.click(within(dialog).getByRole("button", { name: "Create" }));
expect(calls.createSchedule).toHaveBeenCalledWith("survival", {
label: "",
action: "backup",
command: "",
every_minutes: 0,
minute_of_day: 300,
weekdays: 127,
timezone: BROWSER,
warn_minutes: 5,
enabled: true,
});
});
it("edits a task and saves the whole of it", async () => {
listing([
schedule({ id: 4, label: "Ad", action: "command", command: "say hi", every_minutes: 120, minute_of_day: 0, weekdays: 65, timezone: OTHER, warn_minutes: 0 }),
]);
calls.updateSchedule.mockResolvedValue(schedule({ id: 4, label: "Advert" }));
renderPage();
await userEvent.click(within(await rowOf("Ad")).getByRole("button", { name: "Edit" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Edit scheduled task")).toBeTruthy();
expect(within(dialog).getByRole("combobox", { name: "Repeat every" }).textContent).toBe("2 hours");
await userEvent.type(within(dialog).getByLabelText("Name (optional)"), "vert");
await userEvent.click(within(dialog).getByRole("button", { name: "Save" }));
expect(calls.updateSchedule).toHaveBeenCalledWith("survival", 4, {
label: "Advert",
action: "command",
command: "say hi",
every_minutes: 120,
minute_of_day: 0,
weekdays: 65,
timezone: OTHER,
warn_minutes: 0,
enabled: true,
});
expect(await screen.findByText("Saved “Advert”.")).toBeTruthy();
});
it("switches a task off by saving all of it with enabled flipped", async () => {
listing([
schedule({ id: 4, label: "Nightly", minute_of_day: 330, weekdays: 42, timezone: OTHER, warn_minutes: 10 }),
]);
let answer: (s: Schedule) => void = () => {};
calls.updateSchedule.mockReturnValue(new Promise<Schedule>((resolve) => (answer = resolve)));
renderPage();
const toggle = (await screen.findByRole("switch", { name: "Run Nightly on schedule" })) as HTMLButtonElement;
expect(toggle.getAttribute("aria-checked")).toBe("true");
await userEvent.click(toggle);
expect(calls.updateSchedule).toHaveBeenCalledWith("survival", 4, {
label: "Nightly",
action: "restart",
command: "",
every_minutes: 0,
minute_of_day: 330,
weekdays: 42,
timezone: OTHER,
warn_minutes: 10,
enabled: false,
});
expect(toggle.disabled).toBe(true);
await act(async () => answer(schedule({ id: 4, enabled: false })));
expect(await screen.findByText("“Nightly” is off.")).toBeTruthy();
});
it("asks before running a task now, and warns that a restart disconnects the players", async () => {
listing([
schedule({ id: 1, label: "Nightly" }),
schedule({ id: 2, label: "Hello", action: "command", command: "say hi", warn_minutes: 0 }),
]);
calls.runSchedule.mockResolvedValue(schedule({ id: 1, run_state: "stopping" }));
renderPage();
await userEvent.click(within(await rowOf("Nightly")).getByRole("button", { name: "Run now" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Stops the server now and starts it again.")).toBeTruthy();
expect(within(dialog).getByText(KICK)).toBeTruthy();
expect(calls.runSchedule).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Run now" }));
expect(calls.runSchedule).toHaveBeenCalledWith("survival", 1);
expect(await screen.findByText("Started “Nightly”.")).toBeTruthy();
await userEvent.click(within(await rowOf("Hello")).getByRole("button", { name: "Run now" }));
const second = await screen.findByRole("dialog");
expect(within(second).getByText("say hi", { selector: "code" })).toBeTruthy();
expect(within(second).queryByText(KICK)).toBeNull();
});
it("deletes a task once confirmed", async () => {
listing([schedule({ id: 5, label: "Old" })]);
calls.deleteSchedule.mockResolvedValue(null);
renderPage();
await userEvent.click(within(await rowOf("Old")).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Delete “Old”?")).toBeTruthy();
expect(calls.deleteSchedule).not.toHaveBeenCalled();
await userEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
expect(calls.deleteSchedule).toHaveBeenCalledWith("survival", 5);
expect(await screen.findByText("Deleted “Old”.")).toBeTruthy();
});
it("locks a task while it runs, and says why", async () => {
// A claim stamps last_run_at with the run's start and clears the result.
const started = new Date(Date.now() - 5 * 60_000).toISOString();
listing([
schedule({ id: 1, label: "Busy", run_state: "stopping", last_run_at: started }),
schedule({ id: 2, label: "Idle" }),
]);
renderPage();
const busy = await rowOf("Busy");
expect(within(busy).getByText("Stopping the server…")).toBeTruthy();
expect(within(busy).getByText("Started 5 minutes ago")).toBeTruthy();
expect(within(busy).queryByText("Hasn't run yet")).toBeNull();
for (const name of ["Run now", "Edit", "Delete"]) {
const button = within(busy).getByRole("button", { name }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(button.parentElement?.title).toBe("This task is running. Wait for the run to finish.");
}
expect((within(busy).getByRole("switch") as HTMLButtonElement).disabled).toBe(true);
const idle = await rowOf("Idle");
for (const name of ["Run now", "Edit", "Delete"]) {
expect((within(idle).getByRole("button", { name }) as HTMLButtonElement).disabled).toBe(false);
}
expect((within(idle).getByRole("switch") as HTMLButtonElement).disabled).toBe(false);
});
it("offers no new task once the server holds its limit", async () => {
listing([schedule({ id: 1, label: "One" }), schedule({ id: 2, label: "Two" })], 2);
renderPage();
const add = (await screen.findByRole("button", { name: "New task" })) as HTMLButtonElement;
await rowOf("One");
expect(add.disabled).toBe(true);
expect(add.parentElement?.title).toBe("A server holds at most 2 tasks. Delete one to add another.");
});
it("says plainly when felis-api has no schedule store, and treats other failures as errors", async () => {
calls.listSchedules.mockRejectedValue({ status: 503, code: "schedules_unavailable", message: "not configured" });
const first = renderPage();
expect(await screen.findByText("Scheduled tasks aren't available")).toBeTruthy();
expect(screen.queryByRole("button", { name: "New task" })).toBeNull();
first.unmount();
calls.listSchedules.mockRejectedValue({ status: 500, code: "internal", message: "boom" });
renderPage();
expect(await screen.findByRole("alert")).toBeTruthy();
expect(screen.queryByText("Scheduled tasks aren't available")).toBeNull();
});
it("rereads fast while a run is in progress", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
listing([schedule({ id: 1, label: "Backup", action: "backup", run_state: "backing_up" })]);
renderPage();
await screen.findByText("Backing up…");
const reads = calls.listSchedules.mock.calls.length;
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS + 100));
expect(calls.listSchedules.mock.calls.length).toBe(reads + 1);
});
it("rereads slowly while nothing runs", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
listing([schedule({ id: 1, label: "Nightly" })]);
renderPage();
await rowOf("Nightly");
const reads = calls.listSchedules.mock.calls.length;
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS + 100));
expect(calls.listSchedules.mock.calls.length).toBe(reads);
await act(() => vi.advanceTimersByTimeAsync(STATUS_POLL_SLOW_MS - STATUS_POLL_FAST_MS));
expect(calls.listSchedules.mock.calls.length).toBe(reads + 1);
});
});
File diff suppressed because it is too large. Load diff