add: 任务添加本地文件路径以及任务添加状态详情
This commit is contained in:
@@ -17,6 +17,8 @@ import { t } from "../i18n.js";
|
||||
|
||||
let pollTimer = null;
|
||||
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
||||
const LOCAL_FS_VALUE = "__local__";
|
||||
const expandedJobs = new Set();
|
||||
|
||||
export async function renderJobs() {
|
||||
const app = document.getElementById("app");
|
||||
@@ -52,8 +54,10 @@ export async function renderNewJob() {
|
||||
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
|
||||
}
|
||||
|
||||
const remoteOpts = remotes
|
||||
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
|
||||
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 = `
|
||||
@@ -77,7 +81,7 @@ export async function renderNewJob() {
|
||||
<div class="field">
|
||||
<label>${t("jobs.source_remote")}</label>
|
||||
<select name="srcRemote" class="select">
|
||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
||||
${locationOpts}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -87,13 +91,16 @@ export async function renderNewJob() {
|
||||
<div class="field">
|
||||
<label>${t("jobs.dest_remote")}</label>
|
||||
<select name="dstRemote" class="select">
|
||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
||||
${locationOpts}
|
||||
</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 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>
|
||||
@@ -112,15 +119,19 @@ export async function renderNewJob() {
|
||||
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;
|
||||
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,
|
||||
@@ -140,6 +151,14 @@ export async function renderNewJob() {
|
||||
});
|
||||
}
|
||||
|
||||
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 refreshJobs() {
|
||||
const card = document.getElementById("jobs-card");
|
||||
if (!card) return false;
|
||||
@@ -159,17 +178,20 @@ async function refreshJobs() {
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
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,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -185,16 +207,22 @@ async function refreshJobs() {
|
||||
}
|
||||
|
||||
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(renderJobRow).join("");
|
||||
const rows = statuses.map(renderJobRows).join("");
|
||||
|
||||
return `
|
||||
<table class="table">
|
||||
@@ -218,6 +246,14 @@ function renderJobTable(statuses) {
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -259,9 +295,11 @@ function renderJobRow(s) {
|
||||
</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>
|
||||
@@ -273,11 +311,65 @@ function renderJobRow(s) {
|
||||
<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>
|
||||
<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 {
|
||||
@@ -289,6 +381,22 @@ async function onStop(jobid) {
|
||||
}
|
||||
}
|
||||
|
||||
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 () => {
|
||||
@@ -322,3 +430,10 @@ function escapeHtml(s) {
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user