Files
rclone_gui/webgui/web/assets/js/views/jobs.js
T
2026-06-20 09:54:13 +08:00

584 lines
18 KiB
JavaScript

// 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.
import { post, postAsync } from "../rc.js";
import {
toast,
rememberJob,
getJobMeta,
listJobMeta,
forgetJob,
formatBytes,
formatSpeed,
formatDuration,
} from "../state.js";
import { t } from "../i18n.js";
let pollTimer = null;
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
const LOCAL_FS_VALUE = "__local__";
const expandedJobs = new Set();
const LOCAL_PICKER_ROOT = "/root";
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 id="jobs-card" class="card-outline">
<p class="empty">${t("loading.title")}…</p>
</div>
`;
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="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");
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();
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;
}
const body = {
srcFs: src,
dstFs: dst,
_group: `${WEBGUI_JOB_GROUP_PREFIX}/${action}`,
};
if (action === "move") body.deleteEmptySrcDirs = true;
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");
location.hash = "#/jobs";
} catch (e) {
toast(`${t("jobs.start_failed")}: ${e.message}`, "error");
}
});
}
function buildJobFs(location, rawPath) {
if (location === LOCAL_FS_VALUE) {
return rawPath.trim();
}
const remotePath = rawPath.trim().replace(/^\/+|\/+$/g, "");
return remotePath ? `${location}${remotePath}` : location;
}
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;
const trackedJobs = listJobMeta();
const jobIds = trackedJobs.map((job) => job.jobid);
if (jobIds.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;
}
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.querySelectorAll("[data-detail]").forEach((btn) => {
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 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);
}
function renderJobTable(statuses) {
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
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 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(Number(s.jobid))) {
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;
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 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 pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.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(p.bytes)} / ${formatBytes(p.totalBytes)}
</span>
</div>
`;
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
? `<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>${jobCell}</td>
<td>${badge}</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">${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}
${stopBtn}
${deleteBtn}
</div>
</td>
</tr>
`;
}
function renderJobDetailsRow(s) {
const id = s.jobid;
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 || ""],
];
const output = s.output && Object.keys(s.output).length > 0
? JSON.stringify(s.output, null, 2)
: "";
return `
<tr class="job-detail-row">
<td></td>
<td colspan="8">
<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 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");
}
async function onStop(jobid) {
if (!confirm(t("jobs.stop_confirm", jobid))) return;
try {
await post("job/stop", { jobid });
toast(t("jobs.stopped", jobid), "success");
await refreshJobs();
} catch (e) {
toast(`${t("jobs.stop_failed")}: ${e.message}`, "error");
}
}
async function onToggleDetails(jobid) {
if (expandedJobs.has(jobid)) {
expandedJobs.delete(jobid);
} else {
expandedJobs.add(jobid);
}
await refreshJobs();
}
async function onDelete(jobid) {
if (!confirm(t("jobs.delete_confirm", jobid))) return;
forgetJob(jobid);
expandedJobs.delete(jobid);
await refreshJobs();
}
function startPolling() {
stopPolling();
pollTimer = setInterval(async () => {
if (!document.getElementById("jobs-card")) {
stopPolling();
return;
}
const hasRunning = await refreshJobs();
if (!hasRunning) {
stopPolling();
}
}, 1500);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
export function stopJobPolling() {
stopPolling();
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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();
}