jobs: header running/queued badges + GET /jobs/counts (phase 3d)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:48:52 +00:00
co-authored by Claude Opus 4.8
parent c277ceead7
commit f77ce3fb96
7 changed files with 83 additions and 7 deletions
@@ -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 */}
{/* <BugReportButton /> */}
<JobsIndicator />
<RescanButton />
<UserMenu />
</div>
@@ -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<Counts>({ running: 0, runningJobId: null, queued: 0 });
useEffect(() => {
let alive = true;
const load = () => client.get<Counts>('/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 (
<div className="flex items-center gap-1.5">
<Link
to={counts.runningJobId ? `/jobs/${counts.runningJobId}` : '/jobs'}
title={counts.running ? 'Running job' : 'Nothing running'}
className={`${pill} ${counts.running ? 'bg-blue-500/30 text-white hover:bg-blue-500/45' : 'bg-white/10 text-white/50 hover:bg-white/20'}`}
>
<Loader2 className={`h-3.5 w-3.5 ${counts.running ? 'animate-spin' : ''}`} />
{counts.running}
</Link>
<Link
to="/jobs"
title="Queued jobs"
className={`${pill} ${counts.queued ? 'bg-amber-500/30 text-white hover:bg-amber-500/45' : 'bg-white/10 text-white/50 hover:bg-white/20'}`}
>
<ListOrdered className="h-3.5 w-3.5" />
{counts.queued}
</Link>
</div>
);
};
+1
View File
@@ -78,6 +78,7 @@ export {
updatePipelineJob,
getPipelineJobsForUser,
getOldestPendingJob,
countPendingJobs,
markInterruptedJobs,
} from './queries/pipeline-jobs';
@@ -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
@@ -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;
@@ -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');