add: 任务添加本地文件路径以及任务添加状态详情
This commit is contained in:
@@ -6,7 +6,7 @@ import { onRoute } from "./state.js";
|
|||||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
|
import { t, currentLocale, setLocale, onLocale } from "./i18n.js";
|
||||||
import { renderRemotes } from "./views/remotes.js";
|
import { renderRemotes } from "./views/remotes.js";
|
||||||
import { renderBrowse } from "./views/browser.js";
|
import { renderBrowse } from "./views/browser.js";
|
||||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js";
|
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=jobs-details-1";
|
||||||
import {
|
import {
|
||||||
renderConfigureNew,
|
renderConfigureNew,
|
||||||
renderConfigureEdit,
|
renderConfigureEdit,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const STRINGS = {
|
|||||||
"error.couldnt_list": "Couldn't list",
|
"error.couldnt_list": "Couldn't list",
|
||||||
"error.couldnt_load_jobs": "Couldn't load jobs",
|
"error.couldnt_load_jobs": "Couldn't load jobs",
|
||||||
"error.no_remotes_selected": "Pick source and destination remotes",
|
"error.no_remotes_selected": "Pick source and destination remotes",
|
||||||
|
"error.no_paths_selected": "Enter source and destination paths",
|
||||||
"error.not_connected": "Couldn't connect to rclone",
|
"error.not_connected": "Couldn't connect to rclone",
|
||||||
|
|
||||||
// footer
|
// footer
|
||||||
@@ -156,16 +157,18 @@ const STRINGS = {
|
|||||||
"jobs.new_btn": "New Job",
|
"jobs.new_btn": "New Job",
|
||||||
"jobs.cancel": "Cancel",
|
"jobs.cancel": "Cancel",
|
||||||
"jobs.new_title": "New Job",
|
"jobs.new_title": "New Job",
|
||||||
"jobs.new_subtitle": "Copy, sync, or move between remotes.",
|
"jobs.new_subtitle": "Copy, sync, or move between remotes and local paths.",
|
||||||
"jobs.action": "Action",
|
"jobs.action": "Action",
|
||||||
"jobs.action_copy": "copy (mirror src → dst, keep both)",
|
"jobs.action_copy": "copy (mirror src → dst, keep both)",
|
||||||
"jobs.action_sync": "sync (mirror src → dst, delete extras on dst)",
|
"jobs.action_sync": "sync (mirror src → dst, delete extras on dst)",
|
||||||
"jobs.action_move": "move (mirror src → dst, delete src after)",
|
"jobs.action_move": "move (mirror src → dst, delete src after)",
|
||||||
"jobs.source_remote": "Source remote",
|
"jobs.local_option": "Local filesystem",
|
||||||
"jobs.source_path": "Source path (optional)",
|
"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.dest_remote": "Destination remote",
|
"jobs.source_remote": "Source location",
|
||||||
"jobs.dest_path": "Destination path (optional)",
|
"jobs.source_path": "Source path",
|
||||||
"jobs.path_placeholder": "folder/subfolder",
|
"jobs.dest_remote": "Destination location",
|
||||||
|
"jobs.dest_path": "Destination path",
|
||||||
|
"jobs.path_placeholder": "folder/subfolder or /local/path",
|
||||||
"jobs.start": "Start Job",
|
"jobs.start": "Start Job",
|
||||||
"jobs.no_remotes_option": "(no remotes)",
|
"jobs.no_remotes_option": "(no remotes)",
|
||||||
"jobs.empty_title": "No jobs yet",
|
"jobs.empty_title": "No jobs yet",
|
||||||
@@ -175,6 +178,20 @@ const STRINGS = {
|
|||||||
"jobs.stop_confirm": (id) => `Stop job #${id}?`,
|
"jobs.stop_confirm": (id) => `Stop job #${id}?`,
|
||||||
"jobs.stopped": (id) => `Stopped job #${id}`,
|
"jobs.stopped": (id) => `Stopped job #${id}`,
|
||||||
"jobs.stop_failed": "Stop failed",
|
"jobs.stop_failed": "Stop 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.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_group": "Group",
|
||||||
|
"jobs.detail_execute_id": "Execution",
|
||||||
|
"jobs.detail_output": "Output",
|
||||||
|
"jobs.detail_none": "None",
|
||||||
"jobs.col.id": "#",
|
"jobs.col.id": "#",
|
||||||
"jobs.col.job": "Job",
|
"jobs.col.job": "Job",
|
||||||
"jobs.col.status": "Status",
|
"jobs.col.status": "Status",
|
||||||
@@ -209,7 +226,8 @@ const STRINGS = {
|
|||||||
"error.couldnt_load_remotes": "无法加载远程存储列表",
|
"error.couldnt_load_remotes": "无法加载远程存储列表",
|
||||||
"error.couldnt_list": "无法列出",
|
"error.couldnt_list": "无法列出",
|
||||||
"error.couldnt_load_jobs": "无法加载任务列表",
|
"error.couldnt_load_jobs": "无法加载任务列表",
|
||||||
"error.no_remotes_selected": "请选择源和目标远程存储",
|
"error.no_remotes_selected": "请选择源和目标位置",
|
||||||
|
"error.no_paths_selected": "请输入源路径和目标路径",
|
||||||
"error.not_connected": "无法连接到 rclone",
|
"error.not_connected": "无法连接到 rclone",
|
||||||
|
|
||||||
"footer.product": "产品",
|
"footer.product": "产品",
|
||||||
@@ -311,16 +329,18 @@ const STRINGS = {
|
|||||||
"jobs.new_btn": "新建任务",
|
"jobs.new_btn": "新建任务",
|
||||||
"jobs.cancel": "取消",
|
"jobs.cancel": "取消",
|
||||||
"jobs.new_title": "新建任务",
|
"jobs.new_title": "新建任务",
|
||||||
"jobs.new_subtitle": "在远程存储之间复制、同步或移动。",
|
"jobs.new_subtitle": "在远程存储和本地路径之间复制、同步或移动。",
|
||||||
"jobs.action": "操作",
|
"jobs.action": "操作",
|
||||||
"jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)",
|
"jobs.action_copy": "copy(镜像 源 → 目标,两者都保留)",
|
||||||
"jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)",
|
"jobs.action_sync": "sync(镜像 源 → 目标,删除目标的额外文件)",
|
||||||
"jobs.action_move": "move(镜像 源 → 目标,完成后删除源)",
|
"jobs.action_move": "move(镜像 源 → 目标,完成后删除源)",
|
||||||
"jobs.source_remote": "源远程存储",
|
"jobs.local_option": "本地文件系统",
|
||||||
"jobs.source_path": "源路径(可选)",
|
"jobs.local_path_help": "选择本地文件系统时填写 rclone 进程内可见的路径;当前 Docker 编排里通常是 /data/source。选择远程存储时填写该远程内的路径。",
|
||||||
"jobs.dest_remote": "目标远程存储",
|
"jobs.source_remote": "源位置",
|
||||||
"jobs.dest_path": "目标路径(可选)",
|
"jobs.source_path": "源路径",
|
||||||
"jobs.path_placeholder": "文件夹/子文件夹",
|
"jobs.dest_remote": "目标位置",
|
||||||
|
"jobs.dest_path": "目标路径",
|
||||||
|
"jobs.path_placeholder": "文件夹/子文件夹 或 /local/path",
|
||||||
"jobs.start": "开始任务",
|
"jobs.start": "开始任务",
|
||||||
"jobs.no_remotes_option": "(无远程存储)",
|
"jobs.no_remotes_option": "(无远程存储)",
|
||||||
"jobs.empty_title": "尚无任务",
|
"jobs.empty_title": "尚无任务",
|
||||||
@@ -330,6 +350,20 @@ const STRINGS = {
|
|||||||
"jobs.stop_confirm": (id) => `停止任务 #${id}?`,
|
"jobs.stop_confirm": (id) => `停止任务 #${id}?`,
|
||||||
"jobs.stopped": (id) => `已停止任务 #${id}`,
|
"jobs.stopped": (id) => `已停止任务 #${id}`,
|
||||||
"jobs.stop_failed": "停止失败",
|
"jobs.stop_failed": "停止失败",
|
||||||
|
"jobs.details": "详情",
|
||||||
|
"jobs.hide_details": "收起",
|
||||||
|
"jobs.delete_record": "删除",
|
||||||
|
"jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`,
|
||||||
|
"jobs.detail_id": "任务 ID",
|
||||||
|
"jobs.detail_status": "状态",
|
||||||
|
"jobs.detail_error": "错误",
|
||||||
|
"jobs.detail_started": "开始时间",
|
||||||
|
"jobs.detail_finished": "结束时间",
|
||||||
|
"jobs.detail_duration": "耗时",
|
||||||
|
"jobs.detail_group": "分组",
|
||||||
|
"jobs.detail_execute_id": "执行实例",
|
||||||
|
"jobs.detail_output": "输出",
|
||||||
|
"jobs.detail_none": "无",
|
||||||
"jobs.col.id": "#",
|
"jobs.col.id": "#",
|
||||||
"jobs.col.job": "任务",
|
"jobs.col.job": "任务",
|
||||||
"jobs.col.status": "状态",
|
"jobs.col.status": "状态",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { t } from "../i18n.js";
|
|||||||
|
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
||||||
|
const LOCAL_FS_VALUE = "__local__";
|
||||||
|
const expandedJobs = new Set();
|
||||||
|
|
||||||
export async function renderJobs() {
|
export async function renderJobs() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
@@ -52,8 +54,10 @@ export async function renderNewJob() {
|
|||||||
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
|
toast(`${t("error.couldnt_load_remotes")}: ${e.message}`, "error");
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteOpts = remotes
|
const locationOpts = [
|
||||||
.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`)
|
...remotes.map((r) => `<option value="${escapeHtml(r)}:">${escapeHtml(r)}</option>`),
|
||||||
|
`<option value="${LOCAL_FS_VALUE}">${t("jobs.local_option")}</option>`,
|
||||||
|
]
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
@@ -77,7 +81,7 @@ export async function renderNewJob() {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.source_remote")}</label>
|
<label>${t("jobs.source_remote")}</label>
|
||||||
<select name="srcRemote" class="select">
|
<select name="srcRemote" class="select">
|
||||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
${locationOpts}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -87,13 +91,16 @@ export async function renderNewJob() {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.dest_remote")}</label>
|
<label>${t("jobs.dest_remote")}</label>
|
||||||
<select name="dstRemote" class="select">
|
<select name="dstRemote" class="select">
|
||||||
${remoteOpts || `<option value="">${t("jobs.no_remotes_option")}</option>`}
|
${locationOpts}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>${t("jobs.dest_path")}</label>
|
<label>${t("jobs.dest_path")}</label>
|
||||||
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
<input class="input" name="dstPath" placeholder="${t("jobs.path_placeholder")}" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field field-full">
|
||||||
|
<span class="field-help">${t("jobs.local_path_help")}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button type="submit" class="btn btn-primary">${t("jobs.start")}</button>
|
<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 action = form.elements.action.value;
|
||||||
const srcRemote = form.elements.srcRemote.value;
|
const srcRemote = form.elements.srcRemote.value;
|
||||||
const dstRemote = form.elements.dstRemote.value;
|
const dstRemote = form.elements.dstRemote.value;
|
||||||
const srcPath = form.elements.srcPath.value.trim().replace(/^\/+|\/+$/g, "");
|
const srcPath = form.elements.srcPath.value.trim();
|
||||||
const dstPath = form.elements.dstPath.value.trim().replace(/^\/+|\/+$/g, "");
|
const dstPath = form.elements.dstPath.value.trim();
|
||||||
if (!srcRemote || !dstRemote) {
|
try {
|
||||||
toast(t("error.no_remotes_selected"), "error");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const src = srcPath ? `${srcRemote}${srcPath}` : srcRemote;
|
|
||||||
const dst = dstPath ? `${dstRemote}${dstPath}` : dstRemote;
|
|
||||||
try {
|
|
||||||
const body = {
|
const body = {
|
||||||
srcFs: src,
|
srcFs: src,
|
||||||
dstFs: dst,
|
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() {
|
async function refreshJobs() {
|
||||||
const card = document.getElementById("jobs-card");
|
const card = document.getElementById("jobs-card");
|
||||||
if (!card) return false;
|
if (!card) return false;
|
||||||
@@ -159,9 +178,12 @@ async function refreshJobs() {
|
|||||||
|
|
||||||
const statuses = await Promise.all(
|
const statuses = await Promise.all(
|
||||||
jobIds.map((id) =>
|
jobIds.map((id) =>
|
||||||
post("job/status", { jobid: id }).catch((e) => {
|
post("job/status", { jobid: id })
|
||||||
|
.then((status) => ({ ...status, jobid: id }))
|
||||||
|
.catch((e) => {
|
||||||
if (e.message.includes("job not found")) {
|
if (e.message.includes("job not found")) {
|
||||||
forgetJob(id);
|
forgetJob(id);
|
||||||
|
expandedJobs.delete(id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -185,16 +207,22 @@ async function refreshJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
card.innerHTML = renderJobTable(tracked);
|
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) => {
|
card.querySelectorAll("[data-stop]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
|
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 tracked.some((status) => !status.finished);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobTable(statuses) {
|
function renderJobTable(statuses) {
|
||||||
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
|
statuses.sort((a, b) => (b.jobid ?? 0) - (a.jobid ?? 0));
|
||||||
|
|
||||||
const rows = statuses.map(renderJobRow).join("");
|
const rows = statuses.map(renderJobRows).join("");
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<table class="table">
|
<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) {
|
function renderJobRow(s) {
|
||||||
const p = s.progress || {};
|
const p = s.progress || {};
|
||||||
const id = s.jobid;
|
const id = s.jobid;
|
||||||
@@ -259,9 +295,11 @@ function renderJobRow(s) {
|
|||||||
</div>
|
</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
|
const stopBtn = !finished
|
||||||
? `<button class="btn btn-danger btn-sm" data-stop="${id}">${t("jobs.stop")}</button>`
|
? `<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 `
|
return `
|
||||||
<tr>
|
<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">${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.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
|
||||||
<td class="col-num col-mono">${(p.errors && p.errors.length) || 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>
|
</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) {
|
async function onStop(jobid) {
|
||||||
if (!confirm(t("jobs.stop_confirm", jobid))) return;
|
if (!confirm(t("jobs.stop_confirm", jobid))) return;
|
||||||
try {
|
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() {
|
function startPolling() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
@@ -322,3 +430,10 @@ function escapeHtml(s) {
|
|||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -771,6 +771,69 @@
|
|||||||
margin: 0 var(--space-xxs);
|
margin: 0 var(--space-xxs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.job-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-row {
|
||||||
|
background-color: var(--color-surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-xs) var(--space-lg);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail dl div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 110px minmax(0, 1fr);
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail dt {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font: var(--typo-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-body);
|
||||||
|
font: var(--typo-code);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-output span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
color: var(--color-muted);
|
||||||
|
font: var(--typo-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-detail-output pre {
|
||||||
|
max-height: 220px;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background-color: var(--color-canvas);
|
||||||
|
border: 1px solid var(--color-hairline-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-body);
|
||||||
|
font: var(--typo-code);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Responsive ---------- */
|
/* ---------- Responsive ---------- */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.connector-grid {
|
.connector-grid {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
<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/tokens.css">
|
||||||
<link rel="stylesheet" href="/assets/styles/base.css">
|
<link rel="stylesheet" href="/assets/styles/base.css">
|
||||||
<link rel="stylesheet" href="/assets/styles/components.css">
|
<link rel="stylesheet" href="/assets/styles/components.css?v=jobs-details-1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
@@ -80,6 +80,6 @@
|
|||||||
<div id="toast-stack" class="toast-stack"></div>
|
<div id="toast-stack" class="toast-stack"></div>
|
||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
|
|
||||||
<script type="module" src="/assets/js/app.js"></script>
|
<script type="module" src="/assets/js/app.js?v=jobs-details-1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user