diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 45c3894c..683a1e1b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -74,6 +74,7 @@ import QueueOperations from './pages/QueueOperations';
import Notifications from './pages/Notifications';
import DeliveryLog from './pages/DeliveryLog';
import Telemetry from './pages/Telemetry';
+import Jobs from './pages/Jobs';
import useExtensionRoutes from './plugins/ExtensionRoutes';
import { useContributions } from './plugins/contributions';
@@ -128,6 +129,7 @@ const PAGE_TITLES = {
'/notifications': 'Notifications',
'/admin/notifications': 'Notification Delivery Log',
'/telemetry': 'Telemetry',
+ '/jobs': 'Jobs',
};
function PageTitleUpdater() {
@@ -367,6 +369,7 @@ function AppRoutes() {
} />
} />
} />
+ } />
} />
} />
{dashboardRoutes}
diff --git a/frontend/src/components/sidebarItems.js b/frontend/src/components/sidebarItems.js
index e3ab009d..4a40735d 100644
--- a/frontend/src/components/sidebarItems.js
+++ b/frontend/src/components/sidebarItems.js
@@ -186,6 +186,13 @@ export const SIDEBAR_ITEMS = [
category: 'system',
icon: ''
},
+ {
+ id: 'jobs',
+ label: 'Jobs',
+ route: '/jobs',
+ category: 'system',
+ icon: ''
+ },
{
id: 'gpu',
label: 'GPU Monitor',
diff --git a/frontend/src/pages/Jobs.jsx b/frontend/src/pages/Jobs.jsx
new file mode 100644
index 00000000..d1e1bcce
--- /dev/null
+++ b/frontend/src/pages/Jobs.jsx
@@ -0,0 +1,320 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import {
+ Layers, RefreshCw, Inbox, Play, X, RotateCcw, ChevronRight, Clock,
+} from 'lucide-react';
+import api from '../services/api';
+import { PageTopbar, MetricCard, Pill } from '@/components/ds';
+import { Button } from '@/components/ui/button';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { timeAgo } from '../utils/timeAgo';
+
+const STATUSES = ['all', 'pending', 'running', 'succeeded', 'failed', 'cancelled'];
+const POLL_MS = 5000;
+
+// Job status -> Pill colour.
+const PILL_KIND = {
+ succeeded: 'green',
+ running: 'cyan',
+ pending: 'amber',
+ failed: 'red',
+ cancelled: 'gray',
+};
+
+function StatusPill({ status }) {
+ return {status};
+}
+
+function describeSchedule(s) {
+ if (s.schedule_kind === 'cron' && s.cron) return s.cron;
+ const sec = s.interval_seconds;
+ if (!sec) return '—';
+ if (sec % 86400 === 0) return `every ${sec / 86400}d`;
+ if (sec % 3600 === 0) return `every ${sec / 3600}h`;
+ if (sec % 60 === 0) return `every ${sec / 60}m`;
+ return `every ${sec}s`;
+}
+
+function JobDetail({ detail }) {
+ if (!detail) return
Loading details…
;
+ const payload = detail.payload && Object.keys(detail.payload).length > 0 ? detail.payload : null;
+ return (
+
+
+ ID{detail.id}
+ Correlation{detail.correlation_id || '—'}
+ Started{detail.started_at ? new Date(detail.started_at).toLocaleString() : '—'}
+ Completed{detail.completed_at ? new Date(detail.completed_at).toLocaleString() : '—'}
+ Duration{detail.duration != null ? `${detail.duration.toFixed(2)}s` : '—'}
+
+ {detail.error_message && (
+
+
Error
+
{detail.error_message}
+
+ )}
+ {payload && (
+
+
Payload
+
{JSON.stringify(payload, null, 2)}
+
+ )}
+ {detail.result != null && (
+
+
Result
+
{JSON.stringify(detail.result, null, 2)}
+
+ )}
+
+ );
+}
+
+function JobRow({ job, expanded, detail, onToggle, onCancel, onRetry }) {
+ const canCancel = job.status === 'pending' || job.status === 'running';
+ const canRetry = job.status === 'failed' || job.status === 'cancelled';
+ return (
+ <>
+
+ |
+
+ |
+ |
+ {job.kind} |
+
+ {job.owner_type ? `${job.owner_type}${job.owner_id ? `:${job.owner_id}` : ''}` : '—'}
+ |
+ {job.attempts}/{job.max_attempts} |
+ {timeAgo(job.created_at)} |
+ e.stopPropagation()}>
+ {canCancel && (
+
+ )}
+ {canRetry && (
+
+ )}
+ |
+
+ {expanded && (
+
+ |
+
+ )}
+ >
+ );
+}
+
+export default function Jobs() {
+ const { isAdmin } = useAuth();
+ const toast = useToast();
+ const [jobs, setJobs] = useState([]);
+ const [stats, setStats] = useState(null);
+ const [kinds, setKinds] = useState([]);
+ const [scheduled, setScheduled] = useState([]);
+ const [status, setStatus] = useState('all');
+ const [kind, setKind] = useState('all');
+ const [expandedId, setExpandedId] = useState(null);
+ const [detail, setDetail] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const pollRef = useRef(null);
+
+ const load = useCallback(async () => {
+ try {
+ const params = {};
+ if (status !== 'all') params.status = status;
+ if (kind !== 'all') params.kind = kind;
+ const [jobsData, statsData, schedData] = await Promise.all([
+ api.getJobs(params),
+ api.getJobStats(),
+ api.getScheduledJobs(),
+ ]);
+ setJobs(jobsData.jobs || []);
+ setStats(statsData || null);
+ setScheduled(schedData.scheduled || []);
+ } catch {
+ // leave the last good state on screen
+ } finally {
+ setLoading(false);
+ }
+ }, [status, kind]);
+
+ useEffect(() => {
+ if (!isAdmin) return undefined;
+ load();
+ pollRef.current = setInterval(load, POLL_MS);
+ return () => clearInterval(pollRef.current);
+ }, [isAdmin, load]);
+
+ useEffect(() => {
+ if (!isAdmin) return;
+ api.getJobKinds().then((d) => setKinds(d.kinds || [])).catch(() => {});
+ }, [isAdmin]);
+
+ const toggleDetail = async (job) => {
+ if (expandedId === job.id) {
+ setExpandedId(null);
+ setDetail(null);
+ return;
+ }
+ setExpandedId(job.id);
+ setDetail(null);
+ try {
+ const data = await api.getJob(job.id);
+ setDetail(data.job || null);
+ } catch {
+ setDetail(null);
+ }
+ };
+
+ const action = async (fn, okMsg, failMsg) => {
+ try {
+ await fn();
+ toast.success(okMsg);
+ load();
+ } catch (e) {
+ toast.error(e?.message || failMsg);
+ }
+ };
+
+ if (!isAdmin) {
+ return (
+ <>
+ } title="Jobs" />
+
+ >
+ );
+ }
+
+ const byStatus = stats?.by_status || {};
+
+ return (
+ <>
+ }
+ title="Jobs"
+ meta="Background work across the Queue Bus"
+ actions={(
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {loading && jobs.length === 0 ? (
+
Loading…
+ ) : jobs.length === 0 ? (
+
+
+
No jobs match these filters.
+
+ ) : (
+
+
+
+
+ |
+ Status |
+ Kind |
+ Owner |
+ Tries |
+ Created |
+ |
+
+
+
+ {jobs.map((j) => (
+ toggleDetail(j)}
+ onCancel={() => action(() => api.cancelJob(j.id), 'Job cancelled', 'Cancel failed')}
+ onRetry={() => action(() => api.retryJob(j.id), 'Job re-queued', 'Retry failed')}
+ />
+ ))}
+
+
+
+ )}
+
+
+
+
Scheduled jobs
+
+
+ {scheduled.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | Name |
+ Kind |
+ Schedule |
+ Next run |
+ Last run |
+ |
+
+
+
+ {scheduled.map((s) => (
+
+ | {s.name} |
+ {s.kind} |
+ {describeSchedule(s)} |
+
+ {s.enabled
+ ? (s.next_run_at ? timeAgo(s.next_run_at) : '—')
+ : paused}
+ |
+ {s.last_run_at ? timeAgo(s.last_run_at) : '—'} |
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ >
+ );
+}
diff --git a/frontend/src/services/api/index.js b/frontend/src/services/api/index.js
index 36e39a06..ad239962 100644
--- a/frontend/src/services/api/index.js
+++ b/frontend/src/services/api/index.js
@@ -21,6 +21,7 @@ import * as containerOpsMethods from './containerOps.js';
import * as queueBusMethods from './queueBus.js';
import * as notificationMethods from './notifications.js';
import * as telemetryMethods from './telemetry.js';
+import * as jobMethods from './jobs.js';
class ApiService extends ApiClient {
constructor() {
@@ -49,6 +50,7 @@ class ApiService extends ApiClient {
queueBusMethods,
notificationMethods,
telemetryMethods,
+ jobMethods,
];
for (const mod of modules) {
for (const [key, fn] of Object.entries(mod)) {
diff --git a/frontend/src/services/api/jobs.js b/frontend/src/services/api/jobs.js
new file mode 100644
index 00000000..e1fdfc27
--- /dev/null
+++ b/frontend/src/services/api/jobs.js
@@ -0,0 +1,48 @@
+// Unified job system API (admin only) — backs the Jobs page.
+
+export async function getJobs(params = {}) {
+ const query = new URLSearchParams();
+ if (params.status) query.append('status', params.status);
+ if (params.kind) query.append('kind', params.kind);
+ if (params.owner_type) query.append('owner_type', params.owner_type);
+ if (params.owner_id) query.append('owner_id', params.owner_id);
+ if (params.limit) query.append('limit', params.limit);
+ if (params.offset) query.append('offset', params.offset);
+ const suffix = query.toString() ? `?${query}` : '';
+ return this.request(`/jobs${suffix}`);
+}
+
+export async function getJobStats() {
+ return this.request('/jobs/stats');
+}
+
+export async function getJobKinds() {
+ return this.request('/jobs/kinds');
+}
+
+export async function getJob(jobId) {
+ return this.request(`/jobs/${jobId}`);
+}
+
+export async function cancelJob(jobId) {
+ return this.request(`/jobs/${jobId}/cancel`, { method: 'POST' });
+}
+
+export async function retryJob(jobId) {
+ return this.request(`/jobs/${jobId}/retry`, { method: 'POST' });
+}
+
+export async function getScheduledJobs() {
+ return this.request('/jobs/scheduled');
+}
+
+export async function runScheduledJob(scheduledId) {
+ return this.request(`/jobs/scheduled/${scheduledId}/run`, { method: 'POST' });
+}
+
+export async function setScheduledJobEnabled(scheduledId, enabled) {
+ return this.request(`/jobs/scheduled/${scheduledId}/enabled`, {
+ method: 'POST',
+ body: JSON.stringify({ enabled }),
+ });
+}
diff --git a/frontend/src/styles/main.scss b/frontend/src/styles/main.scss
index 08904e81..8428acd5 100644
--- a/frontend/src/styles/main.scss
+++ b/frontend/src/styles/main.scss
@@ -120,3 +120,4 @@
@import 'pages/_dynamic-dns';
@import 'pages/_queue-operations';
@import 'pages/_telemetry';
+@import 'pages/_jobs';
diff --git a/frontend/src/styles/pages/_jobs.scss b/frontend/src/styles/pages/_jobs.scss
new file mode 100644
index 00000000..06c5c3cd
--- /dev/null
+++ b/frontend/src/styles/pages/_jobs.scss
@@ -0,0 +1,208 @@
+.jobs-page {
+ padding: $spacing-md;
+}
+
+.jobs-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: $spacing-md;
+ margin-bottom: $spacing-lg;
+}
+
+.jobs-filters {
+ display: flex;
+ gap: $spacing-lg;
+ flex-wrap: wrap;
+ margin-bottom: $spacing-md;
+
+ label {
+ display: flex;
+ flex-direction: column;
+ gap: $spacing-xs;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $text-tertiary;
+ }
+
+ select {
+ padding: $spacing-sm;
+ border-radius: $radius-sm;
+ border: 1px solid $border-default;
+ background: $bg-card;
+ color: $text-primary;
+ font-size: 0.875rem;
+ min-width: 160px;
+ }
+}
+
+.jobs-table-wrap {
+ overflow-x: auto;
+ border: 1px solid $border-default;
+ border-radius: $radius-md;
+ background: $bg-card;
+ margin-bottom: $spacing-lg;
+}
+
+.jobs-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+
+ th {
+ text-align: left;
+ padding: $spacing-sm $spacing-md;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $text-tertiary;
+ border-bottom: 1px solid $border-default;
+ white-space: nowrap;
+ }
+
+ td {
+ padding: $spacing-sm $spacing-md;
+ border-bottom: 1px solid $border-default;
+ color: $text-primary;
+ vertical-align: middle;
+ }
+
+ tbody tr:last-child td {
+ border-bottom: none;
+ }
+
+ tr.is-disabled td {
+ opacity: 0.55;
+ }
+}
+
+.jobs-row {
+ cursor: pointer;
+ transition: background $transition-fast;
+
+ &:hover,
+ &.is-open {
+ background: $bg-hover;
+ }
+}
+
+.jobs-caret svg {
+ color: $text-tertiary;
+ transition: transform $transition-fast;
+
+ &.is-open {
+ transform: rotate(90deg);
+ }
+}
+
+.jobs-mono {
+ font-family: var(--mono, monospace);
+ font-size: 0.8125rem;
+}
+
+.jobs-owner {
+ color: $text-tertiary;
+ font-size: 0.8125rem;
+}
+
+.jobs-when {
+ color: $text-tertiary;
+ white-space: nowrap;
+}
+
+.jobs-actions {
+ display: flex;
+ gap: $spacing-xs;
+ justify-content: flex-end;
+ white-space: nowrap;
+}
+
+.jobs-detail-row > td {
+ padding: 0;
+ background: $bg-tertiary;
+}
+
+.jobs-detail {
+ padding: $spacing-md $spacing-lg;
+
+ &--loading {
+ color: $text-tertiary;
+ }
+
+ &__grid {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: $spacing-xs $spacing-md;
+ font-size: 0.8125rem;
+ margin-bottom: $spacing-md;
+
+ span:nth-child(odd) {
+ color: $text-tertiary;
+ }
+
+ code {
+ font-family: var(--mono, monospace);
+ color: $text-primary;
+ word-break: break-all;
+ }
+ }
+
+ &__block {
+ margin-top: $spacing-sm;
+
+ > span {
+ display: block;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $text-tertiary;
+ margin-bottom: $spacing-xs;
+ }
+
+ pre {
+ margin: 0;
+ background: $bg-card;
+ border: 1px solid $border-default;
+ border-radius: $radius-sm;
+ padding: $spacing-sm $spacing-md;
+ overflow-x: auto;
+ font-size: 0.8125rem;
+ color: $text-primary;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ &--error pre {
+ color: $danger;
+ border-color: $danger;
+ }
+ }
+}
+
+.jobs-section-head {
+ display: flex;
+ align-items: center;
+ gap: $spacing-sm;
+ margin: $spacing-lg 0 $spacing-md;
+ color: $text-primary;
+
+ svg {
+ color: $text-tertiary;
+ }
+
+ h2 {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+}
+
+.jobs-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: $spacing-md;
+ padding: $spacing-xl;
+ color: $text-tertiary;
+}