('/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');