fix: 显示任务传输状态
This commit is contained in:
+21
-4
@@ -605,7 +605,7 @@ def start_schedule(row: sqlite3.Row, trigger: str) -> None:
|
|||||||
"srcFs": row["src"],
|
"srcFs": row["src"],
|
||||||
"dstFs": row["dst"],
|
"dstFs": row["dst"],
|
||||||
"_async": True,
|
"_async": True,
|
||||||
"_group": f"webgui/recurring/{schedule_id}/{action}",
|
"_group": transfer_group("recurring", schedule_id, action),
|
||||||
}
|
}
|
||||||
if action == "move":
|
if action == "move":
|
||||||
payload["deleteEmptySrcDirs"] = True
|
payload["deleteEmptySrcDirs"] = True
|
||||||
@@ -686,7 +686,7 @@ def start_one_time_job(job_id: int) -> None:
|
|||||||
"srcFs": row["src"],
|
"srcFs": row["src"],
|
||||||
"dstFs": row["dst"],
|
"dstFs": row["dst"],
|
||||||
"_async": True,
|
"_async": True,
|
||||||
"_group": f"webgui/once/{job_id}/{action}",
|
"_group": transfer_group("once", job_id, action),
|
||||||
}
|
}
|
||||||
if action == "move":
|
if action == "move":
|
||||||
payload["deleteEmptySrcDirs"] = True
|
payload["deleteEmptySrcDirs"] = True
|
||||||
@@ -744,6 +744,23 @@ def status_name(status: dict[str, Any]) -> str:
|
|||||||
return "finished"
|
return "finished"
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_group(kind: str, record_id: int, action: str) -> str:
|
||||||
|
return f"webgui/{kind}/{record_id}/{action}"
|
||||||
|
|
||||||
|
|
||||||
|
def status_with_stats(kind: str, row: sqlite3.Row, jobid: int) -> dict[str, Any]:
|
||||||
|
status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True)
|
||||||
|
group = transfer_group(kind, int(row["id"]), row["action"])
|
||||||
|
try:
|
||||||
|
stats = rc_post("core/stats", {"group": group}, timeout=15, allow_error_body=True)
|
||||||
|
except Exception as exc:
|
||||||
|
stats = {"error": str(exc)}
|
||||||
|
status["stats"] = stats
|
||||||
|
if "group" not in status:
|
||||||
|
status["group"] = group
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
def refresh_running_jobs() -> None:
|
def refresh_running_jobs() -> None:
|
||||||
with db_lock, connect() as conn:
|
with db_lock, connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall()
|
rows = conn.execute("SELECT * FROM job_schedules WHERE current_jobid IS NOT NULL").fetchall()
|
||||||
@@ -752,7 +769,7 @@ def refresh_running_jobs() -> None:
|
|||||||
schedule_id = int(row["id"])
|
schedule_id = int(row["id"])
|
||||||
jobid = int(row["current_jobid"])
|
jobid = int(row["current_jobid"])
|
||||||
try:
|
try:
|
||||||
status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True)
|
status = status_with_stats("recurring", row, jobid)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = {"finished": True, "error": str(exc), "jobid": jobid}
|
status = {"finished": True, "error": str(exc), "jobid": jobid}
|
||||||
apply_status(row, status)
|
apply_status(row, status)
|
||||||
@@ -765,7 +782,7 @@ def refresh_one_time_jobs() -> None:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
jobid = int(row["jobid"])
|
jobid = int(row["jobid"])
|
||||||
try:
|
try:
|
||||||
status = rc_post("job/status", {"jobid": jobid}, timeout=15, allow_error_body=True)
|
status = status_with_stats("once", row, jobid)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
status = {"finished": True, "error": str(exc), "jobid": jobid}
|
status = {"finished": True, "error": str(exc), "jobid": jobid}
|
||||||
apply_one_time_status(row, status)
|
apply_one_time_status(row, status)
|
||||||
|
|||||||
@@ -128,7 +128,18 @@ class OneTimeJobTest(unittest.TestCase):
|
|||||||
self.next_jobid += 1
|
self.next_jobid += 1
|
||||||
return {"jobid": self.next_jobid}
|
return {"jobid": self.next_jobid}
|
||||||
if path == "job/status":
|
if path == "job/status":
|
||||||
return {"finished": False, "jobid": payload["jobid"], "progress": {"bytes": 1, "totalBytes": 2}}
|
return {"finished": False, "jobid": payload["jobid"]}
|
||||||
|
if path == "core/stats":
|
||||||
|
return {
|
||||||
|
"bytes": 1,
|
||||||
|
"totalBytes": 2,
|
||||||
|
"speed": 3,
|
||||||
|
"eta": 4,
|
||||||
|
"transfers": 5,
|
||||||
|
"totalTransfers": 6,
|
||||||
|
"errors": 7,
|
||||||
|
"group": payload["group"],
|
||||||
|
}
|
||||||
if path == "job/stop":
|
if path == "job/stop":
|
||||||
return {}
|
return {}
|
||||||
raise AssertionError(path)
|
raise AssertionError(path)
|
||||||
@@ -153,6 +164,9 @@ class OneTimeJobTest(unittest.TestCase):
|
|||||||
self.assertEqual(len(jobs), 1)
|
self.assertEqual(len(jobs), 1)
|
||||||
self.assertEqual(jobs[0]["id"], 1)
|
self.assertEqual(jobs[0]["id"], 1)
|
||||||
self.assertEqual(jobs[0]["status"], "running")
|
self.assertEqual(jobs[0]["status"], "running")
|
||||||
|
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["bytes"], 1)
|
||||||
|
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["totalBytes"], 2)
|
||||||
|
self.assertEqual(jobs[0]["statusSnapshot"]["stats"]["speed"], 3)
|
||||||
|
|
||||||
def test_stop_one_time_job_persists_stopped_status(self):
|
def test_stop_one_time_job_persists_stopped_status(self):
|
||||||
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
|
job = server.create_one_time_job({"action": "copy", "src": "/tmp/a", "dst": "/tmp/b"})
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
// the top-nav active state in sync, and re-renders chrome strings when
|
// the top-nav active state in sync, and re-renders chrome strings when
|
||||||
// the locale changes.
|
// the locale changes.
|
||||||
|
|
||||||
import { onRoute } from "./state.js?v=persist-all-jobs-1";
|
import { onRoute } from "./state.js?v=job-stats-1";
|
||||||
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=persist-all-jobs-1";
|
import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-stats-1";
|
||||||
import { renderRemotes } from "./views/remotes.js?v=persist-all-jobs-1";
|
import { renderRemotes } from "./views/remotes.js?v=job-stats-1";
|
||||||
import { renderBrowse } from "./views/browser.js?v=persist-all-jobs-1";
|
import { renderBrowse } from "./views/browser.js?v=job-stats-1";
|
||||||
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=persist-all-jobs-1";
|
import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-stats-1";
|
||||||
import {
|
import {
|
||||||
renderConfigureNew,
|
renderConfigureNew,
|
||||||
renderConfigureEdit,
|
renderConfigureEdit,
|
||||||
} from "./views/configure.js?v=persist-all-jobs-1";
|
} from "./views/configure.js?v=job-stats-1";
|
||||||
|
|
||||||
const views = {
|
const views = {
|
||||||
remotes: renderRemotes,
|
remotes: renderRemotes,
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ const STRINGS = {
|
|||||||
"jobs.detail_schedule": "Schedule",
|
"jobs.detail_schedule": "Schedule",
|
||||||
"jobs.detail_group": "Group",
|
"jobs.detail_group": "Group",
|
||||||
"jobs.detail_execute_id": "Execution",
|
"jobs.detail_execute_id": "Execution",
|
||||||
|
"jobs.detail_bytes": "Bytes",
|
||||||
|
"jobs.detail_speed": "Speed",
|
||||||
|
"jobs.detail_eta": "ETA",
|
||||||
|
"jobs.detail_files": "Files",
|
||||||
|
"jobs.detail_errors": "Errors",
|
||||||
"jobs.detail_output": "Output",
|
"jobs.detail_output": "Output",
|
||||||
"jobs.detail_none": "None",
|
"jobs.detail_none": "None",
|
||||||
"jobs.col.id": "#",
|
"jobs.col.id": "#",
|
||||||
@@ -463,6 +468,11 @@ const STRINGS = {
|
|||||||
"jobs.detail_schedule": "固定计划",
|
"jobs.detail_schedule": "固定计划",
|
||||||
"jobs.detail_group": "分组",
|
"jobs.detail_group": "分组",
|
||||||
"jobs.detail_execute_id": "执行实例",
|
"jobs.detail_execute_id": "执行实例",
|
||||||
|
"jobs.detail_bytes": "字节",
|
||||||
|
"jobs.detail_speed": "速度",
|
||||||
|
"jobs.detail_eta": "预计剩余",
|
||||||
|
"jobs.detail_files": "文件",
|
||||||
|
"jobs.detail_errors": "错误",
|
||||||
"jobs.detail_output": "输出",
|
"jobs.detail_output": "输出",
|
||||||
"jobs.detail_none": "无",
|
"jobs.detail_none": "无",
|
||||||
"jobs.col.id": "#",
|
"jobs.col.id": "#",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
|
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
|
||||||
|
|
||||||
import { post, uploadFile, downloadURL } from "../rc.js?v=persist-all-jobs-1";
|
import { post, uploadFile, downloadURL } from "../rc.js?v=job-stats-1";
|
||||||
import { toast, formatBytes, formatTime } from "../state.js?v=persist-all-jobs-1";
|
import { toast, formatBytes, formatTime } from "../state.js?v=job-stats-1";
|
||||||
import { t } from "../i18n.js?v=persist-all-jobs-1";
|
import { t } from "../i18n.js?v=job-stats-1";
|
||||||
|
|
||||||
export async function renderBrowse({ remote, path }) {
|
export async function renderBrowse({ remote, path }) {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
// OAuth backends (option named "token" with IsPassword) get a banner
|
// OAuth backends (option named "token" with IsPassword) get a banner
|
||||||
// and disabled submit — user must run `rclone config` in a terminal.
|
// and disabled submit — user must run `rclone config` in a terminal.
|
||||||
|
|
||||||
import { post } from "../rc.js?v=persist-all-jobs-1";
|
import { post } from "../rc.js?v=job-stats-1";
|
||||||
import { getState, setState, toast } from "../state.js?v=persist-all-jobs-1";
|
import { getState, setState, toast } from "../state.js?v=job-stats-1";
|
||||||
import { t } from "../i18n.js?v=persist-all-jobs-1";
|
import { t } from "../i18n.js?v=job-stats-1";
|
||||||
|
|
||||||
// --- Route entrypoints ---
|
// --- Route entrypoints ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
|
||||||
|
|
||||||
import { post } from "../rc.js?v=persist-all-jobs-1";
|
import { post } from "../rc.js?v=job-stats-1";
|
||||||
import {
|
import {
|
||||||
toast,
|
toast,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatSpeed,
|
formatSpeed,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
} from "../state.js?v=persist-all-jobs-1";
|
} from "../state.js?v=job-stats-1";
|
||||||
import { t } from "../i18n.js?v=persist-all-jobs-1";
|
import { t } from "../i18n.js?v=job-stats-1";
|
||||||
import {
|
import {
|
||||||
createOneTimeJob,
|
createOneTimeJob,
|
||||||
createRecurringJob,
|
createRecurringJob,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
runRecurringJobNow,
|
runRecurringJobNow,
|
||||||
stopOneTimeJob,
|
stopOneTimeJob,
|
||||||
stopRecurringJob,
|
stopRecurringJob,
|
||||||
} from "../jobs_api.js?v=persist-all-jobs-1";
|
} from "../jobs_api.js?v=job-stats-1";
|
||||||
|
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
const LOCAL_FS_VALUE = "__local__";
|
const LOCAL_FS_VALUE = "__local__";
|
||||||
@@ -566,11 +566,13 @@ function renderJobRows(s) {
|
|||||||
|
|
||||||
function renderJobRow(job) {
|
function renderJobRow(job) {
|
||||||
const snapshot = job.statusSnapshot || {};
|
const snapshot = job.statusSnapshot || {};
|
||||||
const p = snapshot.progress || {};
|
const stats = transferStats(snapshot);
|
||||||
const id = job.id;
|
const id = job.id;
|
||||||
const key = jobKey(job);
|
const key = jobKey(job);
|
||||||
const running = !!job.running;
|
const running = !!job.running;
|
||||||
const jobid = job.currentJobid || job.lastJobid;
|
const jobid = job.currentJobid || job.lastJobid;
|
||||||
|
const displayedSpeed = stats.speed || averageSpeed(stats);
|
||||||
|
const displayedEta = running ? stats.eta : 0;
|
||||||
|
|
||||||
const badge = renderStatusBadge(job.status);
|
const badge = renderStatusBadge(job.status);
|
||||||
const jobCell = `
|
const jobCell = `
|
||||||
@@ -590,12 +592,12 @@ function renderJobRow(job) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const pct = p && p.totalBytes > 0 ? Math.min(100, (p.bytes / p.totalBytes) * 100) : 0;
|
const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0;
|
||||||
const progress = `
|
const progress = `
|
||||||
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px">
|
<div style="display:flex;flex-direction:column;gap:4px;min-width:120px">
|
||||||
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
|
<div class="progress"><span style="width:${pct.toFixed(1)}%"></span></div>
|
||||||
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
|
<span class="col-mono" style="font-size:11px;color:var(--color-muted)">
|
||||||
${formatBytes(p.bytes)} / ${formatBytes(p.totalBytes)}
|
${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -619,10 +621,10 @@ function renderJobRow(job) {
|
|||||||
<td>${badge}</td>
|
<td>${badge}</td>
|
||||||
<td>${scheduleCell}</td>
|
<td>${scheduleCell}</td>
|
||||||
<td class="col-num">${progress}</td>
|
<td class="col-num">${progress}</td>
|
||||||
<td class="col-num col-mono">${running ? escapeHtml(formatSpeed(p.speed || 0)) : "—"}</td>
|
<td class="col-num col-mono">${escapeHtml(formatSpeed(displayedSpeed))}</td>
|
||||||
<td class="col-num col-mono">${running ? escapeHtml(formatDuration(p.eta || 0)) : "—"}</td>
|
<td class="col-num col-mono">${escapeHtml(formatDuration(displayedEta))}</td>
|
||||||
<td class="col-num col-mono">${p.transfers ?? 0} / ${p.totalTransfers ?? 0}</td>
|
<td class="col-num col-mono">${escapeHtml(formatTransferCount(stats))}</td>
|
||||||
<td class="col-num col-mono">${(p.errors && p.errors.length) || 0}</td>
|
<td class="col-num col-mono">${escapeHtml(String(stats.errors || 0))}</td>
|
||||||
<td class="col-num">
|
<td class="col-num">
|
||||||
<div class="job-actions">
|
<div class="job-actions">
|
||||||
${detailBtn}
|
${detailBtn}
|
||||||
@@ -655,6 +657,7 @@ function renderStatusBadge(status) {
|
|||||||
|
|
||||||
function renderJobDetailsRow(job) {
|
function renderJobDetailsRow(job) {
|
||||||
const snapshot = job.statusSnapshot || {};
|
const snapshot = job.statusSnapshot || {};
|
||||||
|
const stats = transferStats(snapshot);
|
||||||
const jobid = job.currentJobid || job.lastJobid || "";
|
const jobid = job.currentJobid || job.lastJobid || "";
|
||||||
const details = [
|
const details = [
|
||||||
[job.kind === "once" ? t("jobs.detail_record_id") : t("jobs.detail_schedule_id"), displayJobId(job)],
|
[job.kind === "once" ? t("jobs.detail_record_id") : t("jobs.detail_schedule_id"), displayJobId(job)],
|
||||||
@@ -668,6 +671,11 @@ function renderJobDetailsRow(job) {
|
|||||||
[t("jobs.detail_schedule"), formatSchedule(job)],
|
[t("jobs.detail_schedule"), formatSchedule(job)],
|
||||||
[t("jobs.detail_group"), snapshot.group || ""],
|
[t("jobs.detail_group"), snapshot.group || ""],
|
||||||
[t("jobs.detail_execute_id"), snapshot.executeId || ""],
|
[t("jobs.detail_execute_id"), snapshot.executeId || ""],
|
||||||
|
[t("jobs.detail_bytes"), `${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}`],
|
||||||
|
[t("jobs.detail_speed"), formatSpeed(stats.speed || averageSpeed(stats))],
|
||||||
|
[t("jobs.detail_eta"), formatDuration(job.running ? stats.eta : 0)],
|
||||||
|
[t("jobs.detail_files"), formatTransferCount(stats)],
|
||||||
|
[t("jobs.detail_errors"), String(stats.errors || 0)],
|
||||||
];
|
];
|
||||||
const output = snapshot && Object.keys(snapshot).length > 0
|
const output = snapshot && Object.keys(snapshot).length > 0
|
||||||
? JSON.stringify(snapshot, null, 2)
|
? JSON.stringify(snapshot, null, 2)
|
||||||
@@ -698,6 +706,54 @@ function renderJobDetailsRow(job) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transferStats(snapshot) {
|
||||||
|
const stats = snapshot.stats || snapshot.progress || snapshot || {};
|
||||||
|
return {
|
||||||
|
bytes: numberOrZero(stats.bytes),
|
||||||
|
totalBytes: numberOrZero(stats.totalBytes),
|
||||||
|
speed: numberOrZero(stats.speed),
|
||||||
|
eta: numberOrNull(stats.eta),
|
||||||
|
transfers: numberOrZero(stats.transfers),
|
||||||
|
totalTransfers: numberOrZero(stats.totalTransfers),
|
||||||
|
checks: numberOrZero(stats.checks),
|
||||||
|
totalChecks: numberOrZero(stats.totalChecks),
|
||||||
|
transferTime: numberOrZero(stats.transferTime),
|
||||||
|
errors: normalizeErrors(stats.errors),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageSpeed(stats) {
|
||||||
|
if (stats.bytes > 0 && stats.transferTime > 0) {
|
||||||
|
return stats.bytes / stats.transferTime;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransferCount(stats) {
|
||||||
|
if (stats.totalTransfers > 0) {
|
||||||
|
return `${stats.transfers} / ${stats.totalTransfers}`;
|
||||||
|
}
|
||||||
|
if (stats.totalChecks > 0) {
|
||||||
|
return `${stats.checks} / ${stats.totalChecks}`;
|
||||||
|
}
|
||||||
|
return String(stats.transfers || stats.checks || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeErrors(errors) {
|
||||||
|
if (Array.isArray(errors)) return errors.length;
|
||||||
|
return numberOrZero(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrZero(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberOrNull(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
function detailStatus(job) {
|
function detailStatus(job) {
|
||||||
const status = job.status || "scheduled";
|
const status = job.status || "scheduled";
|
||||||
const key = `jobs.status.${status}`;
|
const key = `jobs.status.${status}`;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
|
||||||
|
|
||||||
import { post } from "../rc.js?v=persist-all-jobs-1";
|
import { post } from "../rc.js?v=job-stats-1";
|
||||||
import { getState, setState, toast } from "../state.js?v=persist-all-jobs-1";
|
import { getState, setState, toast } from "../state.js?v=job-stats-1";
|
||||||
import { t } from "../i18n.js?v=persist-all-jobs-1";
|
import { t } from "../i18n.js?v=job-stats-1";
|
||||||
|
|
||||||
export async function renderRemotes() {
|
export async function renderRemotes() {
|
||||||
const app = document.getElementById("app");
|
const app = document.getElementById("app");
|
||||||
|
|||||||
@@ -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?v=persist-all-jobs-1">
|
<link rel="stylesheet" href="/assets/styles/components.css?v=job-stats-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?v=persist-all-jobs-1"></script>
|
<script type="module" src="/assets/js/app.js?v=job-stats-1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user