add: 显示单次任务记录
This commit is contained in:
@@ -32,8 +32,9 @@
|
||||
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
|
||||
rename / download。
|
||||
- **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、
|
||||
ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC;
|
||||
固定循环任务保存到 SQLite,并由 jobs-api sidecar 调度。
|
||||
ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC,并在
|
||||
当前浏览器显示;固定循环任务保存到 SQLite,并由 jobs-api sidecar
|
||||
调度。
|
||||
|
||||
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
|
||||
> 引导用户在终端跑 `rclone config` 完成授权。
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
// the top-nav active state in sync, and re-renders chrome strings when
|
||||
// the locale changes.
|
||||
|
||||
import { onRoute } from "./state.js?v=monthly-last-day-1";
|
||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=monthly-last-day-1";
|
||||
import { renderRemotes } from "./views/remotes.js?v=monthly-last-day-1";
|
||||
import { renderBrowse } from "./views/browser.js?v=monthly-last-day-1";
|
||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=monthly-last-day-1";
|
||||
import { onRoute } from "./state.js?v=one-time-visible-1";
|
||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=one-time-visible-1";
|
||||
import { renderRemotes } from "./views/remotes.js?v=one-time-visible-1";
|
||||
import { renderBrowse } from "./views/browser.js?v=one-time-visible-1";
|
||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=one-time-visible-1";
|
||||
import {
|
||||
renderConfigureNew,
|
||||
renderConfigureEdit,
|
||||
} from "./views/configure.js?v=monthly-last-day-1";
|
||||
} from "./views/configure.js?v=one-time-visible-1";
|
||||
|
||||
const views = {
|
||||
remotes: renderRemotes,
|
||||
|
||||
@@ -167,7 +167,7 @@ const STRINGS = {
|
||||
"jobs.schedule_type": "Job type",
|
||||
"jobs.schedule_once": "One-time",
|
||||
"jobs.schedule_recurring": "Fixed recurring",
|
||||
"jobs.schedule_help": "One-time jobs are submitted to rclone and not saved. Fixed recurring jobs are stored in SQLite.",
|
||||
"jobs.schedule_help": "One-time jobs are visible in this browser and not saved to SQLite. Fixed recurring jobs are stored in SQLite.",
|
||||
"jobs.recurrence_kind": "Recurring plan",
|
||||
"jobs.recurrence_daily": "Every day",
|
||||
"jobs.recurrence_weekly": "Every week",
|
||||
@@ -206,7 +206,7 @@ const STRINGS = {
|
||||
"jobs.empty_title": "No jobs yet",
|
||||
"jobs.empty_body": "Use New Job to start a copy, sync, or move.",
|
||||
"jobs.started": (a, id) => `Started ${a} job #${id}`,
|
||||
"jobs.started_once": (a, id) => `Started one-time ${a} job #${id}. It was not saved.`,
|
||||
"jobs.started_once": (a, id) => `Started one-time ${a} job #${id}`,
|
||||
"jobs.recurring_saved": (id) => `Saved recurring job #${id}`,
|
||||
"jobs.start_failed": "Job start failed",
|
||||
"jobs.stop_confirm": (id) => `Stop job #${id}?`,
|
||||
@@ -214,6 +214,7 @@ const STRINGS = {
|
||||
"jobs.stop_failed": "Stop failed",
|
||||
"jobs.start_again": "Start again",
|
||||
"jobs.restarted": (id) => `Started recurring job #${id}`,
|
||||
"jobs.restarted_once": (id) => `Started one-time job #${id}`,
|
||||
"jobs.restart_failed": "Start again failed",
|
||||
"jobs.details": "Details",
|
||||
"jobs.hide_details": "Hide",
|
||||
@@ -221,6 +222,7 @@ const STRINGS = {
|
||||
"jobs.delete_confirm": (id) => `Delete job #${id} from this list? This won't cancel a running transfer.`,
|
||||
"jobs.delete_failed": "Delete failed",
|
||||
"jobs.detail_schedule_id": "Schedule ID",
|
||||
"jobs.detail_record_id": "Record",
|
||||
"jobs.detail_id": "Job ID",
|
||||
"jobs.detail_status": "Status",
|
||||
"jobs.detail_error": "Error",
|
||||
@@ -250,6 +252,8 @@ const STRINGS = {
|
||||
"jobs.status.scheduled": "scheduled",
|
||||
"jobs.status.stopped": "stopped",
|
||||
"jobs.stop": "Stop",
|
||||
"jobs.once_id": (id) => `once:${id}`,
|
||||
"jobs.schedule_label_once": "Visible in this browser",
|
||||
"jobs.submitted_cli": "— submitted via CLI —",
|
||||
},
|
||||
|
||||
@@ -383,7 +387,7 @@ const STRINGS = {
|
||||
"jobs.schedule_type": "任务类型",
|
||||
"jobs.schedule_once": "单次任务",
|
||||
"jobs.schedule_recurring": "固定循环",
|
||||
"jobs.schedule_help": "单次任务只提交给 rclone,不保存记录。固定循环任务会保存到 SQLite。",
|
||||
"jobs.schedule_help": "单次任务会显示在当前浏览器,但不会保存到 SQLite。固定循环任务会保存到 SQLite。",
|
||||
"jobs.recurrence_kind": "循环计划",
|
||||
"jobs.recurrence_daily": "每天",
|
||||
"jobs.recurrence_weekly": "每周",
|
||||
@@ -422,7 +426,7 @@ const STRINGS = {
|
||||
"jobs.empty_title": "尚无任务",
|
||||
"jobs.empty_body": "用「新建任务」开始一个 copy、sync 或 move。",
|
||||
"jobs.started": (a, id) => `已启动 ${a} 任务 #${id}`,
|
||||
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id},不会保存记录。`,
|
||||
"jobs.started_once": (a, id) => `已启动单次 ${a} 任务 #${id}`,
|
||||
"jobs.recurring_saved": (id) => `已保存循环任务 #${id}`,
|
||||
"jobs.start_failed": "任务启动失败",
|
||||
"jobs.stop_confirm": (id) => `停止任务 #${id}?`,
|
||||
@@ -430,6 +434,7 @@ const STRINGS = {
|
||||
"jobs.stop_failed": "停止失败",
|
||||
"jobs.start_again": "再次启动",
|
||||
"jobs.restarted": (id) => `已启动循环任务 #${id}`,
|
||||
"jobs.restarted_once": (id) => `已启动单次任务 #${id}`,
|
||||
"jobs.restart_failed": "再次启动失败",
|
||||
"jobs.details": "详情",
|
||||
"jobs.hide_details": "收起",
|
||||
@@ -437,6 +442,7 @@ const STRINGS = {
|
||||
"jobs.delete_confirm": (id) => `从列表中删除任务 #${id}? 这不会取消正在运行的传输。`,
|
||||
"jobs.delete_failed": "删除失败",
|
||||
"jobs.detail_schedule_id": "调度 ID",
|
||||
"jobs.detail_record_id": "记录",
|
||||
"jobs.detail_id": "任务 ID",
|
||||
"jobs.detail_status": "状态",
|
||||
"jobs.detail_error": "错误",
|
||||
@@ -466,6 +472,8 @@ const STRINGS = {
|
||||
"jobs.status.scheduled": "已调度",
|
||||
"jobs.status.stopped": "已停止",
|
||||
"jobs.stop": "停止",
|
||||
"jobs.once_id": (id) => `单次:${id}`,
|
||||
"jobs.schedule_label_once": "仅当前浏览器可见",
|
||||
"jobs.submitted_cli": "— 通过命令行提交 —",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export function isNoAuth() {
|
||||
}
|
||||
|
||||
// POST JSON to an RC endpoint. Returns the parsed JSON response, or throws.
|
||||
export async function post(path, body = {}) {
|
||||
export async function post(path, body = {}, options = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (authHeader) headers["Authorization"] = authHeader;
|
||||
const res = await fetch(RC_BASE + "/" + path.replace(/^\//, ""), {
|
||||
@@ -39,7 +39,7 @@ export async function post(path, body = {}) {
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return parseResponse(res, path);
|
||||
return parseResponse(res, path, options);
|
||||
}
|
||||
|
||||
// POST JSON and request an async job. Returns { jobid, executeId }.
|
||||
@@ -82,7 +82,7 @@ export function downloadURL(remoteFs, remotePath, fileName) {
|
||||
return url;
|
||||
}
|
||||
|
||||
async function parseResponse(res, path) {
|
||||
async function parseResponse(res, path, { allowError = false } = {}) {
|
||||
let body = null;
|
||||
const ct = res.headers.get("Content-Type") || "";
|
||||
if (ct.includes("application/json")) {
|
||||
@@ -98,7 +98,7 @@ async function parseResponse(res, path) {
|
||||
err.body = body;
|
||||
throw err;
|
||||
}
|
||||
if (body && body.error) {
|
||||
if (body && body.error && !allowError) {
|
||||
throw new Error(`${path}: ${body.error}`);
|
||||
}
|
||||
return body;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
|
||||
|
||||
import { post, uploadFile, downloadURL } from "../rc.js";
|
||||
import { toast, formatBytes, formatTime } from "../state.js?v=monthly-last-day-1";
|
||||
import { t } from "../i18n.js?v=monthly-last-day-1";
|
||||
import { post, uploadFile, downloadURL } from "../rc.js?v=one-time-visible-1";
|
||||
import { toast, formatBytes, formatTime } from "../state.js?v=one-time-visible-1";
|
||||
import { t } from "../i18n.js?v=one-time-visible-1";
|
||||
|
||||
export async function renderBrowse({ remote, path }) {
|
||||
const app = document.getElementById("app");
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
// OAuth backends (option named "token" with IsPassword) get a banner
|
||||
// and disabled submit — user must run `rclone config` in a terminal.
|
||||
|
||||
import { post } from "../rc.js";
|
||||
import { getState, setState, toast } from "../state.js?v=monthly-last-day-1";
|
||||
import { t } from "../i18n.js?v=monthly-last-day-1";
|
||||
import { post } from "../rc.js?v=one-time-visible-1";
|
||||
import { getState, setState, toast } from "../state.js?v=one-time-visible-1";
|
||||
import { t } from "../i18n.js?v=one-time-visible-1";
|
||||
|
||||
// --- Route entrypoints ---
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
||||
|
||||
import { post, postAsync } from "../rc.js";
|
||||
import { post, postAsync } from "../rc.js?v=one-time-visible-1";
|
||||
import {
|
||||
toast,
|
||||
formatBytes,
|
||||
formatSpeed,
|
||||
formatDuration,
|
||||
} from "../state.js?v=monthly-last-day-1";
|
||||
import { t } from "../i18n.js?v=monthly-last-day-1";
|
||||
} from "../state.js?v=one-time-visible-1";
|
||||
import { t } from "../i18n.js?v=one-time-visible-1";
|
||||
import {
|
||||
createRecurringJob,
|
||||
deleteRecurringJob,
|
||||
@@ -15,13 +15,14 @@ import {
|
||||
listRecurringJobs,
|
||||
runRecurringJobNow,
|
||||
stopRecurringJob,
|
||||
} from "../jobs_api.js?v=monthly-last-day-1";
|
||||
} from "../jobs_api.js?v=one-time-visible-1";
|
||||
|
||||
let pollTimer = null;
|
||||
const WEBGUI_JOB_GROUP_PREFIX = "webgui/transfer";
|
||||
const LOCAL_FS_VALUE = "__local__";
|
||||
const expandedJobs = new Set();
|
||||
const LOCAL_PICKER_ROOT = "/root";
|
||||
const ONE_TIME_JOBS_KEY = "rclone.webgui.oneTimeJobs.v1";
|
||||
|
||||
export async function renderJobs() {
|
||||
const app = document.getElementById("app");
|
||||
@@ -253,6 +254,23 @@ export async function renderNewJob() {
|
||||
const body = buildTransferBody(action, src, dst);
|
||||
const res = await postAsync(`sync/${action}`, body);
|
||||
const jobid = res && res.jobid;
|
||||
if (jobid == null) {
|
||||
throw new Error("missing jobid");
|
||||
}
|
||||
saveOneTimeJob({
|
||||
id: `once:${jobid}`,
|
||||
kind: "once",
|
||||
action,
|
||||
src,
|
||||
dst,
|
||||
currentJobid: jobid,
|
||||
lastJobid: jobid,
|
||||
status: "running",
|
||||
running: true,
|
||||
statusSnapshot: { finished: false, jobid },
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
toast(t("jobs.started_once", action, jobid), "success");
|
||||
location.hash = "#/jobs";
|
||||
} catch (e) {
|
||||
@@ -457,20 +475,25 @@ async function refreshJobs() {
|
||||
const card = document.getElementById("jobs-card");
|
||||
if (!card) return false;
|
||||
|
||||
let jobs = [];
|
||||
let recurringJobs = [];
|
||||
const oneTimeJobs = await refreshOneTimeJobs();
|
||||
try {
|
||||
jobs = await listRecurringJobs();
|
||||
recurringJobs = await listRecurringJobs();
|
||||
} 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 (oneTimeJobs.length === 0) {
|
||||
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;
|
||||
}
|
||||
toast(`${t("error.couldnt_load_jobs")}: ${e.message}`, "error");
|
||||
}
|
||||
|
||||
const jobs = [...oneTimeJobs, ...recurringJobs.map(normalizeRecurringJob)];
|
||||
if (jobs.length === 0) {
|
||||
card.innerHTML = `
|
||||
<div class="empty">
|
||||
@@ -483,22 +506,26 @@ async function refreshJobs() {
|
||||
|
||||
card.innerHTML = renderJobTable(jobs);
|
||||
card.querySelectorAll("[data-detail]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onToggleDetails(parseInt(btn.dataset.detail, 10)));
|
||||
btn.addEventListener("click", () => onToggleDetails(btn.dataset.detail));
|
||||
});
|
||||
card.querySelectorAll("[data-start]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onStartAgain(parseInt(btn.dataset.start, 10)));
|
||||
btn.addEventListener("click", () => onStartAgain(btn.dataset.start));
|
||||
});
|
||||
card.querySelectorAll("[data-stop]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onStop(parseInt(btn.dataset.stop, 10)));
|
||||
btn.addEventListener("click", () => onStop(btn.dataset.stop));
|
||||
});
|
||||
card.querySelectorAll("[data-delete]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onDelete(parseInt(btn.dataset.delete, 10)));
|
||||
btn.addEventListener("click", () => onDelete(btn.dataset.delete));
|
||||
});
|
||||
return jobs.length > 0;
|
||||
return jobs.some((job) => job.running);
|
||||
}
|
||||
|
||||
function renderJobTable(statuses) {
|
||||
statuses.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
|
||||
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("");
|
||||
|
||||
@@ -527,7 +554,7 @@ function renderJobTable(statuses) {
|
||||
|
||||
function renderJobRows(s) {
|
||||
const row = renderJobRow(s);
|
||||
if (!expandedJobs.has(Number(s.id))) {
|
||||
if (!expandedJobs.has(jobKey(s))) {
|
||||
return row;
|
||||
}
|
||||
return row + renderJobDetailsRow(s);
|
||||
@@ -537,6 +564,7 @@ function renderJobRow(job) {
|
||||
const snapshot = job.statusSnapshot || {};
|
||||
const p = snapshot.progress || {};
|
||||
const id = job.id;
|
||||
const key = jobKey(job);
|
||||
const running = !!job.running;
|
||||
const jobid = job.currentJobid || job.lastJobid;
|
||||
|
||||
@@ -553,7 +581,7 @@ function renderJobRow(job) {
|
||||
|
||||
const scheduleCell = `
|
||||
<div class="job-cell">
|
||||
<span class="job-action">${escapeHtml(formatDateTime(job.nextRunAt))}</span>
|
||||
<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>
|
||||
`;
|
||||
@@ -568,19 +596,19 @@ function renderJobRow(job) {
|
||||
</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 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="${id}">${t("jobs.start_again")}</button>`;
|
||||
: `<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="${id}">${t("jobs.stop")}</button>`
|
||||
? `<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="${id}">${t("jobs.delete_record")}</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">
|
||||
${id}
|
||||
${escapeHtml(displayJobId(job))}
|
||||
${jobid ? `<br><span style="color:var(--color-muted-soft)">job ${escapeHtml(jobid)}</span>` : ""}
|
||||
</td>
|
||||
<td>${jobCell}</td>
|
||||
@@ -621,11 +649,10 @@ function renderStatusBadge(status) {
|
||||
}
|
||||
|
||||
function renderJobDetailsRow(job) {
|
||||
const id = job.id;
|
||||
const snapshot = job.statusSnapshot || {};
|
||||
const jobid = job.currentJobid || job.lastJobid || "";
|
||||
const details = [
|
||||
[t("jobs.detail_schedule_id"), id],
|
||||
[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")],
|
||||
@@ -673,10 +700,25 @@ function detailStatus(job) {
|
||||
}
|
||||
|
||||
async function onStop(jobid) {
|
||||
if (!confirm(t("jobs.stop_confirm", jobid))) return;
|
||||
const key = String(jobid);
|
||||
const oneTimeJob = findOneTimeJob(key);
|
||||
const label = displayKey(key);
|
||||
if (!confirm(t("jobs.stop_confirm", label))) return;
|
||||
try {
|
||||
await stopRecurringJob(jobid);
|
||||
toast(t("jobs.stopped", jobid), "success");
|
||||
if (oneTimeJob) {
|
||||
await post("job/stop", { jobid: oneTimeJob.currentJobid || oneTimeJob.lastJobid });
|
||||
saveOneTimeJob({
|
||||
...oneTimeJob,
|
||||
running: false,
|
||||
status: "stopped",
|
||||
currentJobid: null,
|
||||
finishedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
await stopRecurringJob(recurringIdFromKey(key));
|
||||
}
|
||||
toast(t("jobs.stopped", label), "success");
|
||||
await refreshJobs();
|
||||
} catch (e) {
|
||||
toast(`${t("jobs.stop_failed")}: ${e.message}`, "error");
|
||||
@@ -684,18 +726,46 @@ async function onStop(jobid) {
|
||||
}
|
||||
|
||||
async function onToggleDetails(jobid) {
|
||||
if (expandedJobs.has(jobid)) {
|
||||
expandedJobs.delete(jobid);
|
||||
const key = String(jobid);
|
||||
if (expandedJobs.has(key)) {
|
||||
expandedJobs.delete(key);
|
||||
} else {
|
||||
expandedJobs.add(jobid);
|
||||
expandedJobs.add(key);
|
||||
}
|
||||
await refreshJobs();
|
||||
}
|
||||
|
||||
async function onStartAgain(jobid) {
|
||||
const key = String(jobid);
|
||||
const oneTimeJob = findOneTimeJob(key);
|
||||
try {
|
||||
await runRecurringJobNow(jobid);
|
||||
toast(t("jobs.restarted", jobid), "success");
|
||||
if (oneTimeJob) {
|
||||
const body = buildTransferBody(oneTimeJob.action, oneTimeJob.src, oneTimeJob.dst);
|
||||
const res = await postAsync(`sync/${oneTimeJob.action}`, body);
|
||||
const newJobid = res && res.jobid;
|
||||
if (newJobid == null) {
|
||||
throw new Error("missing jobid");
|
||||
}
|
||||
saveOneTimeJob({
|
||||
...oneTimeJob,
|
||||
id: `once:${newJobid}`,
|
||||
currentJobid: newJobid,
|
||||
lastJobid: newJobid,
|
||||
status: "running",
|
||||
running: true,
|
||||
statusSnapshot: { finished: false, jobid: newJobid },
|
||||
error: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
removeOneTimeJob(key);
|
||||
toast(t("jobs.restarted_once", newJobid), "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");
|
||||
@@ -703,10 +773,16 @@ async function onStartAgain(jobid) {
|
||||
}
|
||||
|
||||
async function onDelete(jobid) {
|
||||
if (!confirm(t("jobs.delete_confirm", jobid))) return;
|
||||
const key = String(jobid);
|
||||
const label = displayKey(key);
|
||||
if (!confirm(t("jobs.delete_confirm", label))) return;
|
||||
try {
|
||||
await deleteRecurringJob(jobid);
|
||||
expandedJobs.delete(jobid);
|
||||
if (findOneTimeJob(key)) {
|
||||
removeOneTimeJob(key);
|
||||
} else {
|
||||
await deleteRecurringJob(recurringIdFromKey(key));
|
||||
}
|
||||
expandedJobs.delete(key);
|
||||
await refreshJobs();
|
||||
} catch (e) {
|
||||
toast(`${t("jobs.delete_failed")}: ${e.message}`, "error");
|
||||
@@ -738,6 +814,116 @@ export function stopJobPolling() {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
async function refreshOneTimeJobs() {
|
||||
const jobs = loadOneTimeJobs();
|
||||
let changed = false;
|
||||
for (const job of jobs) {
|
||||
const jobid = job.currentJobid || job.lastJobid;
|
||||
if (!jobid || !job.running) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const status = await post("job/status", { jobid }, { allowError: true });
|
||||
Object.assign(job, oneTimeStatusToJob(job, status));
|
||||
changed = true;
|
||||
} catch (e) {
|
||||
Object.assign(job, {
|
||||
status: "failed",
|
||||
running: false,
|
||||
currentJobid: null,
|
||||
error: e.message,
|
||||
statusSnapshot: { finished: true, error: e.message, jobid },
|
||||
finishedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
saveOneTimeJobs(jobs);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
function oneTimeStatusToJob(job, status) {
|
||||
const name = statusName(status);
|
||||
return {
|
||||
status: name,
|
||||
running: name === "running",
|
||||
currentJobid: name === "running" ? job.currentJobid : null,
|
||||
error: status.error || null,
|
||||
statusSnapshot: status,
|
||||
finishedAt: name === "running" ? null : new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function statusName(status) {
|
||||
if (!status.finished) return "running";
|
||||
if (status.error) return "failed";
|
||||
if (status.success) return "done";
|
||||
return "finished";
|
||||
}
|
||||
|
||||
function normalizeRecurringJob(job) {
|
||||
return {
|
||||
...job,
|
||||
kind: "recurring",
|
||||
id: `recurring:${job.id}`,
|
||||
scheduleId: job.id,
|
||||
};
|
||||
}
|
||||
|
||||
function jobKey(job) {
|
||||
return String(job.id);
|
||||
}
|
||||
|
||||
function displayJobId(job) {
|
||||
if (job.kind === "once") {
|
||||
return t("jobs.once_id", job.lastJobid || job.currentJobid || "");
|
||||
}
|
||||
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 loadOneTimeJobs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(ONE_TIME_JOBS_KEY);
|
||||
const jobs = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(jobs) ? jobs : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveOneTimeJobs(jobs) {
|
||||
localStorage.setItem(ONE_TIME_JOBS_KEY, JSON.stringify(jobs.slice(0, 50)));
|
||||
}
|
||||
|
||||
function saveOneTimeJob(job) {
|
||||
const jobs = loadOneTimeJobs().filter((item) => item.id !== job.id);
|
||||
jobs.unshift(job);
|
||||
saveOneTimeJobs(jobs);
|
||||
}
|
||||
|
||||
function findOneTimeJob(key) {
|
||||
return loadOneTimeJobs().find((job) => job.id === key) || null;
|
||||
}
|
||||
|
||||
function removeOneTimeJob(key) {
|
||||
saveOneTimeJobs(loadOneTimeJobs().filter((job) => job.id !== key));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
@@ -759,6 +945,9 @@ function formatDateTime(value) {
|
||||
}
|
||||
|
||||
function formatSchedule(job) {
|
||||
if (job.kind === "once") {
|
||||
return t("jobs.schedule_label_once");
|
||||
}
|
||||
const time = job.scheduleTime || "";
|
||||
switch (job.scheduleKind) {
|
||||
case "daily":
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
||||
|
||||
import { post } from "../rc.js";
|
||||
import { getState, setState, toast } from "../state.js?v=monthly-last-day-1";
|
||||
import { t } from "../i18n.js?v=monthly-last-day-1";
|
||||
import { post } from "../rc.js?v=one-time-visible-1";
|
||||
import { getState, setState, toast } from "../state.js?v=one-time-visible-1";
|
||||
import { t } from "../i18n.js?v=one-time-visible-1";
|
||||
|
||||
export async function renderRemotes() {
|
||||
const app = document.getElementById("app");
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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/base.css">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css?v=monthly-last-day-1">
|
||||
<link rel="stylesheet" href="/assets/styles/components.css?v=one-time-visible-1">
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-nav">
|
||||
@@ -80,6 +80,6 @@
|
||||
<div id="toast-stack" class="toast-stack"></div>
|
||||
<div id="modal-root"></div>
|
||||
|
||||
<script type="module" src="/assets/js/app.js?v=monthly-last-day-1"></script>
|
||||
<script type="module" src="/assets/js/app.js?v=one-time-visible-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user