feat(PBI-59): add Detail/Usage view-switch on /jobs (#152)
Splits het middenpaneel van de jobs-pagina in twee views (zoals admin/jobs):
- **Detail** — alle metadata (status, kind, product, branch, PR, dates,
errors, summary, verify-result) plus een kind-aware beschrijving:
TASK → implementation_plan, IDEA_GRILL → grill_md, IDEA_MAKE_PLAN →
plan_md, PLAN_CHAT → idea.description.
- **Usage** — model, tokens (in/uit/cache/totaal), berekende kosten in
USD via ModelPrice-tabel, en duur (started→finished).
SprintSubTasksPane blijft als sticky header boven beide views.
Server action `fetchJobsPageData` haalt nu ook ModelPrices op en
selecteert task.{description,implementation_plan} +
idea.{description,grill_md,plan_md} zodat de description en costUsd in
JobWithRelations gevuld kunnen worden.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a7e9ca1c35
commit
16f01283ef
4 changed files with 207 additions and 22 deletions
|
|
@ -20,10 +20,12 @@ export type JobWithRelations = {
|
|||
outputTokens: number | null
|
||||
cacheReadTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
costUsd: number | null
|
||||
branch: string | null
|
||||
prUrl: string | null
|
||||
error: string | null
|
||||
summary: string | null
|
||||
description: string | null
|
||||
verifyResult: VerifyResult | null
|
||||
startedAt: Date | null
|
||||
finishedAt: Date | null
|
||||
|
|
@ -32,13 +34,13 @@ export type JobWithRelations = {
|
|||
}
|
||||
|
||||
const JOB_INCLUDE = {
|
||||
task: { select: { code: true, title: true } },
|
||||
idea: { select: { code: true, title: true } },
|
||||
task: { select: { code: true, title: true, description: true, implementation_plan: true } },
|
||||
idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } },
|
||||
product: { select: { name: true } },
|
||||
sprint_run: { include: { sprint: { select: { sprint_goal: true } } } },
|
||||
} as const
|
||||
|
||||
function mapJob(j: {
|
||||
type RawJob = {
|
||||
id: string
|
||||
kind: ClaudeJobKind
|
||||
status: ClaudeJobStatus
|
||||
|
|
@ -56,11 +58,61 @@ function mapJob(j: {
|
|||
finished_at: Date | null
|
||||
created_at: Date
|
||||
sprint_run_id: string | null
|
||||
task: { code: string | null; title: string } | null
|
||||
idea: { code: string | null; title: string } | null
|
||||
task: {
|
||||
code: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
implementation_plan: string | null
|
||||
} | null
|
||||
idea: {
|
||||
code: string | null
|
||||
title: string
|
||||
description: string | null
|
||||
grill_md: string | null
|
||||
plan_md: string | null
|
||||
} | null
|
||||
product: { name: string }
|
||||
sprint_run: { sprint: { sprint_goal: string } } | null
|
||||
}): JobWithRelations {
|
||||
}
|
||||
|
||||
type PriceRow = {
|
||||
model_id: string
|
||||
input_price_per_1m: { toString: () => string }
|
||||
output_price_per_1m: { toString: () => string }
|
||||
cache_read_price_per_1m: { toString: () => string }
|
||||
cache_write_price_per_1m: { toString: () => string }
|
||||
}
|
||||
|
||||
function pickDescription(j: RawJob): string | null {
|
||||
switch (j.kind) {
|
||||
case 'TASK_IMPLEMENTATION':
|
||||
return j.task?.implementation_plan ?? j.task?.description ?? null
|
||||
case 'IDEA_GRILL':
|
||||
return j.idea?.grill_md ?? j.idea?.description ?? null
|
||||
case 'IDEA_MAKE_PLAN':
|
||||
return j.idea?.plan_md ?? j.idea?.description ?? null
|
||||
case 'PLAN_CHAT':
|
||||
return j.idea?.description ?? null
|
||||
case 'SPRINT_IMPLEMENTATION':
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function computeCost(j: RawJob, priceMap: Map<string, PriceRow>): number | null {
|
||||
if (!j.model_id) return null
|
||||
const p = priceMap.get(j.model_id)
|
||||
if (!p || j.input_tokens == null) return null
|
||||
return (
|
||||
((j.input_tokens ?? 0) * Number(p.input_price_per_1m.toString())) / 1_000_000 +
|
||||
((j.output_tokens ?? 0) * Number(p.output_price_per_1m.toString())) / 1_000_000 +
|
||||
((j.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m.toString())) / 1_000_000 +
|
||||
((j.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m.toString())) / 1_000_000
|
||||
)
|
||||
}
|
||||
|
||||
function mapJob(j: RawJob, priceMap: Map<string, PriceRow>): JobWithRelations {
|
||||
return {
|
||||
id: j.id,
|
||||
kind: j.kind,
|
||||
|
|
@ -77,10 +129,12 @@ function mapJob(j: {
|
|||
outputTokens: j.output_tokens,
|
||||
cacheReadTokens: j.cache_read_tokens,
|
||||
cacheWriteTokens: j.cache_write_tokens,
|
||||
costUsd: computeCost(j, priceMap),
|
||||
branch: j.branch,
|
||||
prUrl: j.pr_url,
|
||||
error: j.error,
|
||||
summary: j.summary,
|
||||
description: pickDescription(j),
|
||||
verifyResult: j.verify_result,
|
||||
startedAt: j.started_at,
|
||||
finishedAt: j.finished_at,
|
||||
|
|
@ -93,7 +147,7 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation
|
|||
const session = await getSession()
|
||||
if (!session.userId) return null
|
||||
|
||||
const [active, done] = await Promise.all([
|
||||
const [active, done, prices] = await Promise.all([
|
||||
prisma.claudeJob.findMany({
|
||||
where: { user_id: session.userId, status: { notIn: ['DONE'] } },
|
||||
include: JOB_INCLUDE,
|
||||
|
|
@ -105,10 +159,13 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation
|
|||
orderBy: { finished_at: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
prisma.modelPrice.findMany(),
|
||||
])
|
||||
|
||||
const priceMap = new Map<string, PriceRow>(prices.map((p) => [p.model_id, p as unknown as PriceRow]))
|
||||
|
||||
return {
|
||||
activeJobs: active.map(mapJob),
|
||||
doneJobs: done.map(mapJob),
|
||||
activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)),
|
||||
doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,24 @@ function FieldRow({ label, children }: FieldRowProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function subjectLabel(job: JobWithRelations): { label: string; value: string } | null {
|
||||
switch (job.kind) {
|
||||
case 'TASK_IMPLEMENTATION':
|
||||
if (!job.taskTitle) return null
|
||||
return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle }
|
||||
case 'SPRINT_IMPLEMENTATION':
|
||||
if (!job.sprintGoal) return null
|
||||
return { label: 'Sprint', value: job.sprintGoal }
|
||||
case 'IDEA_GRILL':
|
||||
case 'IDEA_MAKE_PLAN':
|
||||
case 'PLAN_CHAT':
|
||||
if (!job.ideaTitle) return null
|
||||
return { label: 'Idee', value: job.ideaCode ? `${job.ideaCode} ${job.ideaTitle}` : job.ideaTitle }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface JobDetailPaneProps {
|
||||
job: JobWithRelations | null
|
||||
}
|
||||
|
|
@ -33,6 +51,7 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) {
|
|||
}
|
||||
|
||||
const apiStatus = jobStatusToApi(job.status)
|
||||
const subject = subjectLabel(job)
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-4">
|
||||
|
|
@ -43,11 +62,7 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) {
|
|||
</FieldRow>
|
||||
<FieldRow label="Kind">{job.kind}</FieldRow>
|
||||
<FieldRow label="Product">{job.productName}</FieldRow>
|
||||
<FieldRow label="Model">{job.modelId || '—'}</FieldRow>
|
||||
<FieldRow label="Tokens invoer">{job.inputTokens?.toLocaleString() || '—'}</FieldRow>
|
||||
<FieldRow label="Tokens uitvoer">{job.outputTokens?.toLocaleString() || '—'}</FieldRow>
|
||||
<FieldRow label="Cache read">{job.cacheReadTokens?.toLocaleString() || '—'}</FieldRow>
|
||||
<FieldRow label="Cache write">{job.cacheWriteTokens?.toLocaleString() || '—'}</FieldRow>
|
||||
{subject && <FieldRow label={subject.label}>{subject.value}</FieldRow>}
|
||||
<FieldRow label="Branch">
|
||||
<span className="font-mono text-xs break-all">{job.branch || '—'}</span>
|
||||
</FieldRow>
|
||||
|
|
@ -58,12 +73,9 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) {
|
|||
</a>
|
||||
) : '—'}
|
||||
</FieldRow>
|
||||
<FieldRow label="Fout">
|
||||
{job.error ? (
|
||||
<pre className="text-xs text-status-blocked whitespace-pre-wrap break-all max-h-32 overflow-auto bg-status-blocked/5 rounded p-2">
|
||||
{job.error}
|
||||
</pre>
|
||||
) : '—'}
|
||||
<FieldRow label="Verify">{job.verifyResult ?? '—'}</FieldRow>
|
||||
<FieldRow label="Aangemaakt">
|
||||
{new Date(job.createdAt).toLocaleString('nl-NL')}
|
||||
</FieldRow>
|
||||
<FieldRow label="Gestart">
|
||||
{job.startedAt ? new Date(job.startedAt).toLocaleString('nl-NL') : '—'}
|
||||
|
|
@ -71,6 +83,30 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) {
|
|||
<FieldRow label="Klaar">
|
||||
{job.finishedAt ? new Date(job.finishedAt).toLocaleString('nl-NL') : '—'}
|
||||
</FieldRow>
|
||||
<FieldRow label="Fout">
|
||||
{job.error ? (
|
||||
<pre className="text-xs text-status-blocked whitespace-pre-wrap break-all max-h-40 overflow-auto bg-status-blocked/5 rounded p-2">
|
||||
{job.error}
|
||||
</pre>
|
||||
) : '—'}
|
||||
</FieldRow>
|
||||
<FieldRow label="Samenvatting">
|
||||
{job.summary ? (
|
||||
<pre className="text-xs whitespace-pre-wrap break-words max-h-40 overflow-auto bg-muted/40 rounded p-2">
|
||||
{job.summary}
|
||||
</pre>
|
||||
) : '—'}
|
||||
</FieldRow>
|
||||
<div className="pt-3 mt-3 border-t border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Beschrijving</p>
|
||||
{job.description ? (
|
||||
<pre className="text-xs whitespace-pre-wrap break-words bg-muted/40 rounded p-3 font-sans">
|
||||
{job.description}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Geen beschrijving.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
71
components/jobs/job-usage-pane.tsx
Normal file
71
components/jobs/job-usage-pane.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
'use client'
|
||||
|
||||
import type { JobWithRelations } from '@/actions/jobs-page'
|
||||
|
||||
interface FieldRowProps {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function FieldRow({ label, children }: FieldRowProps) {
|
||||
return (
|
||||
<div className="flex gap-2 py-1.5 border-b border-border/50 text-sm">
|
||||
<span className="w-32 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="flex-1 min-w-0 font-mono text-xs">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatNumber(n: number | null | undefined): string {
|
||||
return n != null ? n.toLocaleString('nl-NL') : '—'
|
||||
}
|
||||
|
||||
function formatDuration(start: Date | null, end: Date | null): string {
|
||||
if (!start) return '—'
|
||||
const endTime = end ? new Date(end).getTime() : Date.now()
|
||||
const ms = endTime - new Date(start).getTime()
|
||||
if (ms < 0) return '—'
|
||||
const sec = Math.floor(ms / 1000)
|
||||
if (sec < 60) return `${sec}s`
|
||||
const min = Math.floor(sec / 60)
|
||||
const remSec = sec % 60
|
||||
if (min < 60) return `${min}m ${remSec}s`
|
||||
const hr = Math.floor(min / 60)
|
||||
const remMin = min % 60
|
||||
return `${hr}u ${remMin}m`
|
||||
}
|
||||
|
||||
interface JobUsagePaneProps {
|
||||
job: JobWithRelations | null
|
||||
}
|
||||
|
||||
export default function JobUsagePane({ job }: JobUsagePaneProps) {
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Selecteer een job om gebruik te zien
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalTokens =
|
||||
(job.inputTokens ?? 0) +
|
||||
(job.outputTokens ?? 0) +
|
||||
(job.cacheReadTokens ?? 0) +
|
||||
(job.cacheWriteTokens ?? 0)
|
||||
|
||||
const costLabel = job.costUsd != null ? `$${job.costUsd.toFixed(4)}` : '—'
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full p-4">
|
||||
<FieldRow label="Model">{job.modelId ?? '—'}</FieldRow>
|
||||
<FieldRow label="Tokens invoer">{formatNumber(job.inputTokens)}</FieldRow>
|
||||
<FieldRow label="Tokens uitvoer">{formatNumber(job.outputTokens)}</FieldRow>
|
||||
<FieldRow label="Cache read">{formatNumber(job.cacheReadTokens)}</FieldRow>
|
||||
<FieldRow label="Cache write">{formatNumber(job.cacheWriteTokens)}</FieldRow>
|
||||
<FieldRow label="Tokens totaal">{formatNumber(totalTokens || null)}</FieldRow>
|
||||
<FieldRow label="Kosten (USD)">{costLabel}</FieldRow>
|
||||
<FieldRow label="Duur">{formatDuration(job.startedAt, job.finishedAt)}</FieldRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||
import JobCard from './job-card'
|
||||
import JobDetailPane from './job-detail-pane'
|
||||
import JobUsagePane from './job-usage-pane'
|
||||
import SprintSubTasksPane from './sprint-sub-tasks-pane'
|
||||
import { useJobsStore } from '@/stores/jobs-store'
|
||||
import useJobsRealtime from '@/hooks/use-jobs-realtime'
|
||||
|
|
@ -14,6 +16,8 @@ interface JobsBoardProps {
|
|||
initialDoneJobs: JobWithRelations[]
|
||||
}
|
||||
|
||||
type View = 'detail' | 'usage'
|
||||
|
||||
function jobToCardProps(j: JobWithRelations) {
|
||||
return {
|
||||
id: j.id,
|
||||
|
|
@ -34,6 +38,7 @@ function jobToCardProps(j: JobWithRelations) {
|
|||
|
||||
export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) {
|
||||
const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore()
|
||||
const [view, setView] = useState<View>('detail')
|
||||
useJobsRealtime()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -63,8 +68,24 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo
|
|||
jobId={selectedJobId}
|
||||
isSprintJob={selectedJob?.kind === 'SPRINT_IMPLEMENTATION'}
|
||||
/>
|
||||
<div className="flex gap-1 px-3 pt-3 pb-2 border-b shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === 'detail' ? 'default' : 'outline'}
|
||||
onClick={() => setView('detail')}
|
||||
>
|
||||
Detail
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === 'usage' ? 'default' : 'outline'}
|
||||
onClick={() => setView('usage')}
|
||||
>
|
||||
Usage
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<JobDetailPane job={selectedJob} />
|
||||
{view === 'detail' ? <JobDetailPane job={selectedJob} /> : <JobUsagePane job={selectedJob} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue