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" /> +
Admins only.
+ + ); + } + + const byStatus = stats?.by_status || {}; + + return ( + <> + } + title="Jobs" + meta="Background work across the Queue Bus" + actions={( + + )} + /> + +
+
+ + + + + +
+ +
+ + +
+ + {loading && jobs.length === 0 ? ( +
Loading…
+ ) : jobs.length === 0 ? ( +
+
+ ) : ( +
+ + + + + + + + + + + + {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')} + /> + ))} + +
+ StatusKindOwnerTriesCreated +
+
+ )} + +
+
+ + {scheduled.length === 0 ? ( +

No scheduled jobs.

+ ) : ( +
+ + + + + + + + + + + + {scheduled.map((s) => ( + + + + + + + + + ))} + +
NameKindScheduleNext runLast run +
{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; +}