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.

fix: records page now properly lists collections in dropdown

Trezy 1c7d6208 e68ad796

+126 -50
+2
src/admin/mod.rs
··· 3 3 mod backfill; 4 4 mod lexicons; 5 5 mod network_lexicons; 6 + mod records; 6 7 mod stats; 7 8 mod types; 8 9 ··· 29 30 post(admins::create_admin).get(admins::list_admins), 30 31 ) 31 32 .route("/admins/{id}", delete(admins::delete_admin)) 33 + .route("/records", get(records::list_records)) 32 34 .route( 33 35 "/network-lexicons", 34 36 post(network_lexicons::add).get(network_lexicons::list),
+69
src/admin/records.rs
··· 1 + use axum::Json; 2 + use axum::extract::{Query, State}; 3 + use serde::{Deserialize, Serialize}; 4 + use serde_json::Value; 5 + 6 + use crate::AppState; 7 + use crate::error::AppError; 8 + 9 + use super::auth::AdminAuth; 10 + 11 + #[derive(Deserialize)] 12 + pub(super) struct ListRecordsParams { 13 + pub collection: String, 14 + pub limit: Option<i64>, 15 + pub cursor: Option<String>, 16 + } 17 + 18 + #[derive(Serialize)] 19 + pub(super) struct RecordEntry { 20 + pub uri: String, 21 + pub did: String, 22 + pub record: Value, 23 + } 24 + 25 + #[derive(Serialize)] 26 + pub(super) struct ListRecordsResponse { 27 + pub records: Vec<RecordEntry>, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub cursor: Option<String>, 30 + } 31 + 32 + /// GET /admin/records?collection=X&limit=N&cursor=C — browse records by collection. 33 + pub(super) async fn list_records( 34 + State(state): State<AppState>, 35 + _admin: AdminAuth, 36 + Query(params): Query<ListRecordsParams>, 37 + ) -> Result<Json<ListRecordsResponse>, AppError> { 38 + let limit = params.limit.unwrap_or(20).min(100); 39 + let offset: i64 = params 40 + .cursor 41 + .as_deref() 42 + .and_then(|c| c.parse().ok()) 43 + .unwrap_or(0); 44 + 45 + let rows: Vec<(String, String, Value)> = sqlx::query_as( 46 + "SELECT uri, did, record FROM records WHERE collection = $1 ORDER BY indexed_at DESC LIMIT $2 OFFSET $3", 47 + ) 48 + .bind(&params.collection) 49 + .bind(limit + 1) 50 + .bind(offset) 51 + .fetch_all(&state.db) 52 + .await 53 + .map_err(|e| AppError::Internal(format!("failed to list records: {e}")))?; 54 + 55 + let has_more = rows.len() as i64 > limit; 56 + let records: Vec<RecordEntry> = rows 57 + .into_iter() 58 + .take(limit as usize) 59 + .map(|(uri, did, record)| RecordEntry { uri, did, record }) 60 + .collect(); 61 + 62 + let cursor = if has_more { 63 + Some((offset + limit).to_string()) 64 + } else { 65 + None 66 + }; 67 + 68 + Ok(Json(ListRecordsResponse { records, cursor })) 69 + }
+28 -50
web/src/app/(dashboard)/records/page.tsx
··· 4 4 5 5 import { useAuth } from "@/lib/auth-context" 6 6 import { 7 - getLexicons, 8 - xrpcQuery, 9 - type LexiconSummary, 7 + getStats, 8 + getAdminRecords, 9 + type CollectionStat, 10 + type AdminRecord, 10 11 } from "@/lib/api" 11 12 import { SiteHeader } from "@/components/site-header" 12 13 import { Button } from "@/components/ui/button" ··· 32 33 TableRow, 33 34 } from "@/components/ui/table" 34 35 35 - interface XrpcRecord { 36 - uri: string 37 - [key: string]: unknown 38 - } 39 - 40 - interface XrpcListResponse { 41 - records: XrpcRecord[] 42 - cursor?: string 43 - } 44 - 45 36 function parseAtUri(uri: string): { did: string; rkey: string } { 46 37 // at://did:plc:xxx/collection/rkey 47 38 const parts = uri.replace("at://", "").split("/") 48 39 return { did: parts[0] ?? "", rkey: parts[2] ?? "" } 49 40 } 50 41 51 - function truncateJson(record: XrpcRecord, maxLen = 120): string { 52 - const { uri, ...rest } = record 53 - const str = JSON.stringify(rest) 42 + function truncateJson(record: AdminRecord, maxLen = 120): string { 43 + const str = JSON.stringify(record.record) 54 44 return str.length > maxLen ? str.slice(0, maxLen) + "..." : str 55 45 } 56 46 57 47 export default function RecordsPage() { 58 48 const { getToken } = useAuth() 59 - const [queryLexicons, setQueryLexicons] = useState<LexiconSummary[]>([]) 60 - const [selectedMethod, setSelectedMethod] = useState<string>("") 61 - const [records, setRecords] = useState<XrpcRecord[]>([]) 49 + const [collections, setCollections] = useState<CollectionStat[]>([]) 50 + const [selectedCollection, setSelectedCollection] = useState<string>("") 51 + const [records, setRecords] = useState<AdminRecord[]>([]) 62 52 const [cursorStack, setCursorStack] = useState<string[]>([]) 63 53 const [nextCursor, setNextCursor] = useState<string | undefined>() 64 54 const [loading, setLoading] = useState(false) 65 55 const [error, setError] = useState<string | null>(null) 66 - const [viewRecord, setViewRecord] = useState<XrpcRecord | null>(null) 56 + const [viewRecord, setViewRecord] = useState<AdminRecord | null>(null) 67 57 68 - // Load query-type lexicons for the collection selector 58 + // Load collections from stats 69 59 useEffect(() => { 70 - getLexicons(getToken) 71 - .then((lexicons) => 72 - setQueryLexicons(lexicons.filter((l) => l.lexicon_type === "query")) 73 - ) 60 + getStats(getToken) 61 + .then((stats) => setCollections(stats.collections)) 74 62 .catch((e) => setError(e.message)) 75 63 }, [getToken]) 76 64 77 65 const fetchRecords = useCallback( 78 - async (method: string, cursor?: string) => { 66 + async (collection: string, cursor?: string) => { 79 67 setLoading(true) 80 68 setError(null) 81 69 try { 82 - const params: Record<string, string> = { limit: "20" } 83 - if (cursor) params.cursor = cursor 84 - const data = await xrpcQuery<XrpcListResponse>(method, params) 70 + const data = await getAdminRecords(getToken, collection, 20, cursor) 85 71 setRecords(data.records) 86 72 setNextCursor(data.cursor) 87 73 } catch (e: unknown) { ··· 92 78 setLoading(false) 93 79 } 94 80 }, 95 - [] 81 + [getToken] 96 82 ) 97 83 98 - function handleSelectCollection(method: string) { 99 - setSelectedMethod(method) 84 + function handleSelectCollection(collection: string) { 85 + setSelectedCollection(collection) 100 86 setCursorStack([]) 101 87 setNextCursor(undefined) 102 - fetchRecords(method) 88 + fetchRecords(collection) 103 89 } 104 90 105 91 function handleNext() { 106 - if (!nextCursor || !selectedMethod) return 92 + if (!nextCursor || !selectedCollection) return 107 93 setCursorStack((prev) => [...prev, nextCursor]) 108 - fetchRecords(selectedMethod, nextCursor) 94 + fetchRecords(selectedCollection, nextCursor) 109 95 } 110 96 111 97 function handlePrevious() { 112 - if (cursorStack.length === 0 || !selectedMethod) return 98 + if (cursorStack.length === 0 || !selectedCollection) return 113 99 const stack = [...cursorStack] 114 100 stack.pop() // remove current page's cursor 115 101 const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined 116 102 setCursorStack(stack) 117 - fetchRecords(selectedMethod, prevCursor) 103 + fetchRecords(selectedCollection, prevCursor) 118 104 } 119 - 120 - // Find the selected lexicon to show its target_collection label 121 - const selectedLexicon = queryLexicons.find((l) => l.id === selectedMethod) 122 105 123 106 return ( 124 107 <> ··· 127 110 {error && <p className="text-destructive text-sm">{error}</p>} 128 111 129 112 <div className="flex items-center gap-4"> 130 - <Select value={selectedMethod} onValueChange={handleSelectCollection}> 113 + <Select value={selectedCollection} onValueChange={handleSelectCollection}> 131 114 <SelectTrigger className="w-80"> 132 115 <SelectValue placeholder="Select a collection" /> 133 116 </SelectTrigger> 134 117 <SelectContent> 135 - {queryLexicons.map((lex) => ( 136 - <SelectItem key={lex.id} value={lex.id}> 137 - {lex.target_collection ?? lex.id} 118 + {collections.map((col) => ( 119 + <SelectItem key={col.collection} value={col.collection}> 120 + {col.collection} ({col.count.toLocaleString()}) 138 121 </SelectItem> 139 122 ))} 140 123 </SelectContent> 141 124 </Select> 142 - {selectedLexicon && ( 143 - <span className="text-muted-foreground text-sm"> 144 - via {selectedLexicon.id} 145 - </span> 146 - )} 147 125 </div> 148 126 149 - {selectedMethod && ( 127 + {selectedCollection && ( 150 128 <> 151 129 <div className="rounded-lg border"> 152 130 <Table>
+27
web/src/lib/api.ts
··· 235 235 } 236 236 return res.json() 237 237 } 238 + 239 + // Admin records browsing 240 + export interface AdminRecord { 241 + uri: string 242 + did: string 243 + record: Record<string, unknown> 244 + } 245 + 246 + export interface AdminListRecordsResponse { 247 + records: AdminRecord[] 248 + cursor?: string 249 + } 250 + 251 + export function getAdminRecords( 252 + getToken: () => Promise<string | null>, 253 + collection: string, 254 + limit?: number, 255 + cursor?: string 256 + ) { 257 + const params = new URLSearchParams({ collection }) 258 + if (limit) params.set("limit", String(limit)) 259 + if (cursor) params.set("cursor", cursor) 260 + return apiFetch<AdminListRecordsResponse>( 261 + `/admin/records?${params}`, 262 + getToken 263 + ) 264 + }