Compare commits
1 commit
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667b1484f6 |
4 changed files with 239 additions and 0 deletions
42
app/(app)/admin/jobs/page.tsx
Normal file
42
app/(app)/admin/jobs/page.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { requireAdmin } from '@/lib/auth-guard'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import type { ClaudeJobStatus } from '@prisma/client'
|
||||||
|
import { JobsTable, StatusFilter } from '@/components/admin/jobs-table'
|
||||||
|
|
||||||
|
const VALID_STATUSES = new Set<ClaudeJobStatus>([
|
||||||
|
'QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED',
|
||||||
|
])
|
||||||
|
|
||||||
|
export default async function AdminJobsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ status?: string }>
|
||||||
|
}) {
|
||||||
|
await requireAdmin()
|
||||||
|
|
||||||
|
const params = await searchParams
|
||||||
|
const rawStatus = params.status ?? ''
|
||||||
|
const statusFilter = VALID_STATUSES.has(rawStatus as ClaudeJobStatus)
|
||||||
|
? (rawStatus as ClaudeJobStatus)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const jobs = await prisma.claudeJob.findMany({
|
||||||
|
where: statusFilter ? { status: statusFilter } : undefined,
|
||||||
|
include: {
|
||||||
|
user: { select: { username: true } },
|
||||||
|
product: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
take: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">Claude Jobs</h1>
|
||||||
|
<StatusFilter current={rawStatus} />
|
||||||
|
</div>
|
||||||
|
<JobsTable jobs={jobs} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
app/(app)/admin/layout.tsx
Normal file
16
app/(app)/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { requireAdmin } from '@/lib/auth-guard'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
await requireAdmin()
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<nav className="w-48 border-r p-4 flex flex-col gap-2">
|
||||||
|
<Link href="/admin/users" className="text-sm font-medium text-foreground hover:text-primary">Gebruikers</Link>
|
||||||
|
<Link href="/admin/jobs" className="text-sm font-medium text-foreground hover:text-primary">Claude Jobs</Link>
|
||||||
|
<Link href="/admin/products" className="text-sm font-medium text-foreground hover:text-primary">Producten</Link>
|
||||||
|
</nav>
|
||||||
|
<main className="flex-1 p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
app/(app)/admin/page.tsx
Normal file
5
app/(app)/admin/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
redirect('/admin/jobs')
|
||||||
|
}
|
||||||
176
components/admin/jobs-table.tsx
Normal file
176
components/admin/jobs-table.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import type { ClaudeJobStatus } from '@prisma/client'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import { cancelJobAction, deleteJobAction } from '@/actions/admin/jobs'
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES: ClaudeJobStatus[] = ['DONE', 'FAILED', 'CANCELLED']
|
||||||
|
|
||||||
|
const STATUS_CLASS: Record<ClaudeJobStatus, string> = {
|
||||||
|
QUEUED: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||||
|
CLAIMED: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||||
|
RUNNING: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||||
|
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||||
|
FAILED: 'bg-destructive/15 text-destructive border-destructive/30',
|
||||||
|
CANCELLED: 'bg-secondary text-secondary-foreground border-border',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job = {
|
||||||
|
id: string
|
||||||
|
status: ClaudeJobStatus
|
||||||
|
kind: string
|
||||||
|
user: { username: string }
|
||||||
|
product: { name: string }
|
||||||
|
created_at: Date
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_STATUSES: ClaudeJobStatus[] = ['QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED']
|
||||||
|
|
||||||
|
function DeleteDialog({ job }: { job: Job }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [pending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteJobAction(job.id)
|
||||||
|
setOpen(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button variant="destructive" size="sm" />}>
|
||||||
|
Verwijder
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Job verwijderen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Weet je zeker dat je job <strong className="font-mono text-xs">{job.id.slice(-8)}</strong> wilt verwijderen?
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button variant="outline" />}>Annuleer</DialogClose>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||||
|
{pending ? 'Verwijderen…' : 'Verwijderen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CancelButton({ job }: { job: Job }) {
|
||||||
|
const [pending, startTransition] = useTransition()
|
||||||
|
const isTerminal = TERMINAL_STATUSES.includes(job.status)
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await cancelJobAction(job.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={isTerminal || pending}
|
||||||
|
onClick={handleCancel}
|
||||||
|
title={isTerminal ? 'Job is al in eindstatus' : undefined}
|
||||||
|
>
|
||||||
|
{pending ? '…' : 'Annuleer'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusFilter({ current }: { current: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={current}
|
||||||
|
onChange={e => router.push(e.target.value ? `?status=${e.target.value}` : '?')}
|
||||||
|
className="text-sm border border-border rounded-md px-2 py-1 bg-background text-foreground"
|
||||||
|
>
|
||||||
|
<option value="">Alle statussen</option>
|
||||||
|
{ALL_STATUSES.map(s => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JobsTable({ jobs }: { jobs: Job[] }) {
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Kind</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Product</TableHead>
|
||||||
|
<TableHead>Aangemaakt</TableHead>
|
||||||
|
<TableHead>Error</TableHead>
|
||||||
|
<TableHead className="text-right">Acties</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{jobs.map(job => (
|
||||||
|
<TableRow key={job.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={STATUS_CLASS[job.status]}>{job.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{job.kind}</TableCell>
|
||||||
|
<TableCell className="font-medium">{job.user.username}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{job.product.name}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{new Date(job.created_at).toLocaleString('nl-NL')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px]">
|
||||||
|
{job.error ? (
|
||||||
|
<span className="text-xs text-destructive truncate block" title={job.error}>
|
||||||
|
{job.error.slice(0, 80)}{job.error.length > 80 ? '…' : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<CancelButton job={job} />
|
||||||
|
<DeleteDialog job={job} />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{jobs.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||||
|
Geen jobs gevonden.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue