325 lines
9.8 KiB
JavaScript
325 lines
9.8 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";
|
|
|
|
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 remoteOpts = remotes
|
|
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</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">
|
|
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>${t("jobs.source_path")}</label>
|
|
<input class="input" name="srcPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
|
</div>
|
|
<div class="field">
|
|
<label>${t("jobs.dest_remote")}</label>
|
|
<select name="dstRemote" class="select">
|
|
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>${t("jobs.dest_path")}</label>
|
|
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
|
</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.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().replace(/^\/+|\/+$/g, "");
|
|
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
|
|
if (!srcRemote || !dstRemote) {
|
|
toast(t("error.no_remotes_selected"), "error");
|
|
return;
|
|
}
|
|
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
|
|
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
|
|
try {
|
|
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");
|
|
}
|
|
});
|
|
}
|
|
|
|
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 }).catch((e) => {
|
|
if (e.message.includes("job not found")) {
|
|
forgetJob(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-stop]").forEach((btn) => {
|
|
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
|
|
});
|
|
return tracked.some((status) => !status.finished);
|
|
}
|
|
|
|
function renderJobTable(statuses) {
|
|
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
|
|
|
|
const rows = statuses.map(renderJobRow).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 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 stopBtn = !finished
|
|
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</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">${stopBtn}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|