Files
k-photos/k-photos-frontend/app/(app)/admin/jobs/page.tsx
Gabriel Kaszewski 957737ac9b feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
Backend:
- user roles (DB + JWT + first-user-is-admin)
- volume-aware file resolver (multi-volume asset serving)
- directory scanner uses volume URI directly
- date-summary endpoint (capture date from EXIF)
- timeline ordered by capture date
- list endpoints: volumes, plugins, pipelines, library paths
- delete endpoints: volumes, library paths
- configurable upload body limit (MAX_UPLOAD_BYTES)

Frontend:
- auth: login/register, token refresh, role-based admin gate
- timeline: date-grouped grid, infinite scroll, date scrubber
- image viewer: fullscreen zoom/pan/pinch, metadata sidebar
- upload: drag-drop, sequential upload, progress tracking
- albums: create, add/remove photos, asset picker dialog
- admin: storage (import library), jobs (pagination, error details),
  plugins (list + toggle), pipelines, sidecars, duplicates
- multi-select mode with add-to-album action
- TanStack Query for all data fetching
2026-06-01 01:35:43 +02:00

276 lines
10 KiB
TypeScript

"use client"
import { useState } from "react"
import {
useJobs,
useStartJob,
useFailJob,
useCompleteJob,
JOBS_PAGE_SIZE,
} from "@/hooks/use-jobs"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Spinner } from "@/components/ui/spinner"
import { toast } from "sonner"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlayIcon,
CheckIcon,
XIcon,
} from "lucide-react"
const STATUS_FILTERS = [
{ value: undefined, label: "All" },
{ value: "queued", label: "Queued" },
{ value: "running", label: "Running" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
]
function statusVariant(status: string) {
switch (status.toLowerCase()) {
case "queued":
return "secondary" as const
case "running":
return "default" as const
case "completed":
return "default" as const
case "failed":
return "destructive" as const
default:
return "secondary" as const
}
}
export default function JobsPage() {
const [filter, setFilter] = useState<string | undefined>(undefined)
const [offset, setOffset] = useState(0)
const jobs = useJobs(filter, offset)
const startJob = useStartJob()
const failJob = useFailJob()
const completeJob = useCompleteJob()
const total = jobs.data?.total ?? 0
const page = Math.floor(offset / JOBS_PAGE_SIZE) + 1
const totalPages = Math.ceil(total / JOBS_PAGE_SIZE)
const handleFilterChange = (v: string) => {
setFilter(v === "all" ? undefined : v)
setOffset(0)
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Job Queue</h1>
{total > 0 && (
<span className="text-sm text-muted-foreground">{total} total</span>
)}
</div>
<Tabs value={filter ?? "all"} onValueChange={handleFilterChange}>
<TabsList>
{STATUS_FILTERS.map((f) => (
<TabsTrigger key={f.label} value={f.value ?? "all"}>
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Card>
<CardHeader>
<CardTitle>Jobs</CardTitle>
</CardHeader>
<CardContent>
{jobs.isLoading ? (
<Spinner />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-6" />
<TableHead>ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(jobs.data?.jobs ?? []).map((job) => (
<Collapsible key={job.job_id} asChild>
<>
<TableRow>
<TableCell className="p-0 pl-2">
{job.error_message && (
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
>
<ChevronDownIcon className="h-3 w-3 transition-transform [[data-state=open]>&]:rotate-180" />
</Button>
</CollapsibleTrigger>
)}
</TableCell>
<TableCell className="font-mono text-xs">
{job.job_id.slice(0, 8)}...
</TableCell>
<TableCell className="text-sm">
{job.job_type}
</TableCell>
<TableCell>
<Badge variant={statusVariant(job.status)}>
{job.status}
</Badge>
</TableCell>
<TableCell>{job.priority}</TableCell>
<TableCell className="text-xs">
{new Date(job.created_at).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex gap-1">
{job.status.toLowerCase() === "queued" && (
<Button
size="icon"
variant="outline"
className="h-6 w-6"
title="Start"
onClick={async () => {
try {
await startJob.mutateAsync(job.job_id)
toast.success("Job started")
} catch {
toast.error("Failed to start")
}
}}
>
<PlayIcon className="h-3 w-3" />
</Button>
)}
{job.status.toLowerCase() === "running" && (
<>
<Button
size="icon"
variant="outline"
className="h-6 w-6"
title="Complete"
onClick={async () => {
try {
await completeJob.mutateAsync({
jobId: job.job_id,
result: {},
})
toast.success("Job completed")
} catch {
toast.error("Failed")
}
}}
>
<CheckIcon className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="destructive"
className="h-6 w-6"
title="Fail"
onClick={async () => {
try {
await failJob.mutateAsync({
jobId: job.job_id,
error: "Manually failed",
})
toast.success("Job failed")
} catch {
toast.error("Failed")
}
}}
>
<XIcon className="h-3 w-3" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
{job.error_message && (
<CollapsibleContent asChild>
<tr>
<td />
<td colSpan={6} className="pb-3 pt-0">
<pre className="mt-1 max-h-40 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
{job.error_message}
</pre>
</td>
</tr>
</CollapsibleContent>
)}
</>
</Collapsible>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-xs text-muted-foreground">
Page {page} of {totalPages}
</span>
<div className="flex gap-1">
<Button
size="icon"
variant="outline"
className="h-7 w-7"
disabled={offset === 0}
onClick={() =>
setOffset(Math.max(0, offset - JOBS_PAGE_SIZE))
}
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="outline"
className="h-7 w-7"
disabled={offset + JOBS_PAGE_SIZE >= total}
onClick={() => setOffset(offset + JOBS_PAGE_SIZE)}
>
<ChevronRightIcon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
)
}