From f77ce3fb96134a6fa12419cec5a43a7e18c5b410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 23 Jul 2026 15:48:52 +0000 Subject: [PATCH] jobs: header running/queued badges + GET /jobs/counts (phase 3d) Co-Authored-By: Claude Opus 4.8 --- docs/jobs-unification.md | 10 ++--- .../Dashboard/Layout/Header/Header.tsx | 2 + .../Dashboard/Layout/Header/JobsIndicator.tsx | 43 +++++++++++++++++++ src/databases/officer_db/src/index.ts | 1 + .../officer_db/src/queries/pipeline-jobs.ts | 11 ++++- src/servers/api/tasks/pipeline-job-manager.ts | 16 +++++++ src/servers/api/tasks/pipeline-jobs-routes.ts | 7 +++ 7 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx diff --git a/docs/jobs-unification.md b/docs/jobs-unification.md index e4f3780b..b88dd4c9 100644 --- a/docs/jobs-unification.md +++ b/docs/jobs-unification.md @@ -75,7 +75,7 @@ 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 frontend +- [x] 3 frontend — master-detail `/jobs`, modal-as-creator, split list, header badges. Done. - [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel (left, polls `GET /jobs`, highlights active) + a detail panel (right) that branches by `mode` — `ScriptJobDetail` terminal (polls log + status, Stop) or `PipelineJobDetail`. One page serves @@ -87,9 +87,7 @@ Favor power-user affordances over guardrails. See memory `sole-user-assume-compe (queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new` page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the phone creates jobs directly via `POST /jobs`.)* - - [ ] 3d header job indicators — two always-present badges next to the avatar + reload-tasks: - (1) **running** count (0/1) → links to the running job's `/jobs/:id`; - (2) **queued** count → links to the queue (`/jobs`). Backed by a lightweight - `GET /jobs/counts` → `{ running, runningJobId, queued }`, polled while mounted (cheap, avoids - fetching full lists). Badges hide/dim at 0. + - [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton + + UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling + `GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0. - [ ] 4 push notifications diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx index 9c7dc408..5729ee40 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx @@ -6,6 +6,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import type { DockItem } from '../Dock'; import { useIsTouch } from '../useIsTouch'; import { UserMenu } from './UserMenu'; +import { JobsIndicator } from './JobsIndicator'; import { RescanButton } from '../Rescan/RescanButton'; // import { BugReportButton } from '../BugReport/BugReportButton'; @@ -53,6 +54,7 @@ export function Header({ dockItems }: HeaderProps) { */} {/* Bug Report — hidden */} {/* */} + diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx new file mode 100644 index 00000000..181d2090 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router'; +import { Loader2, ListOrdered } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; + +type Counts = { running: number; runningJobId: string | null; queued: number }; + +// Always-present header badges: how many jobs are running (→ the running job) and queued (→ the queue). +export const JobsIndicator = () => { + const client = useClient(); + const [counts, setCounts] = useState({ running: 0, runningJobId: null, queued: 0 }); + + useEffect(() => { + let alive = true; + const load = () => client.get('/jobs/counts').then((c) => alive && setCounts(c)).catch(() => {}); + load(); + const timer = setInterval(load, 3000); + return () => { alive = false; clearInterval(timer); }; + }, []); + + const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors'; + + return ( +
+ + + {counts.running} + + + + {counts.queued} + +
+ ); +}; diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index d59ed752..aa8c4e21 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -78,6 +78,7 @@ export { updatePipelineJob, getPipelineJobsForUser, getOldestPendingJob, + countPendingJobs, markInterruptedJobs, } from './queries/pipeline-jobs'; diff --git a/src/databases/officer_db/src/queries/pipeline-jobs.ts b/src/databases/officer_db/src/queries/pipeline-jobs.ts index bc12c83d..8155e485 100644 --- a/src/databases/officer_db/src/queries/pipeline-jobs.ts +++ b/src/databases/officer_db/src/queries/pipeline-jobs.ts @@ -1,4 +1,4 @@ -import { eq, asc, desc } from 'drizzle-orm'; +import { eq, and, asc, desc } from 'drizzle-orm'; import { db } from '../db'; import { pipelineJobs } from '../schema/pipeline-jobs'; import type { PipelineJobInsert } from '../types'; @@ -26,6 +26,15 @@ export async function getPipelineJobsForUser(userId: number, limit = 50) { .limit(limit); } +// Count of the user's queued (pending) jobs — for the header badge. +export async function countPendingJobs(userId: number) { + const rows = await db + .select({ id: pipelineJobs.id }) + .from(pipelineJobs) + .where(and(eq(pipelineJobs.userId, userId), eq(pipelineJobs.status, 'pending'))); + return rows.length; +} + // Oldest queued job across everything (single-user → global queue). Used to promote the next job. export async function getOldestPendingJob() { const rows = await db diff --git a/src/servers/api/tasks/pipeline-job-manager.ts b/src/servers/api/tasks/pipeline-job-manager.ts index 3bc9d8fa..d8ef4492 100644 --- a/src/servers/api/tasks/pipeline-job-manager.ts +++ b/src/servers/api/tasks/pipeline-job-manager.ts @@ -7,6 +7,7 @@ import { updatePipelineJob, getPipelineJobsForUser, getOldestPendingJob, + countPendingJobs, markInterruptedJobs, getUserById, } from 'officerdb'; @@ -306,6 +307,21 @@ export async function getJobsForUser(userId: number) { })); } +// Lightweight header-badge summary: how many of the user's jobs are running / queued, and which one +// is running (for the "running" badge's link). +export async function getCounts(userId: number): Promise<{ running: number; runningJobId: string | null; queued: number }> { + let running = 0; + let runningJobId: string | null = null; + for (const [id, job] of liveJobs) { + if (job.userId === userId) { + running++; + if (!runningJobId) runningJobId = id; + } + } + const queued = await countPendingJobs(userId); + return { running, runningJobId, queued }; +} + export async function getJob(jobId: string) { const job = await getPipelineJob(jobId); if (!job) return null; diff --git a/src/servers/api/tasks/pipeline-jobs-routes.ts b/src/servers/api/tasks/pipeline-jobs-routes.ts index 6c8a2e95..67b85145 100644 --- a/src/servers/api/tasks/pipeline-jobs-routes.ts +++ b/src/servers/api/tasks/pipeline-jobs-routes.ts @@ -65,6 +65,13 @@ pipelineJobsRouter.post('/', async (c) => { return c.json({ jobId, status }); }); +// GET /counts — header-badge summary { running, runningJobId, queued }. Before /:id so it isn't +// captured as an id. +pipelineJobsRouter.get('/counts', async (c) => { + const user = c.get('user'); + return c.json(await jobManager.getCounts(user.id)); +}); + // GET /:id/log?offset= — tail the persisted output log (script jobs). Returns text from `offset`. pipelineJobsRouter.get('/:id/log', async (c) => { const user = c.get('user');