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 records browsing page to admin dashboard

Trezy b9279d4e f929a9cd

+266 -3
+4 -3
src/admin/lexicons.rs
··· 117 117 _admin: AdminAuth, 118 118 ) -> Result<Json<Vec<LexiconSummary>>, AppError> { 119 119 #[allow(clippy::type_complexity)] 120 - let rows: Vec<(String, i32, Value, bool, Option<String>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 120 + let rows: Vec<(String, i32, Value, bool, Option<String>, Option<String>, chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> = 121 121 sqlx::query_as( 122 - "SELECT id, revision, lexicon_json, backfill, action, created_at, updated_at FROM lexicons ORDER BY id", 122 + "SELECT id, revision, lexicon_json, backfill, action, target_collection, created_at, updated_at FROM lexicons ORDER BY id", 123 123 ) 124 124 .fetch_all(&state.db) 125 125 .await ··· 128 128 let summaries: Vec<LexiconSummary> = rows 129 129 .into_iter() 130 130 .map( 131 - |(id, revision, json, backfill, action, created_at, updated_at)| { 131 + |(id, revision, json, backfill, action, target_collection, created_at, updated_at)| { 132 132 let lexicon_type = 133 133 ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert) 134 134 .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) ··· 140 140 lexicon_type, 141 141 backfill, 142 142 action, 143 + target_collection, 143 144 created_at, 144 145 updated_at, 145 146 }
+1
src/admin/types.rs
··· 12 12 pub(super) lexicon_type: String, 13 13 pub(super) backfill: bool, 14 14 pub(super) action: Option<String>, 15 + pub(super) target_collection: Option<String>, 15 16 pub(super) created_at: chrono::DateTime<chrono::Utc>, 16 17 pub(super) updated_at: chrono::DateTime<chrono::Utc>, 17 18 }
+244
web/src/app/(dashboard)/records/page.tsx
··· 1 + "use client" 2 + 3 + import { useCallback, useEffect, useState } from "react" 4 + 5 + import { useAuth } from "@/lib/auth-context" 6 + import { 7 + getLexicons, 8 + xrpcQuery, 9 + type LexiconSummary, 10 + } from "@/lib/api" 11 + import { SiteHeader } from "@/components/site-header" 12 + import { Button } from "@/components/ui/button" 13 + import { 14 + Dialog, 15 + DialogContent, 16 + DialogHeader, 17 + DialogTitle, 18 + } from "@/components/ui/dialog" 19 + import { 20 + Select, 21 + SelectContent, 22 + SelectItem, 23 + SelectTrigger, 24 + SelectValue, 25 + } from "@/components/ui/select" 26 + import { 27 + Table, 28 + TableBody, 29 + TableCell, 30 + TableHead, 31 + TableHeader, 32 + TableRow, 33 + } from "@/components/ui/table" 34 + 35 + interface XrpcRecord { 36 + uri: string 37 + [key: string]: unknown 38 + } 39 + 40 + interface XrpcListResponse { 41 + records: XrpcRecord[] 42 + cursor?: string 43 + } 44 + 45 + function parseAtUri(uri: string): { did: string; rkey: string } { 46 + // at://did:plc:xxx/collection/rkey 47 + const parts = uri.replace("at://", "").split("/") 48 + return { did: parts[0] ?? "", rkey: parts[2] ?? "" } 49 + } 50 + 51 + function truncateJson(record: XrpcRecord, maxLen = 120): string { 52 + const { uri, ...rest } = record 53 + const str = JSON.stringify(rest) 54 + return str.length > maxLen ? str.slice(0, maxLen) + "..." : str 55 + } 56 + 57 + export default function RecordsPage() { 58 + const { getToken } = useAuth() 59 + const [queryLexicons, setQueryLexicons] = useState<LexiconSummary[]>([]) 60 + const [selectedMethod, setSelectedMethod] = useState<string>("") 61 + const [records, setRecords] = useState<XrpcRecord[]>([]) 62 + const [cursorStack, setCursorStack] = useState<string[]>([]) 63 + const [nextCursor, setNextCursor] = useState<string | undefined>() 64 + const [loading, setLoading] = useState(false) 65 + const [error, setError] = useState<string | null>(null) 66 + const [viewRecord, setViewRecord] = useState<XrpcRecord | null>(null) 67 + 68 + // Load query-type lexicons for the collection selector 69 + useEffect(() => { 70 + getLexicons(getToken) 71 + .then((lexicons) => 72 + setQueryLexicons(lexicons.filter((l) => l.lexicon_type === "query")) 73 + ) 74 + .catch((e) => setError(e.message)) 75 + }, [getToken]) 76 + 77 + const fetchRecords = useCallback( 78 + async (method: string, cursor?: string) => { 79 + setLoading(true) 80 + setError(null) 81 + try { 82 + const params: Record<string, string> = { limit: "20" } 83 + if (cursor) params.cursor = cursor 84 + const data = await xrpcQuery<XrpcListResponse>(method, params) 85 + setRecords(data.records) 86 + setNextCursor(data.cursor) 87 + } catch (e: unknown) { 88 + setError(e instanceof Error ? e.message : String(e)) 89 + setRecords([]) 90 + setNextCursor(undefined) 91 + } finally { 92 + setLoading(false) 93 + } 94 + }, 95 + [] 96 + ) 97 + 98 + function handleSelectCollection(method: string) { 99 + setSelectedMethod(method) 100 + setCursorStack([]) 101 + setNextCursor(undefined) 102 + fetchRecords(method) 103 + } 104 + 105 + function handleNext() { 106 + if (!nextCursor || !selectedMethod) return 107 + setCursorStack((prev) => [...prev, nextCursor]) 108 + fetchRecords(selectedMethod, nextCursor) 109 + } 110 + 111 + function handlePrevious() { 112 + if (cursorStack.length === 0 || !selectedMethod) return 113 + const stack = [...cursorStack] 114 + stack.pop() // remove current page's cursor 115 + const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined 116 + setCursorStack(stack) 117 + fetchRecords(selectedMethod, prevCursor) 118 + } 119 + 120 + // Find the selected lexicon to show its target_collection label 121 + const selectedLexicon = queryLexicons.find((l) => l.id === selectedMethod) 122 + 123 + return ( 124 + <> 125 + <SiteHeader title="Records" /> 126 + <div className="flex flex-1 flex-col gap-4 p-4 md:p-6"> 127 + {error && <p className="text-destructive text-sm">{error}</p>} 128 + 129 + <div className="flex items-center gap-4"> 130 + <Select value={selectedMethod} onValueChange={handleSelectCollection}> 131 + <SelectTrigger className="w-80"> 132 + <SelectValue placeholder="Select a collection" /> 133 + </SelectTrigger> 134 + <SelectContent> 135 + {queryLexicons.map((lex) => ( 136 + <SelectItem key={lex.id} value={lex.id}> 137 + {lex.target_collection ?? lex.id} 138 + </SelectItem> 139 + ))} 140 + </SelectContent> 141 + </Select> 142 + {selectedLexicon && ( 143 + <span className="text-muted-foreground text-sm"> 144 + via {selectedLexicon.id} 145 + </span> 146 + )} 147 + </div> 148 + 149 + {selectedMethod && ( 150 + <> 151 + <div className="rounded-lg border"> 152 + <Table> 153 + <TableHeader> 154 + <TableRow> 155 + <TableHead>DID</TableHead> 156 + <TableHead>Rkey</TableHead> 157 + <TableHead>Record</TableHead> 158 + </TableRow> 159 + </TableHeader> 160 + <TableBody> 161 + {loading && ( 162 + <TableRow> 163 + <TableCell 164 + colSpan={3} 165 + className="text-muted-foreground text-center" 166 + > 167 + Loading... 168 + </TableCell> 169 + </TableRow> 170 + )} 171 + {!loading && records.length === 0 && ( 172 + <TableRow> 173 + <TableCell 174 + colSpan={3} 175 + className="text-muted-foreground text-center" 176 + > 177 + No records found. 178 + </TableCell> 179 + </TableRow> 180 + )} 181 + {!loading && 182 + records.map((record) => { 183 + const { did, rkey } = parseAtUri(record.uri) 184 + return ( 185 + <TableRow 186 + key={record.uri} 187 + className="cursor-pointer" 188 + onClick={() => setViewRecord(record)} 189 + > 190 + <TableCell className="font-mono text-xs"> 191 + {did} 192 + </TableCell> 193 + <TableCell className="font-mono text-xs"> 194 + {rkey} 195 + </TableCell> 196 + <TableCell className="max-w-md truncate font-mono text-xs"> 197 + {truncateJson(record)} 198 + </TableCell> 199 + </TableRow> 200 + ) 201 + })} 202 + </TableBody> 203 + </Table> 204 + </div> 205 + 206 + <div className="flex items-center justify-end gap-2"> 207 + <Button 208 + variant="outline" 209 + size="sm" 210 + disabled={cursorStack.length === 0} 211 + onClick={handlePrevious} 212 + > 213 + Previous 214 + </Button> 215 + <Button 216 + variant="outline" 217 + size="sm" 218 + disabled={!nextCursor} 219 + onClick={handleNext} 220 + > 221 + Next 222 + </Button> 223 + </div> 224 + </> 225 + )} 226 + 227 + {viewRecord && ( 228 + <Dialog open onOpenChange={() => setViewRecord(null)}> 229 + <DialogContent className="max-w-2xl"> 230 + <DialogHeader> 231 + <DialogTitle className="font-mono text-sm"> 232 + {viewRecord.uri} 233 + </DialogTitle> 234 + </DialogHeader> 235 + <pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs"> 236 + {JSON.stringify(viewRecord, null, 2)} 237 + </pre> 238 + </DialogContent> 239 + </Dialog> 240 + )} 241 + </div> 242 + </> 243 + ) 244 + }
+2
web/src/components/app-sidebar.tsx
··· 5 5 IconFileDescription, 6 6 IconWorld, 7 7 IconDatabase, 8 + IconTable, 8 9 IconUsers, 9 10 IconLogout, 10 11 } from "@tabler/icons-react" ··· 29 30 { title: "Lexicons", url: "/lexicons", icon: IconFileDescription }, 30 31 { title: "Network Lexicons", url: "/network-lexicons", icon: IconWorld }, 31 32 { title: "Backfill", url: "/backfill", icon: IconDatabase }, 33 + { title: "Records", url: "/records", icon: IconTable }, 32 34 { title: "Admins", url: "/admins", icon: IconUsers }, 33 35 ] 34 36
+15
web/src/lib/api.ts
··· 88 88 lexicon_type: string 89 89 backfill: boolean 90 90 action: string | null 91 + target_collection: string | null 91 92 created_at: string 92 93 updated_at: string 93 94 } ··· 219 220 method: "DELETE", 220 221 }) 221 222 } 223 + 224 + // XRPC (public, no auth needed) 225 + export async function xrpcQuery<T = unknown>( 226 + method: string, 227 + params?: Record<string, string> 228 + ): Promise<T> { 229 + const search = params ? `?${new URLSearchParams(params)}` : "" 230 + const res = await fetch(`/xrpc/${encodeURIComponent(method)}${search}`) 231 + if (!res.ok) { 232 + const text = await res.text().catch(() => res.statusText) 233 + throw new ApiError(res.status, text) 234 + } 235 + return res.json() 236 + }