feat(jobs): admin Jobs UI page over the unified job API (Phase 9)

Add /jobs — the single pane to observe and manage all background work, built
on the /api/v1/jobs API. Mirrors the DeliveryLog/Telemetry admin pages.

- Jobs table polls every 5s: status Pills (succeeded/running/pending/failed/
  cancelled), kind, owner, attempts, age. Status/kind filters. Per-row Cancel
  (pending/running) and Retry (failed/cancelled) with toast feedback. Row
  expands to a detail view (payload/result/error/correlation/timing) via getJob.
- Scheduled-jobs table: name, kind, schedule (cron or "every Nh"), next/last run,
  with Run-now and Pause/Enable.
- Stat cards (total/running/pending/succeeded/failed) from /jobs/stats.
- Wiring: services/api/jobs.js module bound into ApiService; route + page title
  in App.jsx; "Jobs" item in the sidebar "system" group; _jobs.scss imported
  in main.scss. Admin-gated client-side via useAuth().isAdmin.

Lint clean (0 errors) and production build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 4.8 committed 2026-06-21 21:28:41 -04:00
1 parent c0cb0f088e
commit 70f8697586
7 files changed
+589

No files matched your search

+3
View File
@@ -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() {
<Route path="notifications" element={<Notifications />} />
<Route path="admin/notifications" element={<DeliveryLog />} />
<Route path="telemetry" element={<Telemetry />} />
<Route path="jobs" element={<Jobs />} />
<Route path="settings" element={<Settings />} />
<Route path="settings/:tab" element={<Settings />} />
{dashboardRoutes}
+7
View File
@@ -186,6 +186,13 @@ export const SIDEBAR_ITEMS = [
category: 'system',
icon: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>'
},
{
id: 'jobs',
label: 'Jobs',
route: '/jobs',
category: 'system',
icon: '<path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/>'
},
{
id: 'gpu',
label: 'GPU Monitor',
+320
View File
@@ -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 <Pill kind={PILL_KIND[status] || 'gray'}>{status}</Pill>;
}
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 <div className="jobs-detail jobs-detail--loading">Loading details…</div>;
const payload = detail.payload && Object.keys(detail.payload).length > 0 ? detail.payload : null;
return (
<div className="jobs-detail">
<div className="jobs-detail__grid">
<span>ID</span><code>{detail.id}</code>
<span>Correlation</span><code>{detail.correlation_id || '—'}</code>
<span>Started</span><span>{detail.started_at ? new Date(detail.started_at).toLocaleString() : '—'}</span>
<span>Completed</span><span>{detail.completed_at ? new Date(detail.completed_at).toLocaleString() : '—'}</span>
<span>Duration</span><span>{detail.duration != null ? `${detail.duration.toFixed(2)}s` : '—'}</span>
</div>
{detail.error_message && (
<div className="jobs-detail__block jobs-detail__block--error">
<span>Error</span>
<pre>{detail.error_message}</pre>
</div>
)}
{payload && (
<div className="jobs-detail__block">
<span>Payload</span>
<pre>{JSON.stringify(payload, null, 2)}</pre>
</div>
)}
{detail.result != null && (
<div className="jobs-detail__block">
<span>Result</span>
<pre>{JSON.stringify(detail.result, null, 2)}</pre>
</div>
)}
</div>
);
}
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 (
<>
<tr className={`jobs-row${expanded ? ' is-open' : ''}`} onClick={onToggle}>
<td className="jobs-caret">
<ChevronRight size={14} className={expanded ? 'is-open' : ''} />
</td>
<td><StatusPill status={job.status} /></td>
<td className="jobs-mono">{job.kind}</td>
<td className="jobs-owner">
{job.owner_type ? `${job.owner_type}${job.owner_id ? `:${job.owner_id}` : ''}` : '—'}
</td>
<td>{job.attempts}/{job.max_attempts}</td>
<td className="jobs-when">{timeAgo(job.created_at)}</td>
<td className="jobs-actions" onClick={(e) => e.stopPropagation()}>
{canCancel && (
<Button variant="ghost" size="sm" onClick={onCancel}><X size={14} /> Cancel</Button>
)}
{canRetry && (
<Button variant="ghost" size="sm" onClick={onRetry}><RotateCcw size={14} /> Retry</Button>
)}
</td>
</tr>
{expanded && (
<tr className="jobs-detail-row">
<td colSpan={7}><JobDetail detail={detail} /></td>
</tr>
)}
</>
);
}
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 (
<>
<PageTopbar icon={<Layers size={18} />} title="Jobs" />
<div className="jobs-page"><div className="jobs-empty">Admins only.</div></div>
</>
);
}
const byStatus = stats?.by_status || {};
return (
<>
<PageTopbar
icon={<Layers size={18} />}
title="Jobs"
meta="Background work across the Queue Bus"
actions={(
<Button variant="outline" size="sm" onClick={load}>
<RefreshCw size={14} /> Refresh
</Button>
)}
/>
<div className="jobs-page">
<div className="jobs-stats">
<MetricCard label="Total" value={stats?.total ?? 0} tone="accent" />
<MetricCard label="Running" value={byStatus.running ?? 0} />
<MetricCard label="Pending" value={byStatus.pending ?? 0} tone="amber" />
<MetricCard label="Succeeded" value={byStatus.succeeded ?? 0} tone="green" />
<MetricCard label="Failed" value={byStatus.failed ?? 0} tone="red" />
</div>
<div className="jobs-filters">
<label>
Status
<select value={status} onChange={(e) => setStatus(e.target.value)}>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label>
Kind
<select value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="all">all</option>
{kinds.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
</label>
</div>
{loading && jobs.length === 0 ? (
<div className="jobs-empty">Loading…</div>
) : jobs.length === 0 ? (
<div className="jobs-empty">
<Inbox size={24} aria-hidden="true" />
<p>No jobs match these filters.</p>
</div>
) : (
<div className="jobs-table-wrap">
<table className="jobs-table">
<thead>
<tr>
<th aria-label="expand" />
<th>Status</th>
<th>Kind</th>
<th>Owner</th>
<th>Tries</th>
<th>Created</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<JobRow
key={j.id}
job={j}
expanded={expandedId === j.id}
detail={expandedId === j.id ? detail : null}
onToggle={() => toggleDetail(j)}
onCancel={() => action(() => api.cancelJob(j.id), 'Job cancelled', 'Cancel failed')}
onRetry={() => action(() => api.retryJob(j.id), 'Job re-queued', 'Retry failed')}
/>
))}
</tbody>
</table>
</div>
)}
<div className="jobs-section-head">
<Clock size={16} aria-hidden="true" />
<h2>Scheduled jobs</h2>
</div>
{scheduled.length === 0 ? (
<div className="jobs-empty"><p>No scheduled jobs.</p></div>
) : (
<div className="jobs-table-wrap">
<table className="jobs-table">
<thead>
<tr>
<th>Name</th>
<th>Kind</th>
<th>Schedule</th>
<th>Next run</th>
<th>Last run</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{scheduled.map((s) => (
<tr key={s.id} className={s.enabled ? '' : 'is-disabled'}>
<td className="jobs-mono">{s.name}</td>
<td className="jobs-mono">{s.kind}</td>
<td>{describeSchedule(s)}</td>
<td>
{s.enabled
? (s.next_run_at ? timeAgo(s.next_run_at) : '—')
: <Pill kind="gray">paused</Pill>}
</td>
<td className="jobs-when">{s.last_run_at ? timeAgo(s.last_run_at) : '—'}</td>
<td className="jobs-actions">
<Button variant="ghost" size="sm" title="Run now"
onClick={() => action(() => api.runScheduledJob(s.id), `Enqueued "${s.name}"`, 'Run failed')}>
<Play size={14} /> Run
</Button>
<Button variant="ghost" size="sm"
onClick={() => action(
() => api.setScheduledJobEnabled(s.id, !s.enabled),
s.enabled ? `Paused "${s.name}"` : `Enabled "${s.name}"`,
'Update failed',
)}>
{s.enabled ? 'Pause' : 'Enable'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
+2
View File
@@ -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)) {
+48
View File
@@ -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 }),
});
}
+1
View File
@@ -120,3 +120,4 @@
@import 'pages/_dynamic-dns';
@import 'pages/_queue-operations';
@import 'pages/_telemetry';
@import 'pages/_jobs';
+208
View File
@@ -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;
}