From aff11b77b646daa9dd1b1dffe59a5e32251ef7d6 Mon Sep 17 00:00:00 2001 From: wuxu Date: Sat, 20 Jun 2026 11:07:54 +0800 Subject: [PATCH] =?UTF-8?q?add:=20=E6=9B=B4=E6=96=B0=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=9B=BA=E5=AE=9A=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- webgui/web/assets/js/app.js | 12 +- webgui/web/assets/js/i18n.js | 78 ++++- webgui/web/assets/js/state.js | 66 +--- webgui/web/assets/js/views/browser.js | 4 +- webgui/web/assets/js/views/configure.js | 4 +- webgui/web/assets/js/views/jobs.js | 423 +++++++++++++++++------- webgui/web/assets/js/views/remotes.js | 4 +- webgui/web/assets/styles/components.css | 61 ++++ webgui/web/index.html | 4 +- 9 files changed, 464 insertions(+), 192 deletions(-) diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js index bd36697..dd7bfbe 100644 --- a/webgui/web/assets/js/app.js +++ b/webgui/web/assets/js/app.js @@ -2,15 +2,15 @@ // the top-nav active state in sync, and re-renders chrome strings when // the locale changes. -import { onRoute } from "./state.js"; -import { t, currentLocale, setLocale, onLocale } from "./i18n.js"; -import { renderRemotes } from "./views/remotes.js"; -import { renderBrowse } from "./views/browser.js"; -import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-picker-restore-1"; +import { onRoute } from "./state.js?v=recurring-jobs-5"; +import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=recurring-jobs-5"; +import { renderRemotes } from "./views/remotes.js?v=recurring-jobs-5"; +import { renderBrowse } from "./views/browser.js?v=recurring-jobs-5"; +import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=recurring-jobs-5"; import { renderConfigureNew, renderConfigureEdit, -} from "./views/configure.js"; +} from "./views/configure.js?v=recurring-jobs-5"; const views = { remotes: renderRemotes, diff --git a/webgui/web/assets/js/i18n.js b/webgui/web/assets/js/i18n.js index dc3e4d5..977e394 100644 --- a/webgui/web/assets/js/i18n.js +++ b/webgui/web/assets/js/i18n.js @@ -48,6 +48,8 @@ const STRINGS = { "error.couldnt_load_jobs": "Couldn't load jobs", "error.no_remotes_selected": "Pick source and destination remotes", "error.no_paths_selected": "Enter source and destination paths", + "error.no_schedule_selected": "Pick a fixed recurring schedule", + "error.invalid_schedule_time": "Pick a valid run time", "error.not_connected": "Couldn't connect to rclone", // footer @@ -153,7 +155,7 @@ const STRINGS = { // jobs view "jobs.title": "Jobs", - "jobs.subtitle": "Running and recently completed transfers.", + "jobs.subtitle": "Saved recurring transfers and their latest runs.", "jobs.new_btn": "New Job", "jobs.cancel": "Cancel", "jobs.new_title": "New Job", @@ -162,6 +164,29 @@ const STRINGS = { "jobs.action_copy": "copy (mirror src → dst, keep both)", "jobs.action_sync": "sync (mirror src → dst, delete extras on dst)", "jobs.action_move": "move (mirror src → dst, delete src after)", + "jobs.schedule_type": "Job type", + "jobs.schedule_once": "One-time", + "jobs.schedule_recurring": "Fixed recurring", + "jobs.schedule_help": "One-time jobs are submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.", + "jobs.recurrence_kind": "Recurring plan", + "jobs.recurrence_daily": "Every day", + "jobs.recurrence_weekly": "Every week", + "jobs.recurrence_monthly": "Every month", + "jobs.schedule_time": "Run time", + "jobs.schedule_weekday": "Weekday", + "jobs.schedule_monthday": "Day of month", + "jobs.fixed_schedule_help": "Runs at the selected fixed local time. If the previous run is still active, the scheduler will not start an overlapping run.", + "jobs.weekday_monday": "Monday", + "jobs.weekday_tuesday": "Tuesday", + "jobs.weekday_wednesday": "Wednesday", + "jobs.weekday_thursday": "Thursday", + "jobs.weekday_friday": "Friday", + "jobs.weekday_saturday": "Saturday", + "jobs.weekday_sunday": "Sunday", + "jobs.monthday": (n) => `Day ${n}`, + "jobs.schedule_label_daily": (time) => `Every day at ${time}`, + "jobs.schedule_label_weekly": (day, time) => `Every ${day} at ${time}`, + "jobs.schedule_label_monthly": (day, time) => `Day ${day} of every month at ${time}`, "jobs.local_option": "Local filesystem", "jobs.local_path_help": "For Local filesystem, enter a path visible inside the rclone process, such as /data/source in this Docker setup. For remotes, enter a path inside the selected remote.", "jobs.source_remote": "Source location", @@ -179,20 +204,29 @@ const STRINGS = { "jobs.empty_title": "No jobs yet", "jobs.empty_body": "Use New Job to start a copy, sync, or move.", "jobs.started": (a, id) => `Started ${a} job #${id}`, + "jobs.started_once": (a, id) => `Started one-time ${a} job #${id}. It was not saved.`, + "jobs.recurring_saved": (id) => `Saved recurring job #${id}`, "jobs.start_failed": "Job start failed", "jobs.stop_confirm": (id) => `Stop job #${id}?`, "jobs.stopped": (id) => `Stopped job #${id}`, "jobs.stop_failed": "Stop failed", + "jobs.start_again": "Start again", + "jobs.restarted": (id) => `Started recurring job #${id}`, + "jobs.restart_failed": "Start again failed", "jobs.details": "Details", "jobs.hide_details": "Hide", "jobs.delete_record": "Delete", "jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`, + "jobs.delete_failed": "Delete failed", + "jobs.detail_schedule_id": "Schedule ID", "jobs.detail_id": "Job ID", "jobs.detail_status": "Status", "jobs.detail_error": "Error", "jobs.detail_started": "Started", "jobs.detail_finished": "Finished", "jobs.detail_duration": "Duration", + "jobs.detail_next_run": "Next run", + "jobs.detail_schedule": "Schedule", "jobs.detail_group": "Group", "jobs.detail_execute_id": "Execution", "jobs.detail_output": "Output", @@ -200,6 +234,7 @@ const STRINGS = { "jobs.col.id": "#", "jobs.col.job": "Job", "jobs.col.status": "Status", + "jobs.col.next_run": "Next run", "jobs.col.progress": "Progress", "jobs.col.speed": "Speed", "jobs.col.eta": "ETA", @@ -210,6 +245,8 @@ const STRINGS = { "jobs.status.failed": "failed", "jobs.status.done": "done", "jobs.status.finished": "finished", + "jobs.status.scheduled": "scheduled", + "jobs.status.stopped": "stopped", "jobs.stop": "Stop", "jobs.submitted_cli": "— submitted via CLI —", }, @@ -233,6 +270,8 @@ const STRINGS = { "error.couldnt_load_jobs": "无法加载任务列表", "error.no_remotes_selected": "请选择源和目标位置", "error.no_paths_selected": "请输入源路径和目标路径", + "error.no_schedule_selected": "请选择固定循环计划", + "error.invalid_schedule_time": "请选择有效的运行时间", "error.not_connected": "无法连接到 rclone", "footer.product": "产品", @@ -330,7 +369,7 @@ const STRINGS = { "configure.example_custom_placeholder": "自定义值", "jobs.title": "任务", - "jobs.subtitle": "正在运行和最近完成的传输。", + "jobs.subtitle": "已保存的循环传输任务及其最近运行状态。", "jobs.new_btn": "新建任务", "jobs.cancel": "取消", "jobs.new_title": "新建任务", @@ -339,6 +378,29 @@ const STRINGS = { "jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)", "jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)", "jobs.action_move": "move(镜像 源 → 目标,完成后删除源)", + "jobs.schedule_type": "任务类型", + "jobs.schedule_once": "单次任务", + "jobs.schedule_recurring": "固定循环", + "jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。", + "jobs.recurrence_kind": "循环计划", + "jobs.recurrence_daily": "每天", + "jobs.recurrence_weekly": "每周", + "jobs.recurrence_monthly": "每月", + "jobs.schedule_time": "运行时间", + "jobs.schedule_weekday": "星期", + "jobs.schedule_monthday": "每月几号", + "jobs.fixed_schedule_help": "按所选本地固定时间运行;如果上一轮仍在运行,不会重叠启动。", + "jobs.weekday_monday": "星期一", + "jobs.weekday_tuesday": "星期二", + "jobs.weekday_wednesday": "星期三", + "jobs.weekday_thursday": "星期四", + "jobs.weekday_friday": "星期五", + "jobs.weekday_saturday": "星期六", + "jobs.weekday_sunday": "星期日", + "jobs.monthday": (n) => `${n} 号`, + "jobs.schedule_label_daily": (time) => `每天 ${time}`, + "jobs.schedule_label_weekly": (day, time) => `每周${day.replace(/^星期/, "")} ${time}`, + "jobs.schedule_label_monthly": (day, time) => `每月 ${day} 号 ${time}`, "jobs.local_option": "本地文件系统", "jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。", "jobs.source_remote": "源位置", @@ -356,20 +418,29 @@ const STRINGS = { "jobs.empty_title": "尚无任务", "jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。", "jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`, + "jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id},不会保存记录。`, + "jobs.recurring_saved": (id) => `已保存循环任务 #${id}`, "jobs.start_failed": "任务启动失败", "jobs.stop_confirm": (id) => `停止任务 #${id}?`, "jobs.stopped": (id) => `已停止任务 #${id}`, "jobs.stop_failed": "停止失败", + "jobs.start_again": "再次启动", + "jobs.restarted": (id) => `已启动循环任务 #${id}`, + "jobs.restart_failed": "再次启动失败", "jobs.details": "详情", "jobs.hide_details": "收起", "jobs.delete_record": "删除", "jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`, + "jobs.delete_failed": "删除失败", + "jobs.detail_schedule_id": "调度 ID", "jobs.detail_id": "任务 ID", "jobs.detail_status": "状态", "jobs.detail_error": "错误", "jobs.detail_started": "开始时间", "jobs.detail_finished": "结束时间", "jobs.detail_duration": "耗时", + "jobs.detail_next_run": "下次启动", + "jobs.detail_schedule": "固定计划", "jobs.detail_group": "分组", "jobs.detail_execute_id": "执行实例", "jobs.detail_output": "输出", @@ -377,6 +448,7 @@ const STRINGS = { "jobs.col.id": "#", "jobs.col.job": "任务", "jobs.col.status": "状态", + "jobs.col.next_run": "下次启动", "jobs.col.progress": "进度", "jobs.col.speed": "速度", "jobs.col.eta": "预计剩余", @@ -387,6 +459,8 @@ const STRINGS = { "jobs.status.failed": "失败", "jobs.status.done": "完成", "jobs.status.finished": "已结束", + "jobs.status.scheduled": "已调度", + "jobs.status.stopped": "已停止", "jobs.stop": "停止", "jobs.submitted_cli": "— 通过命令行提交 —", }, diff --git a/webgui/web/assets/js/state.js b/webgui/web/assets/js/state.js index 084022b..c4c70e1 100644 --- a/webgui/web/assets/js/state.js +++ b/webgui/web/assets/js/state.js @@ -1,49 +1,12 @@ -// state.js — minimal pub/sub store + hash router + localStorage-backed -// job metadata cache. Vanilla, no framework. - -const JOB_META_KEY = "webgui:jobMeta"; -const JOB_META_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +// state.js — minimal pub/sub store + hash router. Vanilla, no framework. const listeners = new Set(); const state = { remotes: [], // [{ name, type }] jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst } providers: null, // cached /config/providers response - // jobMeta is persisted to localStorage so the jobs view can show - // src/dst for jobs after a page reload. Keyed by jobid (number). - jobMeta: loadJobMeta(), }; -function loadJobMeta() { - try { - const raw = localStorage.getItem(JOB_META_KEY); - if (!raw) return new Map(); - const arr = JSON.parse(raw); - if (!Array.isArray(arr)) return new Map(); - const now = Date.now(); - const map = new Map(); - for (const [id, meta] of arr) { - if (meta && typeof meta.submittedAt === "number") { - if (now - meta.submittedAt < JOB_META_TTL_MS) { - map.set(Number(id), meta); - } - } - } - return map; - } catch { - return new Map(); - } -} - -function saveJobMeta() { - try { - const arr = Array.from(state.jobMeta.entries()); - localStorage.setItem(JOB_META_KEY, JSON.stringify(arr)); - } catch { - // localStorage might be unavailable (private mode, quota); ignore. - } -} - export function getState() { return state; } @@ -64,33 +27,6 @@ export function subscribe(fn) { return () => listeners.delete(fn); } -// --- Job metadata API --- - -export function rememberJob(jobid, { action, src, dst }) { - state.jobMeta.set(Number(jobid), { - action, - src, - dst, - submittedAt: Date.now(), - }); - saveJobMeta(); -} - -export function getJobMeta(jobid) { - return state.jobMeta.get(Number(jobid)); -} - -export function listJobMeta() { - return Array.from(state.jobMeta.entries()) - .map(([jobid, meta]) => ({ jobid: Number(jobid), ...meta })) - .sort((a, b) => b.jobid - a.jobid); -} - -export function forgetJob(jobid) { - state.jobMeta.delete(Number(jobid)); - saveJobMeta(); -} - // --- Router --- // Hash-based routes: // #/remotes diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js index 7734d2c..308628a 100644 --- a/webgui/web/assets/js/views/browser.js +++ b/webgui/web/assets/js/views/browser.js @@ -1,8 +1,8 @@ // views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename. import { post, uploadFile, downloadURL } from "../rc.js"; -import { toast, formatBytes, formatTime } from "../state.js"; -import { t } from "../i18n.js"; +import { toast, formatBytes, formatTime } from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; export async function renderBrowse({ remote, path }) { const app = document.getElementById("app"); diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js index 0a7c1cf..fc4b7e5 100644 --- a/webgui/web/assets/js/views/configure.js +++ b/webgui/web/assets/js/views/configure.js @@ -10,8 +10,8 @@ // and disabled submit — user must run `rclone config` in a terminal. import { post } from "../rc.js"; -import { getState, setState, toast } from "../state.js"; -import { t } from "../i18n.js"; +import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; // --- Route entrypoints --- diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js index c2d9163..20962ee 100644 --- a/webgui/web/assets/js/views/jobs.js +++ b/webgui/web/assets/js/views/jobs.js @@ -1,19 +1,21 @@ -// views/jobs.js — submit sync/copy/move jobs and poll their progress. -// Job src/dst metadata is persisted to localStorage (via state.rememberJob) -// so the table can show what each job is doing even after a page reload. +// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers. import { post, postAsync } from "../rc.js"; import { toast, - rememberJob, - getJobMeta, - listJobMeta, - forgetJob, formatBytes, formatSpeed, formatDuration, -} from "../state.js"; -import { t } from "../i18n.js"; +} from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; +import { + createRecurringJob, + deleteRecurringJob, + jobsApiURL, + listRecurringJobs, + runRecurringJobNow, + stopRecurringJob, +} from "../jobs_api.js?v=recurring-jobs-5"; let pollTimer = null; const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer"; @@ -78,6 +80,60 @@ export async function renderNewJob() { +
+ +
+ + +
+ ${t("jobs.schedule_help")} +
+
@@ -121,6 +177,31 @@ export async function renderNewJob() { } const form = document.getElementById("new-job-form"); + const recurrenceFields = document.getElementById("recurrence-fields"); + const weeklyField = form.querySelector("[data-weekly-field]"); + const monthlyField = form.querySelector("[data-monthly-field]"); + const recurrenceKindInputs = Array.from(form.querySelectorAll('input[name="recurrenceKind"]')); + const updateScheduleFields = () => { + const isRecurring = form.elements.scheduleType.value === "recurring"; + const recurrenceKind = getRecurrenceKind(form); + recurrenceFields.hidden = !isRecurring; + weeklyField.hidden = !isRecurring || recurrenceKind !== "weekly"; + monthlyField.hidden = !isRecurring || recurrenceKind !== "monthly"; + recurrenceKindInputs.forEach((input) => { + input.required = isRecurring; + }); + form.elements.scheduleTime.required = isRecurring; + form.elements.scheduleWeekday.required = isRecurring && recurrenceKind === "weekly"; + form.elements.scheduleMonthday.required = isRecurring && recurrenceKind === "monthly"; + }; + form.querySelectorAll('input[name="scheduleType"]').forEach((input) => { + input.addEventListener("change", updateScheduleFields); + }); + recurrenceKindInputs.forEach((input) => { + input.addEventListener("change", updateScheduleFields); + }); + updateScheduleFields(); + form.querySelector('[data-pick="src"]').addEventListener("click", () => { openFolderPicker({ location: form.elements.srcRemote.value, @@ -143,6 +224,7 @@ export async function renderNewJob() { const dstRemote = form.elements.dstRemote.value; const srcPath = form.elements.srcPath.value.trim(); const dstPath = form.elements.dstPath.value.trim(); + const scheduleType = form.elements.scheduleType.value; try { const src = buildJobFs(srcRemote, srcPath); const dst = buildJobFs(dstRemote, dstPath); @@ -154,18 +236,24 @@ export async function renderNewJob() { toast(t("error.no_paths_selected"), "error"); return; } - const body = { - srcFs: src, - dstFs: dst, - _group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`, - }; - if (action === "move") body.deleteEmptySrcDirs = true; + + if (scheduleType === "recurring") { + const schedule = readRecurringSchedule(form); + const job = await createRecurringJob({ + action, + src, + dst, + ...schedule, + }); + toast(t("jobs.recurring_saved", job && job.id), "success"); + location.hash = "#/jobs"; + return; + } + + const body = buildTransferBody(action, src, dst); const res = await postAsync(`sync/${action}`, body); const jobid = res && res.jobid; - if (jobid != null) { - rememberJob(jobid, { action, src, dst }); - } - toast(t("jobs.started", action, jobid), "success"); + toast(t("jobs.started_once", action, jobid), "success"); location.hash = "#/jobs"; } catch (e) { toast(`${t("jobs.start_failed")}: ${e.message}`, "error"); @@ -173,6 +261,37 @@ export async function renderNewJob() { }); } +function renderWeekdayOptions() { + return [ + [1, t("jobs.weekday_monday")], + [2, t("jobs.weekday_tuesday")], + [3, t("jobs.weekday_wednesday")], + [4, t("jobs.weekday_thursday")], + [5, t("jobs.weekday_friday")], + [6, t("jobs.weekday_saturday")], + [7, t("jobs.weekday_sunday")], + ] + .map(([value, label]) => ``) + .join(""); +} + +function renderMonthdayOptions() { + return Array.from({ length: 31 }, (_, index) => { + const day = index + 1; + return ``; + }).join(""); +} + +function buildTransferBody(action, src, dst) { + const body = { + srcFs: src, + dstFs: dst, + _group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`, + }; + if (action === "move") body.deleteEmptySrcDirs = true; + return body; +} + function buildJobFs(location, rawPath) { if (location === LOCAL_FS_VALUE) { return rawPath.trim(); @@ -181,6 +300,39 @@ function buildJobFs(location, rawPath) { return remotePath ? `${location}${remotePath}` : location; } +function readRecurringSchedule(form) { + const scheduleKind = getRecurrenceKind(form); + const scheduleTime = form.elements.scheduleTime.value; + if (!["daily", "weekly", "monthly"].includes(scheduleKind)) { + throw new Error(t("error.no_schedule_selected")); + } + if (!/^\d{2}:\d{2}$/.test(scheduleTime)) { + throw new Error(t("error.invalid_schedule_time")); + } + const schedule = { + scheduleKind, + scheduleTime, + timezoneOffsetMinutes: -new Date().getTimezoneOffset(), + }; + if (scheduleKind === "weekly") { + schedule.scheduleWeekday = Number(form.elements.scheduleWeekday.value); + if (!Number.isInteger(schedule.scheduleWeekday) || schedule.scheduleWeekday < 1 || schedule.scheduleWeekday > 7) { + throw new Error(t("error.no_schedule_selected")); + } + } + if (scheduleKind === "monthly") { + schedule.scheduleMonthday = Number(form.elements.scheduleMonthday.value); + if (!Number.isInteger(schedule.scheduleMonthday) || schedule.scheduleMonthday < 1 || schedule.scheduleMonthday > 31) { + throw new Error(t("error.no_schedule_selected")); + } + } + return schedule; +} + +function getRecurrenceKind(form) { + return form.elements.recurrenceKind.value || "daily"; +} + async function openFolderPicker({ location, pathInput, title }) { const root = document.getElementById("modal-root"); let current = initialPickerPath(location, pathInput.value); @@ -303,10 +455,21 @@ async function refreshJobs() { const card = document.getElementById("jobs-card"); if (!card) return false; - const trackedJobs = listJobMeta(); - const jobIds = trackedJobs.map((job) => job.jobid); + let jobs = []; + try { + jobs = await listRecurringJobs(); + } catch (e) { + card.innerHTML = ` +
+

${t("error.couldnt_load_jobs")}

+

${escapeHtml(e.message)}

+

${escapeHtml(jobsApiURL())}

+
+ `; + return false; + } - if (jobIds.length === 0) { + if (jobs.length === 0) { card.innerHTML = `

${t("jobs.empty_title")}

@@ -316,51 +479,24 @@ async function refreshJobs() { return false; } - const statuses = await Promise.all( - jobIds.map((id) => - post("job/status", { jobid: id }) - .then((status) => ({ ...status, jobid: id })) - .catch((e) => { - if (e.message.includes("job not found")) { - forgetJob(id); - expandedJobs.delete(id); - return null; - } - return { - jobid: id, - error: e.message, - finished: true, - }; - }), - ), - ); - - const tracked = statuses.filter(Boolean); - if (tracked.length === 0) { - card.innerHTML = ` -
-

${t("jobs.empty_title")}

-

${t("jobs.empty_body").replace("New Job", `${t("jobs.new_btn")}`)}

-
- `; - return false; - } - - card.innerHTML = renderJobTable(tracked); + card.innerHTML = renderJobTable(jobs); card.querySelectorAll("[data-detail]").forEach((btn) => { btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10))); }); + card.querySelectorAll("[data-start]").forEach((btn) => { + btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 10))); + }); card.querySelectorAll("[data-stop]").forEach((btn) => { btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10))); }); card.querySelectorAll("[data-delete]").forEach((btn) => { btn.addEventListener("click", () => onDelete(parseInt(btn.dataset.delete, 10))); }); - return tracked.some((status) => !status.finished); + return jobs.length > 0; } function renderJobTable(statuses) { - statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0)); + statuses.sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); const rows = statuses.map(renderJobRows).join(""); @@ -371,6 +507,7 @@ function renderJobTable(statuses) { ${t("jobs.col.id")} ${t("jobs.col.job")} ${t("jobs.col.status")} + ${t("jobs.col.next_run")} ${t("jobs.col.progress")} ${t("jobs.col.speed")} ${t("jobs.col.eta")} @@ -388,42 +525,36 @@ function renderJobTable(statuses) { function renderJobRows(s) { const row = renderJobRow(s); - if (!expandedJobs.has(Number(s.jobid))) { + if (!expandedJobs.has(Number(s.id))) { return row; } return row + renderJobDetailsRow(s); } -function renderJobRow(s) { - const p = s.progress || {}; - const id = s.jobid; - const finished = s.finished; - const success = s.success; - const errored = !!s.error; +function renderJobRow(job) { + const snapshot = job.statusSnapshot || {}; + const p = snapshot.progress || {}; + const id = job.id; + const running = !!job.running; + const jobid = job.currentJobid || job.lastJobid; - let badge; - if (!finished) { - badge = `${t("jobs.status.running")}`; - } else if (errored || (!success && errored)) { - badge = `${t("jobs.status.failed")}`; - } else if (success) { - badge = `${t("jobs.status.done")}`; - } else { - badge = `${t("jobs.status.finished")}`; - } + const badge = renderStatusBadge(job.status); + const jobCell = ` +
+ ${escapeHtml(job.action)} + + ${escapeHtml(job.src)} + + ${escapeHtml(job.dst)} + +
`; - const meta = getJobMeta(id); - const jobCell = meta - ? ` -
- ${escapeHtml(meta.action)} - - ${escapeHtml(meta.src)} - - ${escapeHtml(meta.dst)} - -
` - : `${t("jobs.submitted_cli")}`; + const scheduleCell = ` +
+ ${escapeHtml(formatDateTime(job.nextRunAt))} + ${escapeHtml(formatSchedule(job))} +
+ `; const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0; const progress = ` @@ -436,24 +567,32 @@ function renderJobRow(s) { `; const detailBtn = ``; - const stopBtn = !finished + const startBtn = running + ? "" + : ``; + const stopBtn = running ? `` : ""; const deleteBtn = ``; return ` - ${id} + + ${id} + ${jobid ? `
job ${escapeHtml(jobid)}` : ""} + ${jobCell} ${badge} + ${scheduleCell} ${progress} - ${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))} - ${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))} + ${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"} + ${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"} ${p.transfers ?? 0} / ${p.totalTransfers ?? 0} ${(p.errors && p.errors.length) || 0}
${detailBtn} + ${startBtn} ${stopBtn} ${deleteBtn}
@@ -462,26 +601,48 @@ function renderJobRow(s) { `; } -function renderJobDetailsRow(s) { - const id = s.jobid; +function renderStatusBadge(status) { + switch (status) { + case "running": + return `${t("jobs.status.running")}`; + case "failed": + return `${t("jobs.status.failed")}`; + case "done": + return `${t("jobs.status.done")}`; + case "stopped": + return `${t("jobs.status.stopped")}`; + case "scheduled": + return `${t("jobs.status.scheduled")}`; + default: + return `${t("jobs.status.finished")}`; + } +} + +function renderJobDetailsRow(job) { + const id = job.id; + const snapshot = job.statusSnapshot || {}; + const jobid = job.currentJobid || job.lastJobid || ""; const details = [ - [t("jobs.detail_id"), id], - [t("jobs.detail_status"), detailStatus(s)], - [t("jobs.detail_error"), s.error || t("jobs.detail_none")], - [t("jobs.detail_started"), formatDateTime(s.startTime)], - [t("jobs.detail_finished"), s.finished ? formatDateTime(s.endTime) : t("jobs.status.running")], - [t("jobs.detail_duration"), formatDuration(s.duration || 0)], - [t("jobs.detail_group"), s.group || ""], - [t("jobs.detail_execute_id"), s.executeId || ""], + [t("jobs.detail_schedule_id"), id], + [t("jobs.detail_id"), jobid], + [t("jobs.detail_status"), detailStatus(job)], + [t("jobs.detail_error"), job.error || snapshot.error || t("jobs.detail_none")], + [t("jobs.detail_started"), formatDateTime(job.startedAt || snapshot.startTime)], + [t("jobs.detail_finished"), job.running ? t("jobs.status.running") : formatDateTime(job.finishedAt || snapshot.endTime)], + [t("jobs.detail_duration"), formatDuration(snapshot.duration || 0)], + [t("jobs.detail_next_run"), formatDateTime(job.nextRunAt)], + [t("jobs.detail_schedule"), formatSchedule(job)], + [t("jobs.detail_group"), snapshot.group || ""], + [t("jobs.detail_execute_id"), snapshot.executeId || ""], ]; - const output = s.output && Object.keys(s.output).length > 0 - ? JSON.stringify(s.output, null, 2) + const output = snapshot && Object.keys(snapshot).length > 0 + ? JSON.stringify(snapshot, null, 2) : ""; return ` - +
${details.map(([label, value]) => ` @@ -503,17 +664,16 @@ function renderJobDetailsRow(s) { `; } -function detailStatus(s) { - if (!s.finished) return t("jobs.status.running"); - if (s.error) return t("jobs.status.failed"); - if (s.success) return t("jobs.status.done"); - return t("jobs.status.finished"); +function detailStatus(job) { + const status = job.status || "scheduled"; + const key = `jobs.status.${status}`; + return t(key) === key ? status : t(key); } async function onStop(jobid) { if (!confirm(t("jobs.stop_confirm", jobid))) return; try { - await post("job/stop", { jobid }); + await stopRecurringJob(jobid); toast(t("jobs.stopped", jobid), "success"); await refreshJobs(); } catch (e) { @@ -530,11 +690,25 @@ async function onToggleDetails(jobid) { await refreshJobs(); } +async function onStartAgain(jobid) { + try { + await runRecurringJobNow(jobid); + toast(t("jobs.restarted", jobid), "success"); + await refreshJobs(); + } catch (e) { + toast(`${t("jobs.restart_failed")}: ${e.message}`, "error"); + } +} + async function onDelete(jobid) { if (!confirm(t("jobs.delete_confirm", jobid))) return; - forgetJob(jobid); - expandedJobs.delete(jobid); - await refreshJobs(); + try { + await deleteRecurringJob(jobid); + expandedJobs.delete(jobid); + await refreshJobs(); + } catch (e) { + toast(`${t("jobs.delete_failed")}: ${e.message}`, "error"); + } } function startPolling() { @@ -548,7 +722,7 @@ function startPolling() { if (!hasRunning) { stopPolling(); } - }, 1500); + }, 5000); } function stopPolling() { @@ -581,3 +755,30 @@ function formatDateTime(value) { if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleString(); } + +function formatSchedule(job) { + const time = job.scheduleTime || ""; + switch (job.scheduleKind) { + case "daily": + return t("jobs.schedule_label_daily", time); + case "weekly": + return t("jobs.schedule_label_weekly", weekdayName(job.scheduleWeekday), time); + case "monthly": + return t("jobs.schedule_label_monthly", job.scheduleMonthday, time); + default: + return ""; + } +} + +function weekdayName(value) { + const names = { + 1: t("jobs.weekday_monday"), + 2: t("jobs.weekday_tuesday"), + 3: t("jobs.weekday_wednesday"), + 4: t("jobs.weekday_thursday"), + 5: t("jobs.weekday_friday"), + 6: t("jobs.weekday_saturday"), + 7: t("jobs.weekday_sunday"), + }; + return names[Number(value)] || ""; +} diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js index 41d904c..710e097 100644 --- a/webgui/web/assets/js/views/remotes.js +++ b/webgui/web/assets/js/views/remotes.js @@ -1,8 +1,8 @@ // views/remotes.js — connector-tile grid of configured remotes with CRUD. import { post } from "../rc.js"; -import { getState, setState, toast } from "../state.js"; -import { t } from "../i18n.js"; +import { getState, setState, toast } from "../state.js?v=recurring-jobs-5"; +import { t } from "../i18n.js?v=recurring-jobs-5"; export async function renderRemotes() { const app = document.getElementById("app"); diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css index ae174de..8ab07c4 100644 --- a/webgui/web/assets/styles/components.css +++ b/webgui/web/assets/styles/components.css @@ -1,5 +1,9 @@ /* Component library — all UI primitives map to DESIGN.md tokens. */ +[hidden] { + display: none !important; +} + /* ---------- Top Navigation ---------- */ .top-nav { display: flex; @@ -318,6 +322,63 @@ flex-shrink: 0; } +.input-action .select { + flex: 0 0 150px; + width: auto; +} + +.segmented { + display: inline-flex; + width: fit-content; + padding: 3px; + border: 1px solid var(--color-hairline); + border-radius: var(--radius-md); + background-color: var(--color-surface-soft); +} + +.segmented label { + position: relative; + color: var(--color-muted); +} + +.segmented input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.segmented span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 92px; + height: 32px; + padding: 0 var(--space-sm); + border-radius: var(--radius-sm); + cursor: pointer; + font: var(--typo-button); +} + +.segmented input:checked + span { + background-color: var(--color-canvas); + color: var(--color-ink); + box-shadow: 0 1px 2px rgba(20, 20, 19, 0.06); +} + +.segmented-wide { + max-width: 100%; + flex-wrap: wrap; +} + +.recurrence-fields { + display: grid; + gap: var(--space-md); +} + +.recurrence-fields[hidden] { + display: none; +} + /* ---------- Badges ---------- */ .badge { display: inline-flex; diff --git a/webgui/web/index.html b/webgui/web/index.html index 7899c9f..6ccfccc 100644 --- a/webgui/web/index.html +++ b/webgui/web/index.html @@ -7,7 +7,7 @@ - +
@@ -80,6 +80,6 @@
- +