A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add backfill jobs page

Trezy c8f86a63 373f9d8b

+217
+217
web/src/app/(dashboard)/backfill/page.tsx
··· 1 + "use client" 2 + 3 + import { useCallback, useEffect, useState } from "react" 4 + 5 + import { useAuth } from "@/lib/auth-context" 6 + import { 7 + createBackfillJob, 8 + getBackfillJobs, 9 + type BackfillJob, 10 + } from "@/lib/api" 11 + import { SiteHeader } from "@/components/site-header" 12 + import { Badge } from "@/components/ui/badge" 13 + import { Button } from "@/components/ui/button" 14 + import { 15 + Dialog, 16 + DialogClose, 17 + DialogContent, 18 + DialogDescription, 19 + DialogFooter, 20 + DialogHeader, 21 + DialogTitle, 22 + DialogTrigger, 23 + } from "@/components/ui/dialog" 24 + import { Input } from "@/components/ui/input" 25 + import { Label } from "@/components/ui/label" 26 + import { 27 + Table, 28 + TableBody, 29 + TableCell, 30 + TableHead, 31 + TableHeader, 32 + TableRow, 33 + } from "@/components/ui/table" 34 + 35 + function statusVariant(status: string) { 36 + switch (status) { 37 + case "completed": 38 + return "default" as const 39 + case "running": 40 + return "secondary" as const 41 + case "failed": 42 + return "destructive" as const 43 + default: 44 + return "outline" as const 45 + } 46 + } 47 + 48 + export default function BackfillPage() { 49 + const { token } = useAuth() 50 + const [jobs, setJobs] = useState<BackfillJob[]>([]) 51 + const [error, setError] = useState<string | null>(null) 52 + 53 + const load = useCallback(() => { 54 + if (!token) return 55 + getBackfillJobs(token).then(setJobs).catch((e) => setError(e.message)) 56 + }, [token]) 57 + 58 + useEffect(() => { 59 + load() 60 + }, [load]) 61 + 62 + // Auto-refresh every 5 seconds when there are active jobs 63 + useEffect(() => { 64 + const hasActive = jobs.some( 65 + (j) => j.status === "pending" || j.status === "running" 66 + ) 67 + if (!hasActive) return 68 + const interval = setInterval(load, 5000) 69 + return () => clearInterval(interval) 70 + }, [jobs, load]) 71 + 72 + return ( 73 + <> 74 + <SiteHeader title="Backfill" /> 75 + <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 76 + {error && <p className="text-destructive text-sm">{error}</p>} 77 + 78 + <div className="flex items-center justify-between"> 79 + <h2 className="text-lg font-semibold">Backfill Jobs</h2> 80 + <CreateDialog token={token!} onSuccess={load} /> 81 + </div> 82 + 83 + <div className="rounded-lg border"> 84 + <Table> 85 + <TableHeader> 86 + <TableRow> 87 + <TableHead>ID</TableHead> 88 + <TableHead>Collection</TableHead> 89 + <TableHead>DID</TableHead> 90 + <TableHead>Status</TableHead> 91 + <TableHead>Progress</TableHead> 92 + <TableHead>Records</TableHead> 93 + <TableHead>Started</TableHead> 94 + </TableRow> 95 + </TableHeader> 96 + <TableBody> 97 + {jobs.length === 0 && ( 98 + <TableRow> 99 + <TableCell 100 + colSpan={7} 101 + className="text-muted-foreground text-center" 102 + > 103 + No backfill jobs yet. 104 + </TableCell> 105 + </TableRow> 106 + )} 107 + {jobs.map((job) => ( 108 + <TableRow key={job.id}> 109 + <TableCell className="font-mono text-xs"> 110 + {job.id.slice(0, 8)} 111 + </TableCell> 112 + <TableCell className="font-mono text-sm"> 113 + {job.collection ?? "All"} 114 + </TableCell> 115 + <TableCell className="font-mono text-sm"> 116 + {job.did ?? "All"} 117 + </TableCell> 118 + <TableCell> 119 + <Badge variant={statusVariant(job.status)}> 120 + {job.status} 121 + </Badge> 122 + </TableCell> 123 + <TableCell className="tabular-nums"> 124 + {job.processed_repos != null && job.total_repos != null 125 + ? `${job.processed_repos} / ${job.total_repos}` 126 + : "--"} 127 + </TableCell> 128 + <TableCell className="tabular-nums"> 129 + {job.total_records?.toLocaleString() ?? "--"} 130 + </TableCell> 131 + <TableCell> 132 + {job.started_at 133 + ? new Date(job.started_at).toLocaleString() 134 + : "--"} 135 + </TableCell> 136 + </TableRow> 137 + ))} 138 + </TableBody> 139 + </Table> 140 + </div> 141 + </div> 142 + </> 143 + ) 144 + } 145 + 146 + function CreateDialog({ 147 + token, 148 + onSuccess, 149 + }: { 150 + token: string 151 + onSuccess: () => void 152 + }) { 153 + const [collection, setCollection] = useState("") 154 + const [did, setDid] = useState("") 155 + const [error, setError] = useState<string | null>(null) 156 + const [open, setOpen] = useState(false) 157 + 158 + async function handleCreate() { 159 + setError(null) 160 + try { 161 + await createBackfillJob(token, { 162 + collection: collection || undefined, 163 + did: did || undefined, 164 + }) 165 + setCollection("") 166 + setDid("") 167 + setOpen(false) 168 + onSuccess() 169 + } catch (e: unknown) { 170 + setError(e instanceof Error ? e.message : String(e)) 171 + } 172 + } 173 + 174 + return ( 175 + <Dialog open={open} onOpenChange={setOpen}> 176 + <DialogTrigger asChild> 177 + <Button>Create Backfill Job</Button> 178 + </DialogTrigger> 179 + <DialogContent> 180 + <DialogHeader> 181 + <DialogTitle>Create Backfill Job</DialogTitle> 182 + <DialogDescription> 183 + Start a backfill for a collection or specific DID. Leave both empty 184 + to backfill all collections. 185 + </DialogDescription> 186 + </DialogHeader> 187 + <div className="flex flex-col gap-4"> 188 + {error && <p className="text-destructive text-sm">{error}</p>} 189 + <div className="flex flex-col gap-2"> 190 + <Label htmlFor="bf-collection">Collection (optional)</Label> 191 + <Input 192 + id="bf-collection" 193 + value={collection} 194 + onChange={(e) => setCollection(e.target.value)} 195 + placeholder="com.example.record" 196 + /> 197 + </div> 198 + <div className="flex flex-col gap-2"> 199 + <Label htmlFor="bf-did">DID (optional)</Label> 200 + <Input 201 + id="bf-did" 202 + value={did} 203 + onChange={(e) => setDid(e.target.value)} 204 + placeholder="did:plc:..." 205 + /> 206 + </div> 207 + </div> 208 + <DialogFooter> 209 + <DialogClose asChild> 210 + <Button variant="outline">Cancel</Button> 211 + </DialogClose> 212 + <Button onClick={handleCreate}>Create</Button> 213 + </DialogFooter> 214 + </DialogContent> 215 + </Dialog> 216 + ) 217 + }