feat(schedules): 每台服务器可设计划任务,按星期和时区定时发命令、重启、停服、开服或备份,重启和备份前可在游戏内提醒
This commit is contained in:
32 files changed
+6011
-27
No files matched your search
@@ -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 />} />
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "返回控制台",
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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"]>>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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
Reference in new issue
Block a user