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:
@@ -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`.
|
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.*
|
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).
|
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
|
- [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`
|
(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
|
— `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`
|
(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
|
page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the
|
||||||
phone creates jobs directly via `POST /jobs`.)*
|
phone creates jobs directly via `POST /jobs`.)*
|
||||||
- [ ] 3d header job indicators — two always-present badges next to the avatar + reload-tasks:
|
- [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton +
|
||||||
(1) **running** count (0/1) → links to the running job's `/jobs/:id`;
|
UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling
|
||||||
(2) **queued** count → links to the queue (`/jobs`). Backed by a lightweight
|
`GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0.
|
||||||
`GET /jobs/counts` → `{ running, runningJobId, queued }`, polled while mounted (cheap, avoids
|
|
||||||
fetching full lists). Badges hide/dim at 0.
|
|
||||||
- [ ] 4 push notifications
|
- [ ] 4 push notifications
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
|
|||||||
import type { DockItem } from '../Dock';
|
import type { DockItem } from '../Dock';
|
||||||
import { useIsTouch } from '../useIsTouch';
|
import { useIsTouch } from '../useIsTouch';
|
||||||
import { UserMenu } from './UserMenu';
|
import { UserMenu } from './UserMenu';
|
||||||
|
import { JobsIndicator } from './JobsIndicator';
|
||||||
import { RescanButton } from '../Rescan/RescanButton';
|
import { RescanButton } from '../Rescan/RescanButton';
|
||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export function Header({ dockItems }: HeaderProps) {
|
|||||||
*/}
|
*/}
|
||||||
{/* Bug Report — hidden */}
|
{/* Bug Report — hidden */}
|
||||||
{/* <BugReportButton /> */}
|
{/* <BugReportButton /> */}
|
||||||
|
<JobsIndicator />
|
||||||
<RescanButton />
|
<RescanButton />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -78,6 +78,7 @@ export {
|
|||||||
updatePipelineJob,
|
updatePipelineJob,
|
||||||
getPipelineJobsForUser,
|
getPipelineJobsForUser,
|
||||||
getOldestPendingJob,
|
getOldestPendingJob,
|
||||||
|
countPendingJobs,
|
||||||
markInterruptedJobs,
|
markInterruptedJobs,
|
||||||
} from './queries/pipeline-jobs';
|
} 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 { db } from '../db';
|
||||||
import { pipelineJobs } from '../schema/pipeline-jobs';
|
import { pipelineJobs } from '../schema/pipeline-jobs';
|
||||||
import type { PipelineJobInsert } from '../types';
|
import type { PipelineJobInsert } from '../types';
|
||||||
@@ -26,6 +26,15 @@ export async function getPipelineJobsForUser(userId: number, limit = 50) {
|
|||||||
.limit(limit);
|
.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.
|
// Oldest queued job across everything (single-user → global queue). Used to promote the next job.
|
||||||
export async function getOldestPendingJob() {
|
export async function getOldestPendingJob() {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
updatePipelineJob,
|
updatePipelineJob,
|
||||||
getPipelineJobsForUser,
|
getPipelineJobsForUser,
|
||||||
getOldestPendingJob,
|
getOldestPendingJob,
|
||||||
|
countPendingJobs,
|
||||||
markInterruptedJobs,
|
markInterruptedJobs,
|
||||||
getUserById,
|
getUserById,
|
||||||
} from 'officerdb';
|
} 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) {
|
export async function getJob(jobId: string) {
|
||||||
const job = await getPipelineJob(jobId);
|
const job = await getPipelineJob(jobId);
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ pipelineJobsRouter.post('/', async (c) => {
|
|||||||
return c.json({ jobId, status });
|
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`.
|
// GET /:id/log?offset= — tail the persisted output log (script jobs). Returns text from `offset`.
|
||||||
pipelineJobsRouter.get('/:id/log', async (c) => {
|
pipelineJobsRouter.get('/:id/log', async (c) => {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
|
|||||||
Reference in New Issue
Block a user