add: 更新循环任务固定调度
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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": "— 通过命令行提交 —",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
+312
-111
@@ -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() {
|
||||
<option value="move">${t("jobs.action_move")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>${t("jobs.schedule_type")}</label>
|
||||
<div class="segmented" role="radiogroup" aria-label="${t("jobs.schedule_type")}">
|
||||
<label>
|
||||
<input type="radio" name="scheduleType" value="once" checked>
|
||||
<span>${t("jobs.schedule_once")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="scheduleType" value="recurring">
|
||||
<span>${t("jobs.schedule_recurring")}</span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="field-help">${t("jobs.schedule_help")}</span>
|
||||
</div>
|
||||
<div id="recurrence-fields" class="recurrence-fields" hidden>
|
||||
<div class="form-grid">
|
||||
<div class="field field-full">
|
||||
<label>${t("jobs.recurrence_kind")}</label>
|
||||
<div class="segmented segmented-wide" role="radiogroup" aria-label="${t("jobs.recurrence_kind")}">
|
||||
<label>
|
||||
<input type="radio" name="recurrenceKind" value="daily" checked>
|
||||
<span>${t("jobs.recurrence_daily")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="recurrenceKind" value="weekly">
|
||||
<span>${t("jobs.recurrence_weekly")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="recurrenceKind" value="monthly">
|
||||
<span>${t("jobs.recurrence_monthly")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>${t("jobs.schedule_time")}</label>
|
||||
<input class="input" type="time" name="scheduleTime" value="23:00">
|
||||
</div>
|
||||
<div class="field" data-weekly-field hidden>
|
||||
<label>${t("jobs.schedule_weekday")}</label>
|
||||
<select name="scheduleWeekday" class="select">
|
||||
${renderWeekdayOptions()}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" data-monthly-field hidden>
|
||||
<label>${t("jobs.schedule_monthday")}</label>
|
||||
<select name="scheduleMonthday" class="select">
|
||||
${renderMonthdayOptions()}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field field-full">
|
||||
<span class="field-help">${t("jobs.fixed_schedule_help")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>${t("jobs.source_remote")}</label>
|
||||
@@ -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]) => `<option value="${value}">${label}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderMonthdayOptions() {
|
||||
return Array.from({ length: 31 }, (_, index) => {
|
||||
const day = index + 1;
|
||||
return `<option value="${day}">${t("jobs.monthday", day)}</option>`;
|
||||
}).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 = `
|
||||
<div class="empty">
|
||||
<h3>${t("error.couldnt_load_jobs")}</h3>
|
||||
<p>${escapeHtml(e.message)}</p>
|
||||
<p class="col-mono">${escapeHtml(jobsApiURL())}</p>
|
||||
</div>
|
||||
`;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (jobIds.length === 0) {
|
||||
if (jobs.length === 0) {
|
||||
card.innerHTML = `
|
||||
<div class="empty">
|
||||
<h3>${t("jobs.empty_title")}</h3>
|
||||
@@ -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 = `
|
||||
<div class="empty">
|
||||
<h3>${t("jobs.empty_title")}</h3>
|
||||
<p>${t("jobs.empty_body").replace("New Job", `<a href="#/jobs/new">${t("jobs.new_btn")}</a>`)}</p>
|
||||
</div>
|
||||
`;
|
||||
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) {
|
||||
<th>${t("jobs.col.id")}</th>
|
||||
<th>${t("jobs.col.job")}</th>
|
||||
<th>${t("jobs.col.status")}</th>
|
||||
<th>${t("jobs.col.next_run")}</th>
|
||||
<th class="col-num">${t("jobs.col.progress")}</th>
|
||||
<th class="col-num">${t("jobs.col.speed")}</th>
|
||||
<th class="col-num">${t("jobs.col.eta")}</th>
|
||||
@@ -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 = `<span class="badge">${t("jobs.status.running")}</span>`;
|
||||
} else if (errored || (!success && errored)) {
|
||||
badge = `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
|
||||
} else if (success) {
|
||||
badge = `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
|
||||
} else {
|
||||
badge = `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
|
||||
}
|
||||
const badge = renderStatusBadge(job.status);
|
||||
const jobCell = `
|
||||
<div class="job-cell">
|
||||
<span class="job-action">${escapeHtml(job.action)}</span>
|
||||
<span class="job-paths">
|
||||
<code>${escapeHtml(job.src)}</code>
|
||||
<span class="arrow">→</span>
|
||||
<code>${escapeHtml(job.dst)}</code>
|
||||
</span>
|
||||
</div>`;
|
||||
|
||||
const meta = getJobMeta(id);
|
||||
const jobCell = meta
|
||||
? `
|
||||
<div class="job-cell">
|
||||
<span class="job-action">${escapeHtml(meta.action)}</span>
|
||||
<span class="job-paths">
|
||||
<code>${escapeHtml(meta.src)}</code>
|
||||
<span class="arrow">→</span>
|
||||
<code>${escapeHtml(meta.dst)}</code>
|
||||
</span>
|
||||
</div>`
|
||||
: `<span class="col-mono" style="color:var(--color-muted-soft)">${t("jobs.submitted_cli")}</span>`;
|
||||
const scheduleCell = `
|
||||
<div class="job-cell">
|
||||
<span class="job-action">${escapeHtml(formatDateTime(job.nextRunAt))}</span>
|
||||
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<button class="btn btn-secondary btn-sm" data-detail="${id}">${expandedJobs.has(Number(id)) ? t("jobs.hide_details") : t("jobs.details")}</button>`;
|
||||
const stopBtn = !finished
|
||||
const startBtn = running
|
||||
? ""
|
||||
: `<button class="btn btn-secondary btn-sm" data-start="${id}">${t("jobs.start_again")}</button>`;
|
||||
const stopBtn = running
|
||||
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</button>`
|
||||
: "";
|
||||
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${id}">${t("jobs.delete_record")}</button>`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="col-mono">${id}</td>
|
||||
<td class="col-mono">
|
||||
${id}
|
||||
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""}
|
||||
</td>
|
||||
<td>${jobCell}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${scheduleCell}</td>
|
||||
<td class="col-num">${progress}</td>
|
||||
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatSpeed(p.speed || 0))}</td>
|
||||
<td class="col-num col-mono">${finished ? "—" : escapeHtml(formatDuration(p.eta || 0))}</td>
|
||||
<td class="col-num col-mono">${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"}</td>
|
||||
<td class="col-num col-mono">${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"}</td>
|
||||
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
|
||||
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
|
||||
<td class="col-num">
|
||||
<div class="job-actions">
|
||||
${detailBtn}
|
||||
${startBtn}
|
||||
${stopBtn}
|
||||
${deleteBtn}
|
||||
</div>
|
||||
@@ -462,26 +601,48 @@ function renderJobRow(s) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderJobDetailsRow(s) {
|
||||
const id = s.jobid;
|
||||
function renderStatusBadge(status) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return `<span class="badge">${t("jobs.status.running")}</span>`;
|
||||
case "failed":
|
||||
return `<span class="badge badge-error">${t("jobs.status.failed")}</span>`;
|
||||
case "done":
|
||||
return `<span class="badge badge-success">${t("jobs.status.done")}</span>`;
|
||||
case "stopped":
|
||||
return `<span class="badge badge-warning">${t("jobs.status.stopped")}</span>`;
|
||||
case "scheduled":
|
||||
return `<span class="badge badge-warning">${t("jobs.status.scheduled")}</span>`;
|
||||
default:
|
||||
return `<span class="badge badge-warning">${t("jobs.status.finished")}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 `
|
||||
<tr class="job-detail-row">
|
||||
<td></td>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<div class="job-detail">
|
||||
<dl>
|
||||
${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)] || "";
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
||||
<link rel="stylesheet" href="/assets/styles/tokens.css">
|
||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css?v=jobs-picker-restore-1">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css?v=recurring-jobs-5">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
@@ -80,6 +80,6 @@
|
||||
<div id="toast-stack" class="toast-stack"></div>
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script type="module" src="/assets/js/app.js?v=jobs-picker-restore-1"></script>
|
||||
<script type="module" src="/assets/js/app.js?v=recurring-jobs-5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user