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
276 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|