From 46dc6ce50752dfd436ffcdded9cac8926787c08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 23 Jul 2026 14:04:08 +0000 Subject: [PATCH] jobs: script-job terminal view at /jobs/:id (phase 3a) Co-Authored-By: Claude Opus 4.8 --- docs/jobs-unification.md | 9 +- .../Screens/Dashboard/Jobs/JobDetail.tsx | 2 +- .../Screens/Dashboard/Jobs/JobDetailView.tsx | 34 ++++ .../Dashboard/Jobs/ScriptJobDetail.tsx | 172 ++++++++++++++++++ .../Screens/Dashboard/Jobs/index.tsx | 2 +- 5 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx create mode 100644 src/apps/officer-web/Screens/Dashboard/Jobs/ScriptJobDetail.tsx diff --git a/docs/jobs-unification.md b/docs/jobs-unification.md index 8fc54367..581a5f88 100644 --- a/docs/jobs-unification.md +++ b/docs/jobs-unification.md @@ -70,5 +70,12 @@ Favor power-user affordances over guardrails. See memory `sole-user-assume-compe now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`. Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.* WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live). -- [ ] 3 /jobs/new page + JobDetail script branch + retire modal + header indicator +- [~] 3 frontend + - [x] 3a JobDetail script branch — `ScriptJobDetail` (terminal, polls `GET /jobs/:id/log` + status, + Stop button) + `JobDetailView` wrapper routing by `mode` (pipeline detail renamed + `PipelineJobDetail`). Script jobs are now viewable at `/jobs/:id`. + - [ ] 3b `/jobs/new` page — extract the input UI (TaskInputForm + per-group config + folder probing) + from `TaskRunnerModal` into a shared component; Run/Queue → `POST /jobs` → navigate. + - [ ] 3c FileBrowser task action navigates to `/jobs/new?...`; retire modal/dialog/useTaskRunner. + - [ ] 3d header running-jobs indicator (`GET /jobs?live=1`). - [ ] 4 push notifications diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx index f97d955a..e86f991c 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetail.tsx @@ -330,7 +330,7 @@ const OutputPanel = () => { ); }; -export const JobDetail = () => { +export const PipelineJobDetail = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const client = useClient(); diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx new file mode 100644 index 00000000..1799666d --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobDetailView.tsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router'; +import { Loader2 } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { PipelineJobDetail } from './JobDetail'; +import { ScriptJobDetail } from './ScriptJobDetail'; + +// Route entry for /jobs/:id — picks the right detail view by job mode (script terminal vs pipeline steps). +export const JobDetail = () => { + const { id } = useParams<{ id: string }>(); + const client = useClient(); + const [mode, setMode] = useState(null); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + if (!id) return; + client + .get<{ mode?: string }>(`/jobs/${id}`) + .then((j) => setMode(j.mode ?? 'pipeline')) + .catch(() => setNotFound(true)); + }, [id]); + + if (notFound) { + return
Job not found.
; + } + if (mode === null) { + return ( +
+ +
+ ); + } + return mode === 'script' ? : ; +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/ScriptJobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/ScriptJobDetail.tsx new file mode 100644 index 00000000..2a0e2b3e --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/ScriptJobDetail.tsx @@ -0,0 +1,172 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams, Link } from 'react-router'; +import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { toast } from 'sonner'; + +type ScriptJob = { + id: string; + mode: string; + taskDirName: string; + taskName: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted'; + exitCode: number | null; + error: string | null; + createdAt: string; + startedAt: string | null; + completedAt: string | null; +}; + +const LOG_POLL_MS = 1500; +const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted'; + +const statusBadge = (status: string, exitCode: number | null) => { + switch (status) { + case 'running': return { icon: , label: 'Running', cls: 'text-amber-600 dark:text-amber-500' }; + case 'pending': return { icon: , label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' }; + case 'completed': return { icon: , label: 'Completed', cls: 'text-duck-teal' }; + case 'failed': return { icon: , label: `Failed${exitCode != null ? ` (exit ${exitCode})` : ''}`, cls: 'text-red-600 dark:text-red-400' }; + case 'stopped': return { icon: , label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' }; + default: return { icon: , label: 'Interrupted', cls: 'text-orange-600 dark:text-orange-400' }; + } +}; + +export const ScriptJobDetail = () => { + const { id } = useParams<{ id: string }>(); + const client = useClient(); + + const [job, setJob] = useState(null); + const [output, setOutput] = useState(''); + const [loading, setLoading] = useState(true); + const offsetRef = useRef(0); + const preRef = useRef(null); + const autoFollow = useRef(true); + + // Append the next slice of the log from the tracked offset. + const pullLog = useCallback(async () => { + if (!id) return; + try { + const res = await client.get<{ text: string; offset: number; size: number }>(`/jobs/${id}/log?offset=${offsetRef.current}`); + if (res.text) { + offsetRef.current = res.offset; + setOutput((prev) => prev + res.text); + } + } catch { + // transient + } + }, [id]); + + // Initial load: job + full log so far. + useEffect(() => { + if (!id) return; + let cancelled = false; + (async () => { + try { + const j = await client.get(`/jobs/${id}`); + if (cancelled) return; + setJob(j); + await pullLog(); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [id]); + + // While live, poll the log + status until the job reaches a terminal state. + useEffect(() => { + if (!id || !job || isTerminal(job.status)) return; + const timer = setInterval(async () => { + await pullLog(); + try { + const j = await client.get(`/jobs/${id}`); + setJob(j); + if (isTerminal(j.status)) { + clearInterval(timer); + await pullLog(); // final catch-up + } + } catch { + // transient + } + }, LOG_POLL_MS); + return () => clearInterval(timer); + }, [id, job?.status]); + + // Auto-scroll to the bottom unless the user scrolled up. + useEffect(() => { + const el = preRef.current; + if (el && autoFollow.current) el.scrollTop = el.scrollHeight; + }, [output]); + + const onScroll = () => { + const el = preRef.current; + if (!el) return; + autoFollow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + }; + + const stop = async () => { + if (!id) return; + try { + await client.post(`/jobs/${id}/stop`, {}); + toast.success('Stop requested'); + } catch { + toast.error('Failed to stop'); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + if (!job) { + return ( +
+ Job not found. + Back to jobs +
+ ); + } + + const badge = statusBadge(job.status, job.exitCode); + const running = !isTerminal(job.status); + + return ( +
+
+ + + +
+ {job.taskName} + {job.taskDirName} +
+ + {badge.icon} {badge.label} + + {running && ( + + )} +
+ +
+        {output || (running ? '…' : '(no output)')}
+      
+ + {job.error && !running && ( +
{job.error}
+ )} +
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx index 13076ead..861b6e81 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/index.tsx @@ -1,2 +1,2 @@ export { JobsScreen } from './JobsScreen'; -export { JobDetail } from './JobDetail'; +export { JobDetail } from './JobDetailView';