diff --git a/webgui/web/assets/js/app.js b/webgui/web/assets/js/app.js
index 77f86e8..c11f359 100644
--- a/webgui/web/assets/js/app.js
+++ b/webgui/web/assets/js/app.js
@@ -2,15 +2,15 @@
// the top-nav active state in sync, and re-renders chrome strings when
// the locale changes.
-import { escapeHtml, onRoute } from "./state.js?v=security-fixes-1";
-import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=security-fixes-1";
-import { renderRemotes } from "./views/remotes.js?v=security-fixes-1";
-import { renderBrowse } from "./views/browser.js?v=security-fixes-1";
-import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=security-fixes-1";
+import { escapeHtml, onRoute } from "./state.js?v=job-layout-1";
+import { t, currentLocale, setLocale, onLocale } from "./i18n.js?v=job-layout-1";
+import { renderRemotes } from "./views/remotes.js?v=job-layout-1";
+import { renderBrowse } from "./views/browser.js?v=job-layout-1";
+import { renderJobs, renderNewJob, stopJobPolling } from "./views/jobs.js?v=job-layout-1";
import {
renderConfigureNew,
renderConfigureEdit,
-} from "./views/configure.js?v=security-fixes-1";
+} from "./views/configure.js?v=job-layout-1";
const views = {
remotes: renderRemotes,
diff --git a/webgui/web/assets/js/views/browser.js b/webgui/web/assets/js/views/browser.js
index 1e14d3c..73bb245 100644
--- a/webgui/web/assets/js/views/browser.js
+++ b/webgui/web/assets/js/views/browser.js
@@ -1,8 +1,8 @@
// views/browser.js — file/folder listing with breadcrumbs, mkdir, upload, delete, rename.
-import { post, uploadFile, downloadURL } from "../rc.js?v=security-fixes-1";
-import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=security-fixes-1";
-import { t } from "../i18n.js?v=security-fixes-1";
+import { post, uploadFile, downloadURL } from "../rc.js?v=job-layout-1";
+import { escapeHtml, toast, formatBytes, formatTime } from "../state.js?v=job-layout-1";
+import { t } from "../i18n.js?v=job-layout-1";
export async function renderBrowse({ remote, path }) {
const app = document.getElementById("app");
diff --git a/webgui/web/assets/js/views/configure.js b/webgui/web/assets/js/views/configure.js
index 5c82d27..d892a0c 100644
--- a/webgui/web/assets/js/views/configure.js
+++ b/webgui/web/assets/js/views/configure.js
@@ -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?v=security-fixes-1";
-import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
-import { t } from "../i18n.js?v=security-fixes-1";
+import { post } from "../rc.js?v=job-layout-1";
+import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
+import { t } from "../i18n.js?v=job-layout-1";
// --- Route entrypoints ---
diff --git a/webgui/web/assets/js/views/jobs.js b/webgui/web/assets/js/views/jobs.js
index 69698dc..a9e8d3a 100644
--- a/webgui/web/assets/js/views/jobs.js
+++ b/webgui/web/assets/js/views/jobs.js
@@ -1,14 +1,14 @@
// views/jobs.js — submit sync/copy/move jobs and manage recurring transfers.
-import { post } from "../rc.js?v=security-fixes-1";
+import { post } from "../rc.js?v=job-layout-1";
import {
toast,
formatBytes,
formatSpeed,
formatDuration,
escapeHtml,
-} from "../state.js?v=security-fixes-1";
-import { t } from "../i18n.js?v=security-fixes-1";
+} from "../state.js?v=job-layout-1";
+import { t } from "../i18n.js?v=job-layout-1";
import {
createOneTimeJob,
createRecurringJob,
@@ -21,7 +21,7 @@ import {
runRecurringJobNow,
stopOneTimeJob,
stopRecurringJob,
-} from "../jobs_api.js?v=security-fixes-1";
+} from "../jobs_api.js?v=job-layout-1";
let pollTimer = null;
const LOCAL_FS_VALUE = "__local__";
@@ -537,78 +537,37 @@ function renderJobTable(statuses) {
return bTime - aTime;
});
- const rows = statuses.map(renderJobRows).join("");
+ const rows = statuses.map(renderJobRow).join("");
return `
-
-
-
- | ${t("jobs.col.id")} |
- ${t("jobs.col.job")} |
- ${t("jobs.col.status")} |
- ${t("jobs.col.next_run")} |
- ${t("jobs.col.progress")} |
- ${t("jobs.col.speed")} |
- ${t("jobs.col.eta")} |
- ${t("jobs.col.files")} |
- ${t("jobs.col.errors")} |
- ${t("jobs.col.action")} |
-
-
-
- ${rows}
-
-
+
+ ${rows}
+
`;
}
-function renderJobRows(s) {
- const row = renderJobRow(s);
- if (!expandedJobs.has(jobKey(s))) {
- return row;
- }
- return row + renderJobDetailsRow(s);
-}
-
function renderJobRow(job) {
const snapshot = job.statusSnapshot || {};
const stats = transferStats(snapshot);
- const id = job.id;
const key = jobKey(job);
const running = !!job.running;
const jobid = job.currentJobid || job.lastJobid;
const displayedSpeed = stats.speed || averageSpeed(stats);
const displayedEta = running ? stats.eta : 0;
+ const expanded = expandedJobs.has(key);
const badge = renderStatusBadge(job.status);
- const jobCell = `
-
- ${escapeHtml(job.action)}
-
- ${escapeHtml(job.src)}
- →
- ${escapeHtml(job.dst)}
-
-
`;
-
- const scheduleCell = `
-
- ${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}
- ${escapeHtml(formatSchedule(job))}
-
- `;
-
const pct = stats.totalBytes > 0 ? Math.min(100, (stats.bytes / stats.totalBytes) * 100) : 0;
const progress = `
-
+
-
+
${formatBytes(stats.bytes)} / ${formatBytes(stats.totalBytes)}
`;
- const detailBtn = `
`;
+ const detailBtn = `
`;
const startBtn = running
? ""
: `
`;
@@ -618,28 +577,50 @@ function renderJobRow(job) {
const deleteBtn = `
`;
return `
-
-
- ${escapeHtml(displayJobId(job))}
- ${jobid ? ` job ${escapeHtml(jobid)}` : ""}
- |
- ${jobCell} |
- ${badge} |
- ${scheduleCell} |
- ${progress} |
- ${escapeHtml(formatSpeed(displayedSpeed))} |
- ${escapeHtml(formatDuration(displayedEta))} |
- ${escapeHtml(formatTransferCount(stats))} |
- ${escapeHtml(String(stats.errors || 0))} |
-
+
+
+
+
+ ${escapeHtml(displayJobId(job))}
+ ${jobid ? `job ${escapeHtml(jobid)}` : ""}
+
+ ${badge}
+
+
+
+ ${escapeHtml(job.action)}
+ ${escapeHtml(job.src)}
+
+
+ →
+ ${escapeHtml(job.dst)}
+
+
+
+ ${escapeHtml(job.kind === "once" ? t("jobs.schedule_once") : formatDateTime(job.nextRunAt))}
+ ${escapeHtml(formatSchedule(job))}
+
+
+
+
+ ${t("jobs.col.progress")}
+ ${progress}
+
+
+ ${metricHtml(t("jobs.col.speed"), formatSpeed(displayedSpeed))}
+ ${metricHtml(t("jobs.col.eta"), formatDuration(displayedEta))}
+ ${metricHtml(t("jobs.col.files"), formatTransferCount(stats))}
+ ${metricHtml(t("jobs.col.errors"), String(stats.errors || 0))}
+
${detailBtn}
${startBtn}
${stopBtn}
${deleteBtn}
- |
-
+
+ ${expanded ? renderJobDetails(job) : ""}
+
`;
}
@@ -661,7 +642,16 @@ function renderStatusBadge(status) {
}
}
-function renderJobDetailsRow(job) {
+function metricHtml(label, value) {
+ return `
+
+ ${escapeHtml(label)}
+ ${escapeHtml(value)}
+
+ `;
+}
+
+function renderJobDetails(job) {
const snapshot = job.statusSnapshot || {};
const stats = transferStats(snapshot);
const jobid = job.currentJobid || job.lastJobid || "";
@@ -688,27 +678,22 @@ function renderJobDetailsRow(job) {
: "";
return `
-
- |
-
-
-
- ${details.map(([label, value]) => `
-
- - ${escapeHtml(label)}
- - ${escapeHtml(value)}
-
- `).join("")}
-
- ${output ? `
-
- ${t("jobs.detail_output")}
- ${escapeHtml(output)}
-
- ` : ""}
+
+
+ ${details.map(([label, value]) => `
+
+ - ${escapeHtml(label)}
+ - ${escapeHtml(value)}
+
+ `).join("")}
+
+ ${output ? `
+
+ ${t("jobs.detail_output")}
+ ${escapeHtml(output)}
- |
-
+ ` : ""}
+
`;
}
diff --git a/webgui/web/assets/js/views/remotes.js b/webgui/web/assets/js/views/remotes.js
index 226b495..204a5e0 100644
--- a/webgui/web/assets/js/views/remotes.js
+++ b/webgui/web/assets/js/views/remotes.js
@@ -1,8 +1,8 @@
// views/remotes.js — connector-tile grid of configured remotes with CRUD.
-import { post } from "../rc.js?v=security-fixes-1";
-import { escapeHtml, getState, setState, toast } from "../state.js?v=security-fixes-1";
-import { t } from "../i18n.js?v=security-fixes-1";
+import { post } from "../rc.js?v=job-layout-1";
+import { escapeHtml, getState, setState, toast } from "../state.js?v=job-layout-1";
+import { t } from "../i18n.js?v=job-layout-1";
export async function renderRemotes() {
const app = document.getElementById("app");
diff --git a/webgui/web/assets/styles/components.css b/webgui/web/assets/styles/components.css
index 8ab07c4..7342369 100644
--- a/webgui/web/assets/styles/components.css
+++ b/webgui/web/assets/styles/components.css
@@ -865,46 +865,148 @@
margin-bottom: var(--space-md);
}
-/* ---------- Job cell with action + src→dst ---------- */
-.job-cell {
+/* ---------- Jobs ---------- */
+.job-list {
+ display: grid;
+ gap: var(--space-sm);
+}
+
+.job-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
+ gap: var(--space-lg);
+ padding: var(--space-lg);
+ border-bottom: 1px solid var(--color-hairline-soft);
+}
+
+.job-item:last-child {
+ border-bottom: none;
+}
+
+.job-main {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: var(--space-md);
+ min-width: 0;
+}
+
+.job-head {
display: flex;
flex-direction: column;
- gap: 2px;
- min-width: 200px;
+ align-items: flex-start;
+ gap: var(--space-xs);
}
-.job-cell .job-action {
+.job-id {
+ display: grid;
+ gap: 2px;
+ min-width: 58px;
+ color: var(--color-muted);
+ font: var(--typo-code);
+}
+
+.job-id small {
+ color: var(--color-muted-soft);
+ font: var(--typo-caption);
+}
+
+.job-route {
+ display: grid;
+ gap: var(--space-xs);
+ min-width: 0;
+}
+
+.job-route-line {
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr);
+ align-items: start;
+ gap: var(--space-xs);
+}
+
+.job-route-line span {
+ color: var(--color-muted);
font: var(--typo-caption-uppercase);
letter-spacing: var(--typo-caption-uppercase-tracking);
- color: var(--color-muted);
+ text-transform: uppercase;
}
-.job-cell .job-paths {
- font: var(--typo-body-sm);
+.job-route-line code {
color: var(--color-body);
- word-break: break-all;
+ font: var(--typo-code);
+ white-space: normal;
+ overflow-wrap: anywhere;
}
-.job-cell .job-paths .arrow {
- color: var(--color-muted-soft);
- margin: 0 var(--space-xxs);
+.job-schedule {
+ grid-column: 2;
+ display: flex;
+ gap: var(--space-sm);
+ flex-wrap: wrap;
+ color: var(--color-muted);
+ font: var(--typo-caption);
+}
+
+.job-side {
+ display: grid;
+ align-content: start;
+ gap: var(--space-sm);
+ min-width: 0;
+}
+
+.job-progress-wrap {
+ display: grid;
+ gap: var(--space-xs);
+}
+
+.job-metric-label,
+.job-metric span {
+ color: var(--color-muted);
+ font: var(--typo-caption);
+}
+
+.job-progress {
+ display: grid;
+ gap: 4px;
+}
+
+.job-progress-text {
+ color: var(--color-muted);
+ font: var(--typo-code);
+}
+
+.job-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--space-xs);
+}
+
+.job-metric {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.job-metric strong {
+ color: var(--color-body);
+ font: var(--typo-code);
+ font-weight: 400;
+ overflow-wrap: anywhere;
}
.job-actions {
- display: inline-flex;
+ display: flex;
justify-content: flex-end;
gap: var(--space-xs);
flex-wrap: wrap;
}
-.job-detail-row {
- background-color: var(--color-surface-soft);
-}
-
.job-detail {
+ grid-column: 1 / -1;
display: grid;
gap: var(--space-sm);
- padding: var(--space-sm) 0;
+ padding: var(--space-md);
+ background-color: var(--color-surface-soft);
+ border-radius: var(--radius-sm);
}
.job-detail dl {
@@ -964,6 +1066,15 @@
.form-grid {
grid-template-columns: 1fr;
}
+ .job-item {
+ grid-template-columns: 1fr;
+ }
+ .job-side {
+ grid-template-columns: 1fr;
+ }
+ .job-actions {
+ justify-content: flex-start;
+ }
}
@media (max-width: 768px) {
@@ -982,4 +1093,20 @@
.top-nav nav {
display: none;
}
+ .job-item {
+ padding: var(--space-md);
+ }
+ .job-main {
+ grid-template-columns: 1fr;
+ }
+ .job-schedule {
+ grid-column: 1;
+ }
+ .job-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .job-detail dl,
+ .job-detail dl div {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/webgui/web/index.html b/webgui/web/index.html
index 3b62f3e..5ea0c5e 100644
--- a/webgui/web/index.html
+++ b/webgui/web/index.html
@@ -7,7 +7,7 @@
-
+