diff --git a/src/admin/lexicons.rs b/src/admin/lexicons.rs index c7f28e5..0a09b6a 100644 --- a/src/admin/lexicons.rs +++ b/src/admin/lexicons.rs @@ -117,9 +117,9 @@ pub(super) async fn list_lexicons( _admin: AdminAuth, ) -> Result>, AppError> { #[allow(clippy::type_complexity)] - let rows: Vec<(String, i32, Value, bool, Option, chrono::DateTime, chrono::DateTime)> = + let rows: Vec<(String, i32, Value, bool, Option, Option, chrono::DateTime, chrono::DateTime)> = sqlx::query_as( - "SELECT id, revision, lexicon_json, backfill, action, created_at, updated_at FROM lexicons ORDER BY id", + "SELECT id, revision, lexicon_json, backfill, action, target_collection, created_at, updated_at FROM lexicons ORDER BY id", ) .fetch_all(&state.db) .await @@ -128,7 +128,7 @@ pub(super) async fn list_lexicons( let summaries: Vec = rows .into_iter() .map( - |(id, revision, json, backfill, action, created_at, updated_at)| { + |(id, revision, json, backfill, action, target_collection, created_at, updated_at)| { let lexicon_type = ParsedLexicon::parse(json, revision, None, ProcedureAction::Upsert) .map(|p| format!("{:?}", p.lexicon_type).to_lowercase()) @@ -140,6 +140,7 @@ pub(super) async fn list_lexicons( lexicon_type, backfill, action, + target_collection, created_at, updated_at, } diff --git a/src/admin/types.rs b/src/admin/types.rs index f57b5ae..6419c9f 100644 --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -12,6 +12,7 @@ pub(super) struct LexiconSummary { pub(super) lexicon_type: String, pub(super) backfill: bool, pub(super) action: Option, + pub(super) target_collection: Option, pub(super) created_at: chrono::DateTime, pub(super) updated_at: chrono::DateTime, } diff --git a/web/src/app/(dashboard)/records/page.tsx b/web/src/app/(dashboard)/records/page.tsx new file mode 100644 index 0000000..a50a99a --- /dev/null +++ b/web/src/app/(dashboard)/records/page.tsx @@ -0,0 +1,244 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" + +import { useAuth } from "@/lib/auth-context" +import { + getLexicons, + xrpcQuery, + type LexiconSummary, +} from "@/lib/api" +import { SiteHeader } from "@/components/site-header" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface XrpcRecord { + uri: string + [key: string]: unknown +} + +interface XrpcListResponse { + records: XrpcRecord[] + cursor?: string +} + +function parseAtUri(uri: string): { did: string; rkey: string } { + // at://did:plc:xxx/collection/rkey + const parts = uri.replace("at://", "").split("/") + return { did: parts[0] ?? "", rkey: parts[2] ?? "" } +} + +function truncateJson(record: XrpcRecord, maxLen = 120): string { + const { uri, ...rest } = record + const str = JSON.stringify(rest) + return str.length > maxLen ? str.slice(0, maxLen) + "..." : str +} + +export default function RecordsPage() { + const { getToken } = useAuth() + const [queryLexicons, setQueryLexicons] = useState([]) + const [selectedMethod, setSelectedMethod] = useState("") + const [records, setRecords] = useState([]) + const [cursorStack, setCursorStack] = useState([]) + const [nextCursor, setNextCursor] = useState() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [viewRecord, setViewRecord] = useState(null) + + // Load query-type lexicons for the collection selector + useEffect(() => { + getLexicons(getToken) + .then((lexicons) => + setQueryLexicons(lexicons.filter((l) => l.lexicon_type === "query")) + ) + .catch((e) => setError(e.message)) + }, [getToken]) + + const fetchRecords = useCallback( + async (method: string, cursor?: string) => { + setLoading(true) + setError(null) + try { + const params: Record = { limit: "20" } + if (cursor) params.cursor = cursor + const data = await xrpcQuery(method, params) + setRecords(data.records) + setNextCursor(data.cursor) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + setRecords([]) + setNextCursor(undefined) + } finally { + setLoading(false) + } + }, + [] + ) + + function handleSelectCollection(method: string) { + setSelectedMethod(method) + setCursorStack([]) + setNextCursor(undefined) + fetchRecords(method) + } + + function handleNext() { + if (!nextCursor || !selectedMethod) return + setCursorStack((prev) => [...prev, nextCursor]) + fetchRecords(selectedMethod, nextCursor) + } + + function handlePrevious() { + if (cursorStack.length === 0 || !selectedMethod) return + const stack = [...cursorStack] + stack.pop() // remove current page's cursor + const prevCursor = stack.length > 0 ? stack[stack.length - 1] : undefined + setCursorStack(stack) + fetchRecords(selectedMethod, prevCursor) + } + + // Find the selected lexicon to show its target_collection label + const selectedLexicon = queryLexicons.find((l) => l.id === selectedMethod) + + return ( + <> + +
+ {error &&

{error}

} + +
+ + {selectedLexicon && ( + + via {selectedLexicon.id} + + )} +
+ + {selectedMethod && ( + <> +
+ + + + DID + Rkey + Record + + + + {loading && ( + + + Loading... + + + )} + {!loading && records.length === 0 && ( + + + No records found. + + + )} + {!loading && + records.map((record) => { + const { did, rkey } = parseAtUri(record.uri) + return ( + setViewRecord(record)} + > + + {did} + + + {rkey} + + + {truncateJson(record)} + + + ) + })} + +
+
+ +
+ + +
+ + )} + + {viewRecord && ( + setViewRecord(null)}> + + + + {viewRecord.uri} + + +
+                {JSON.stringify(viewRecord, null, 2)}
+              
+
+
+ )} +
+ + ) +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 3bb4c57..c537eda 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -5,6 +5,7 @@ import { IconFileDescription, IconWorld, IconDatabase, + IconTable, IconUsers, IconLogout, } from "@tabler/icons-react" @@ -29,6 +30,7 @@ const navItems = [ { title: "Lexicons", url: "/lexicons", icon: IconFileDescription }, { title: "Network Lexicons", url: "/network-lexicons", icon: IconWorld }, { title: "Backfill", url: "/backfill", icon: IconDatabase }, + { title: "Records", url: "/records", icon: IconTable }, { title: "Admins", url: "/admins", icon: IconUsers }, ] diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6991cbc..d80510a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -88,6 +88,7 @@ export interface LexiconSummary { lexicon_type: string backfill: boolean action: string | null + target_collection: string | null created_at: string updated_at: string } @@ -219,3 +220,17 @@ export function deleteAdmin(getToken: () => Promise, id: string) method: "DELETE", }) } + +// XRPC (public, no auth needed) +export async function xrpcQuery( + method: string, + params?: Record +): Promise { + const search = params ? `?${new URLSearchParams(params)}` : "" + const res = await fetch(`/xrpc/${encodeURIComponent(method)}${search}`) + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new ApiError(res.status, text) + } + return res.json() +}