942 lines
30 KiB
JavaScript
942 lines
30 KiB
JavaScript
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
|
|
|
import { post } from "../rc.js?v=security-fixes-1";
|
|
import {
|
|
toast,
|
|
formatBytes,
|
|
formatSpeed,
|
|
formatDuration,
|
|
escapeHtml,
|
|
} from "../state.js?v=security-fixes-1";
|
|
import { t } from "../i18n.js?v=security-fixes-1";
|
|
import {
|
|
createOneTimeJob,
|
|
createRecurringJob,
|
|
deleteOneTimeJob,
|
|
deleteRecurringJob,
|
|
jobsApiURL,
|
|
listOneTimeJobs,
|
|
listRecurringJobs,
|
|
runOneTimeJobNow,
|
|
runRecurringJobNow,
|
|
stopOneTimeJob,
|
|
stopRecurringJob,
|
|
} from "../jobs_api.js?v=security-fixes-1";
|
|
|
|
let pollTimer = null;
|
|
const LOCAL_FS_VALUE = "__local__";
|
|
const expandedJobs = new Set();
|
|
const LOCAL_PICKER_ROOT = "/root";
|
|
let currentJobsView = "once";
|
|
|
|
export async function renderJobs() {
|
|
const app = document.getElementById("app");
|
|
app.innerHTML = `
|
|
<div class="section-head">
|
|
<div>
|
|
<h2>${t("jobs.title")}</h2>
|
|
<p class="subtitle">${t("jobs.subtitle")}</p>
|
|
</div>
|
|
<a class="btn btn-primary btn-sm" href="#/jobs/new">${t("jobs.new_btn")}</a>
|
|
</div>
|
|
<div class="toolbar">
|
|
<div class="segmented" role="tablist" aria-label="${t("jobs.view_filter")}">
|
|
<label>
|
|
<input type="radio" name="jobsView" value="once" ${currentJobsView === "once" ? "checked" : ""}>
|
|
<span>${t("jobs.view_once")}</span>
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="jobsView" value="recurring" ${currentJobsView === "recurring" ? "checked" : ""}>
|
|
<span>${t("jobs.view_recurring")}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div id="jobs-card" class="card-outline">
|
|
<p class="empty">${t("loading.title")}…</p>
|
|
</div>
|
|
`;
|
|
|
|
app.querySelectorAll('input[name="jobsView"]').forEach((input) => {
|
|
input.addEventListener("change", async () => {
|
|
currentJobsView = input.value;
|
|
const hasRunning = await refreshJobs();
|
|
if (hasRunning) {
|
|
startPolling();
|
|
} else {
|
|
stopPolling();
|
|
}
|
|
});
|
|
});
|
|
|
|
const hasRunning = await refreshJobs();
|
|
if (hasRunning) {
|
|
startPolling();
|
|
} else {
|
|
stopPolling();
|
|
}
|
|
}
|
|
|
|
export async function renderNewJob() {
|
|
const app = document.getElementById("app");
|
|
|
|
let remotes = [];
|
|
try {
|
|
const res = await post("config/listremotes");
|
|
remotes = (res && res.remotes) || [];
|
|
} catch (e) {
|
|
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
|
|
}
|
|
|
|
const locationOpts = [
|
|
...remotes.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`),
|
|
`<option value="${LOCAL_FS_VALUE}">${t("jobs.local_option")}</option>`,
|
|
]
|
|
.join("");
|
|
|
|
app.innerHTML = `
|
|
<div class="section-head">
|
|
<div>
|
|
<h2>${t("jobs.new_title")}</h2>
|
|
<p class="subtitle">${t("jobs.new_subtitle")}</p>
|
|
</div>
|
|
<a class="btn btn-secondary btn-sm" href="#/jobs">${t("jobs.cancel")}</a>
|
|
</div>
|
|
<form id="new-job-form" class="card-outline" style="display:grid;gap:16px;max-width:640px">
|
|
<div class="field">
|
|
<label>${t("jobs.action")}</label>
|
|
<select name="action" class="select">
|
|
<option value="copy">${t("jobs.action_copy")}</option>
|
|
<option value="sync">${t("jobs.action_sync")}</option>
|
|
<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>
|
|
<select name="srcRemote" class="select">
|
|
${locationOpts}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>${t("jobs.source_path")}</label>
|
|
<div class="input-action">
|
|
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-pick="src">${t("jobs.pick_folder")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>${t("jobs.dest_remote")}</label>
|
|
<select name="dstRemote" class="select">
|
|
${locationOpts}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>${t("jobs.dest_path")}</label>
|
|
<div class="input-action">
|
|
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
|
<button type="button" class="btn btn-secondary btn-sm" data-pick="dst">${t("jobs.pick_folder")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="field field-full">
|
|
<span class="field-help">${t("jobs.local_path_help")}</span>
|
|
</div>
|
|
</div>
|
|
<div class="toolbar">
|
|
<button type="submit" class="btn btn-primary">${t("jobs.start")}</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
|
|
if (remotes.length > 0) {
|
|
app.querySelector('select[name="srcRemote"]').value = `${remotes[0]}:`;
|
|
app.querySelector('select[name="dstRemote"]').value = `${remotes[0]}:`;
|
|
}
|
|
|
|
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,
|
|
pathInput: form.elements.srcPath,
|
|
title: t("jobs.pick_source_title"),
|
|
});
|
|
});
|
|
form.querySelector('[data-pick="dst"]').addEventListener("click", () => {
|
|
openFolderPicker({
|
|
location: form.elements.dstRemote.value,
|
|
pathInput: form.elements.dstPath,
|
|
title: t("jobs.pick_dest_title"),
|
|
});
|
|
});
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const action = form.elements.action.value;
|
|
const srcRemote = form.elements.srcRemote.value;
|
|
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);
|
|
if (!src || !dst) {
|
|
toast(t("error.no_paths_selected"), "error");
|
|
return;
|
|
}
|
|
if ((srcRemote === LOCAL_FS_VALUE && !srcPath) || (dstRemote === LOCAL_FS_VALUE && !dstPath)) {
|
|
toast(t("error.no_paths_selected"), "error");
|
|
return;
|
|
}
|
|
|
|
if (scheduleType === "recurring") {
|
|
const schedule = readRecurringSchedule(form);
|
|
const job = await createRecurringJob({
|
|
action,
|
|
src,
|
|
dst,
|
|
...schedule,
|
|
});
|
|
toast(t("jobs.recurring_saved", job && job.id), "success");
|
|
currentJobsView = "recurring";
|
|
location.hash = "#/jobs";
|
|
return;
|
|
}
|
|
|
|
const job = await createOneTimeJob({
|
|
action,
|
|
src,
|
|
dst,
|
|
});
|
|
toast(t("jobs.started_once", action, job && (job.jobid || job.id)), "success");
|
|
currentJobsView = "once";
|
|
location.hash = "#/jobs";
|
|
} catch (e) {
|
|
toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
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() {
|
|
const lastDay = `<option value="0">${t("jobs.monthday_last")}</option>`;
|
|
const numberedDays = Array.from({ length: 31 }, (_, index) => {
|
|
const day = index + 1;
|
|
return `<option value="${day}">${t("jobs.monthday", day)}</option>`;
|
|
}).join("");
|
|
return lastDay + numberedDays;
|
|
}
|
|
|
|
function buildJobFs(location, rawPath) {
|
|
if (location === LOCAL_FS_VALUE) {
|
|
return rawPath.trim();
|
|
}
|
|
const remotePath = rawPath.trim().replace(/^\/+|\/+$/g, "");
|
|
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 < 0 || 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);
|
|
|
|
const close = () => {
|
|
root.innerHTML = "";
|
|
};
|
|
|
|
const choose = () => {
|
|
pathInput.value = current;
|
|
close();
|
|
};
|
|
|
|
const render = async () => {
|
|
root.innerHTML = `
|
|
<div class="modal-overlay">
|
|
<div class="modal folder-picker">
|
|
<h3>${escapeHtml(title)}</h3>
|
|
<div class="folder-picker-path">
|
|
<span>${escapeHtml(displayPickerPath(location, current))}</span>
|
|
</div>
|
|
<div class="folder-picker-list">
|
|
<p class="empty">${t("loading.title")}…</p>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button type="button" class="btn btn-secondary" data-cancel>${t("browser.modal.cancel")}</button>
|
|
<button type="button" class="btn btn-primary" data-select>${t("jobs.pick_current")}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const overlay = root.querySelector(".modal-overlay");
|
|
const list = root.querySelector(".folder-picker-list");
|
|
overlay.addEventListener("click", (e) => {
|
|
if (e.target === overlay) close();
|
|
});
|
|
root.querySelector("[data-cancel]").addEventListener("click", close);
|
|
root.querySelector("[data-select]").addEventListener("click", choose);
|
|
|
|
try {
|
|
const dirs = await listDirs(location, current);
|
|
const parent = parentPickerPath(location, current);
|
|
list.innerHTML = `
|
|
<button type="button" class="folder-picker-row" data-path="${escapeAttr(parent ?? "")}" ${parent == null ? "disabled" : ""}>${t("browser.up")}</button>
|
|
${dirs.length === 0 ? `<p class="empty">${t("jobs.pick_empty")}</p>` : dirs.map((dir) => `
|
|
<button type="button" class="folder-picker-row" data-path="${escapeAttr(joinPickerPath(current, dir.Name))}">
|
|
${escapeHtml(dir.Name)}
|
|
</button>
|
|
`).join("")}
|
|
`;
|
|
list.querySelectorAll("[data-path]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
if (btn.disabled) return;
|
|
current = btn.dataset.path;
|
|
render();
|
|
});
|
|
});
|
|
} catch (e) {
|
|
list.innerHTML = `<div class="empty"><h3>${t("error.couldnt_list")}</h3><p>${escapeHtml(e.message)}</p></div>`;
|
|
}
|
|
};
|
|
|
|
await render();
|
|
}
|
|
|
|
async function listDirs(location, current) {
|
|
const isLocal = location === LOCAL_FS_VALUE;
|
|
const body = isLocal
|
|
? { fs: current || LOCAL_PICKER_ROOT, remote: "" }
|
|
: { fs: location, remote: trimRemotePath(current) };
|
|
const res = await post("operations/list", {
|
|
...body,
|
|
opt: { dirsOnly: true, noModTime: true, noMimeType: true, showHash: false },
|
|
});
|
|
const items = (res && res.list) || [];
|
|
return items
|
|
.filter((item) => item.IsDir)
|
|
.sort((a, b) => a.Name.localeCompare(b.Name));
|
|
}
|
|
|
|
function initialPickerPath(location, rawPath) {
|
|
const value = rawPath.trim();
|
|
if (location === LOCAL_FS_VALUE) {
|
|
return value || LOCAL_PICKER_ROOT;
|
|
}
|
|
return trimRemotePath(value);
|
|
}
|
|
|
|
function displayPickerPath(location, current) {
|
|
if (location === LOCAL_FS_VALUE) {
|
|
return current || "/";
|
|
}
|
|
return current ? `${location}${current}` : location;
|
|
}
|
|
|
|
function parentPickerPath(location, current) {
|
|
if (location === LOCAL_FS_VALUE) {
|
|
if (!current || current === "/") return null;
|
|
const normalized = current.replace(/\/+$/g, "");
|
|
const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/";
|
|
return parent;
|
|
}
|
|
if (!current) return null;
|
|
const parts = trimRemotePath(current).split("/").filter(Boolean);
|
|
parts.pop();
|
|
return parts.join("/");
|
|
}
|
|
|
|
function joinPickerPath(current, name) {
|
|
if (!current || current === "/") return current === "/" ? `/${name}` : name;
|
|
return `${current.replace(/\/+$/g, "")}/${name}`;
|
|
}
|
|
|
|
function trimRemotePath(path) {
|
|
return path.trim().replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
async function refreshJobs() {
|
|
const card = document.getElementById("jobs-card");
|
|
if (!card) return false;
|
|
|
|
let jobs = [];
|
|
try {
|
|
if (currentJobsView === "once") {
|
|
jobs = (await listOneTimeJobs()).map(normalizeOneTimeJob);
|
|
} else {
|
|
jobs = (await listRecurringJobs()).map(normalizeRecurringJob);
|
|
}
|
|
} 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 (jobs.length === 0) {
|
|
card.innerHTML = `
|
|
<div class="empty">
|
|
<h3>${currentJobsView === "once" ? t("jobs.empty_once_title") : t("jobs.empty_recurring_title")}</h3>
|
|
<p>${emptyJobsBody()}</p>
|
|
</div>
|
|
`;
|
|
return false;
|
|
}
|
|
|
|
card.innerHTML = renderJobTable(jobs);
|
|
card.querySelectorAll("[data-detail]").forEach((btn) => {
|
|
btn.addEventListener("click", () => onToggleDetails(btn.dataset.detail));
|
|
});
|
|
card.querySelectorAll("[data-start]").forEach((btn) => {
|
|
btn.addEventListener("click", () => onStartAgain(btn.dataset.start));
|
|
});
|
|
card.querySelectorAll("[data-stop]").forEach((btn) => {
|
|
btn.addEventListener("click", () => onStop(btn.dataset.stop));
|
|
});
|
|
card.querySelectorAll("[data-delete]").forEach((btn) => {
|
|
btn.addEventListener("click", () => onDelete(btn.dataset.delete));
|
|
});
|
|
return jobs.some((job) => job.running);
|
|
}
|
|
|
|
function emptyJobsBody() {
|
|
const link = `<a href="#/jobs/new">${escapeHtml(t("jobs.new_btn"))}</a>`;
|
|
return t("jobs.empty_body", link);
|
|
}
|
|
|
|
function renderJobTable(statuses) {
|
|
statuses.sort((a, b) => {
|
|
const bTime = Date.parse(b.updatedAt || b.createdAt || b.startedAt || 0) || 0;
|
|
const aTime = Date.parse(a.updatedAt || a.createdAt || a.startedAt || 0) || 0;
|
|
return bTime - aTime;
|
|
});
|
|
|
|
const rows = statuses.map(renderJobRows).join("");
|
|
|
|
return `
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<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>
|
|
<th class="col-num">${t("jobs.col.files")}</th>
|
|
<th class="col-num">${t("jobs.col.errors")}</th>
|
|
<th class="col-num">${t("jobs.col.action")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rows}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
function renderJobRows(s) {
|
|
const row = renderJobRow(s);
|
|
if (!expandedJobs.has(jobKey(s))) {
|
|
return row;
|
|
}
|
|
return row + renderJobDetailsRow(s);
|
|
}
|
|
|
|
function renderJobRow(job) {
|
|
const snapshot = job.statusSnapshot || {};
|
|
const stats = transferStats(snapshot);
|
|
const id = job.id;
|
|
const key = jobKey(job);
|
|
const running = !!job.running;
|
|
const jobid = job.currentJobid || job.lastJobid;
|
|
const displayedSpeed = stats.speed || averageSpeed(stats);
|
|
const displayedEta = running ? stats.eta : 0;
|
|
|
|
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 scheduleCell = `
|
|
<div class="job-cell">
|
|
<span class="job-action">${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}</span>
|
|
<span class="job-paths">${escapeHtml(formatSchedule(job))}</span>
|
|
</div>
|
|
`;
|
|
|
|
const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0;
|
|
const progress = `
|
|
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px">
|
|
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
|
|
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
|
|
${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
|
|
</span>
|
|
</div>
|
|
`;
|
|
|
|
const detailBtn = `<button class="btn btn-secondary btn-sm" data-detail="${escapeAttr(key)}">${expandedJobs.has(key) ? t("jobs.hide_details") : t("jobs.details")}</button>`;
|
|
const startBtn = running
|
|
? ""
|
|
: `<button class="btn btn-secondary btn-sm" data-start="${escapeAttr(key)}">${t("jobs.start_again")}</button>`;
|
|
const stopBtn = running
|
|
? `<button class="btn btn-danger btn-sm" data-stop="${escapeAttr(key)}">${t("jobs.stop")}</button>`
|
|
: "";
|
|
const deleteBtn = `<button class="btn btn-danger btn-sm" data-delete="${escapeAttr(key)}">${t("jobs.delete_record")}</button>`;
|
|
|
|
return `
|
|
<tr>
|
|
<td class="col-mono">
|
|
${escapeHtml(displayJobId(job))}
|
|
${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">${escapeHtml(formatSpeed(displayedSpeed))}</td>
|
|
<td class="col-num col-mono">${escapeHtml(formatDuration(displayedEta))}</td>
|
|
<td class="col-num col-mono">${escapeHtml(formatTransferCount(stats))}</td>
|
|
<td class="col-num col-mono">${escapeHtml(String(stats.errors || 0))}</td>
|
|
<td class="col-num">
|
|
<div class="job-actions">
|
|
${detailBtn}
|
|
${startBtn}
|
|
${stopBtn}
|
|
${deleteBtn}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
function renderStatusBadge(status) {
|
|
switch (status) {
|
|
case "running":
|
|
case "starting":
|
|
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 snapshot = job.statusSnapshot || {};
|
|
const stats = transferStats(snapshot);
|
|
const jobid = job.currentJobid || job.lastJobid || "";
|
|
const details = [
|
|
[job.kind === "once" ? t("jobs.detail_record_id") : t("jobs.detail_schedule_id"), displayJobId(job)],
|
|
[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 || ""],
|
|
[t("jobs.detail_bytes"), `${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}`],
|
|
[t("jobs.detail_speed"), formatSpeed(stats.speed || averageSpeed(stats))],
|
|
[t("jobs.detail_eta"), formatDuration(job.running ? stats.eta : 0)],
|
|
[t("jobs.detail_files"), formatTransferCount(stats)],
|
|
[t("jobs.detail_errors"), String(stats.errors || 0)],
|
|
];
|
|
const output = snapshot && Object.keys(snapshot).length > 0
|
|
? JSON.stringify(snapshot, null, 2)
|
|
: "";
|
|
|
|
return `
|
|
<tr class="job-detail-row">
|
|
<td></td>
|
|
<td colspan="9">
|
|
<div class="job-detail">
|
|
<dl>
|
|
${details.map(([label, value]) => `
|
|
<div>
|
|
<dt>${escapeHtml(label)}</dt>
|
|
<dd>${escapeHtml(value)}</dd>
|
|
</div>
|
|
`).join("")}
|
|
</dl>
|
|
${output ? `
|
|
<div class="job-detail-output">
|
|
<span>${t("jobs.detail_output")}</span>
|
|
<pre>${escapeHtml(output)}</pre>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
function transferStats(snapshot) {
|
|
const stats = snapshot.stats || snapshot.progress || snapshot || {};
|
|
return {
|
|
bytes: numberOrZero(stats.bytes),
|
|
totalBytes: numberOrZero(stats.totalBytes),
|
|
speed: numberOrZero(stats.speed),
|
|
eta: numberOrNull(stats.eta),
|
|
transfers: numberOrZero(stats.transfers),
|
|
totalTransfers: numberOrZero(stats.totalTransfers),
|
|
checks: numberOrZero(stats.checks),
|
|
totalChecks: numberOrZero(stats.totalChecks),
|
|
transferTime: numberOrZero(stats.transferTime),
|
|
errors: normalizeErrors(stats.errors),
|
|
};
|
|
}
|
|
|
|
function averageSpeed(stats) {
|
|
if (stats.bytes > 0 && stats.transferTime > 0) {
|
|
return stats.bytes / stats.transferTime;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function formatTransferCount(stats) {
|
|
if (stats.totalTransfers > 0) {
|
|
return `${stats.transfers} / ${stats.totalTransfers}`;
|
|
}
|
|
if (stats.totalChecks > 0) {
|
|
return `${stats.checks} / ${stats.totalChecks}`;
|
|
}
|
|
return String(stats.transfers || stats.checks || 0);
|
|
}
|
|
|
|
function normalizeErrors(errors) {
|
|
if (Array.isArray(errors)) return errors.length;
|
|
return numberOrZero(errors);
|
|
}
|
|
|
|
function numberOrZero(value) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function numberOrNull(value) {
|
|
const n = Number(value);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
function detailStatus(job) {
|
|
const status = job.status || "scheduled";
|
|
const key = `jobs.status.${status}`;
|
|
return t(key) === key ? status : t(key);
|
|
}
|
|
|
|
async function onStop(jobid) {
|
|
const key = String(jobid);
|
|
const label = displayKey(key);
|
|
if (!confirm(t("jobs.stop_confirm", label))) return;
|
|
try {
|
|
if (key.startsWith("once:")) {
|
|
await stopOneTimeJob(oneTimeIdFromKey(key));
|
|
} else {
|
|
await stopRecurringJob(recurringIdFromKey(key));
|
|
}
|
|
toast(t("jobs.stopped", label), "success");
|
|
await refreshJobs();
|
|
} catch (e) {
|
|
toast(`${t("jobs.stop_failed")}: ${e.message}`, "error");
|
|
}
|
|
}
|
|
|
|
async function onToggleDetails(jobid) {
|
|
const key = String(jobid);
|
|
if (expandedJobs.has(key)) {
|
|
expandedJobs.delete(key);
|
|
} else {
|
|
expandedJobs.add(key);
|
|
}
|
|
await refreshJobs();
|
|
}
|
|
|
|
async function onStartAgain(jobid) {
|
|
const key = String(jobid);
|
|
try {
|
|
if (key.startsWith("once:")) {
|
|
const job = await runOneTimeJobNow(oneTimeIdFromKey(key));
|
|
toast(t("jobs.restarted_once", job && (job.jobid || job.id)), "success");
|
|
} else {
|
|
const id = recurringIdFromKey(key);
|
|
await runRecurringJobNow(id);
|
|
toast(t("jobs.restarted", id), "success");
|
|
}
|
|
await refreshJobs();
|
|
} catch (e) {
|
|
toast(`${t("jobs.restart_failed")}: ${e.message}`, "error");
|
|
}
|
|
}
|
|
|
|
async function onDelete(jobid) {
|
|
const key = String(jobid);
|
|
const label = displayKey(key);
|
|
if (!confirm(t("jobs.delete_confirm", label))) return;
|
|
try {
|
|
if (key.startsWith("once:")) {
|
|
await deleteOneTimeJob(oneTimeIdFromKey(key));
|
|
} else {
|
|
await deleteRecurringJob(recurringIdFromKey(key));
|
|
}
|
|
expandedJobs.delete(key);
|
|
await refreshJobs();
|
|
} catch (e) {
|
|
toast(`${t("jobs.delete_failed")}: ${e.message}`, "error");
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
stopPolling();
|
|
pollTimer = setInterval(async () => {
|
|
if (!document.getElementById("jobs-card")) {
|
|
stopPolling();
|
|
return;
|
|
}
|
|
const hasRunning = await refreshJobs();
|
|
if (!hasRunning) {
|
|
stopPolling();
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
export function stopJobPolling() {
|
|
stopPolling();
|
|
}
|
|
|
|
function normalizeRecurringJob(job) {
|
|
return {
|
|
...job,
|
|
kind: "recurring",
|
|
id: `recurring:${job.id}`,
|
|
scheduleId: job.id,
|
|
};
|
|
}
|
|
|
|
function normalizeOneTimeJob(job) {
|
|
return {
|
|
...job,
|
|
kind: "once",
|
|
id: `once:${job.id}`,
|
|
recordId: job.id,
|
|
};
|
|
}
|
|
|
|
function jobKey(job) {
|
|
return String(job.id);
|
|
}
|
|
|
|
function displayJobId(job) {
|
|
if (job.kind === "once") {
|
|
return t("jobs.once_id", job.recordId || String(job.id).replace(/^once:/, ""));
|
|
}
|
|
return job.scheduleId || String(job.id).replace(/^recurring:/, "");
|
|
}
|
|
|
|
function displayKey(key) {
|
|
if (key.startsWith("once:")) {
|
|
return t("jobs.once_id", key.replace(/^once:/, ""));
|
|
}
|
|
return key.replace(/^recurring:/, "");
|
|
}
|
|
|
|
function recurringIdFromKey(key) {
|
|
return Number(String(key).replace(/^recurring:/, ""));
|
|
}
|
|
|
|
function oneTimeIdFromKey(key) {
|
|
return Number(String(key).replace(/^once:/, ""));
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return escapeHtml(s);
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
function formatSchedule(job) {
|
|
if (job.kind === "once") {
|
|
return t("jobs.schedule_label_once");
|
|
}
|
|
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":
|
|
if (Number(job.scheduleMonthday) === 0) {
|
|
return t("jobs.schedule_label_monthly_last", time);
|
|
}
|
|
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)] || "";
|
|
}
|