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
This commit is contained in:
275
k-photos-frontend/app/(app)/admin/jobs/page.tsx
Normal file
275
k-photos-frontend/app/(app)/admin/jobs/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user