Highly ambitious ATProtocol AppView service and sdks

update slice view to include database stats, udpate slice card to show stats

+135 -49
+7 -1
frontend/src/client.ts
··· 1 1 // Generated TypeScript client for AT Protocol records 2 - // Generated at: 2025-09-15 21:45:37 UTC 2 + // Generated at: 2025-09-16 01:17:57 UTC 3 3 // Lexicons: 9 4 4 5 5 /** ··· 295 295 createdAt: string; 296 296 /** Recent activity sparkline data points for the last 24 hours */ 297 297 sparkline?: NetworkSlicesSliceDefs["SparklinePoint"][]; 298 + /** Total number of indexed records in this slice */ 299 + indexedRecordCount?: number; 300 + /** Total number of unique indexed actors in this slice */ 301 + indexedActorCount?: number; 302 + /** Number of collections with indexed records */ 303 + indexedCollectionCount?: number; 298 304 } 299 305 300 306 export interface NetworkSlicesSliceDefsSparklinePoint {
+1 -10
frontend/src/features/slices/overview/handlers.tsx
··· 2 2 import { withAuth } from "../../../routes/middleware.ts"; 3 3 import { renderHTML } from "../../../utils/render.tsx"; 4 4 import { SliceOverview } from "./templates/SliceOverview.tsx"; 5 - import { 6 - getSliceStats, 7 - withSliceAccess, 8 - } from "../../../routes/slice-middleware.ts"; 5 + import { withSliceAccess } from "../../../routes/slice-middleware.ts"; 9 6 import { extractSliceParams } from "../../../utils/slice-params.ts"; 10 7 11 8 async function handleSliceOverview( ··· 30 27 return new Response("Slice not found", { status: 404 }); 31 28 } 32 29 33 - const stats = await getSliceStats(context.sliceContext!.sliceUri); 34 - 35 30 return renderHTML( 36 31 <SliceOverview 37 32 slice={context.sliceContext!.slice!} 38 33 sliceId={sliceParams.sliceId} 39 - totalRecords={stats.totalRecords} 40 - totalActors={stats.totalActors} 41 - totalLexicons={stats.totalLexicons} 42 - collections={stats.collections} 43 34 currentTab="overview" 44 35 currentUser={authContext.currentUser} 45 36 hasSliceAccess={context.sliceContext?.hasAccess}
+4 -9
frontend/src/features/slices/overview/templates/SliceOverview.tsx
··· 13 13 interface SliceOverviewProps { 14 14 slice: NetworkSlicesSliceDefsSliceView; 15 15 sliceId: string; 16 - totalRecords?: number; 17 - totalActors?: number; 18 - totalLexicons?: number; 19 16 collections?: Collection[]; 20 17 currentTab?: string; 21 18 currentUser?: AuthenticatedUser; ··· 25 22 export function SliceOverview({ 26 23 slice, 27 24 sliceId, 28 - totalRecords = 0, 29 - totalActors = 0, 30 25 collections = [], 31 26 currentTab = "overview", 32 27 currentUser, ··· 63 58 </div> 64 59 </div> 65 60 66 - {totalRecords > 0 && ( 61 + {(slice.indexedRecordCount ?? 0) > 0 && ( 67 62 <div className="bg-sky-50 border border-sky-200 p-6 mb-8"> 68 63 <h2 className="text-xl font-semibold text-zinc-900 mb-2"> 69 64 📊 Database Status 70 65 </h2> 71 66 <div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-zinc-700"> 72 67 <div> 73 - <span className="text-2xl font-bold">{totalRecords}</span> 68 + <span className="text-2xl font-bold">{slice.indexedRecordCount ?? 0}</span> 74 69 <p className="text-sm">Records</p> 75 70 </div> 76 71 <div> 77 - <span className="text-2xl font-bold">{collections.length}</span> 72 + <span className="text-2xl font-bold">{slice.indexedCollectionCount ?? 0}</span> 78 73 <p className="text-sm">Collections</p> 79 74 </div> 80 75 <div> 81 - <span className="text-2xl font-bold">{totalActors}</span> 76 + <span className="text-2xl font-bold">{slice.indexedActorCount ?? 0}</span> 82 77 <p className="text-sm">Actors</p> 83 78 </div> 84 79 </div>
+66 -14
frontend/src/lib/api.ts
··· 5 5 NetworkSlicesSlice, 6 6 NetworkSlicesSliceDefsSliceView, 7 7 NetworkSlicesSliceDefsSparklinePoint, 8 + SliceStatsOutput, 8 9 } from "../client.ts"; 9 10 import { recordBlobToCdnUrl, RecordResponse } from "@slices/client"; 10 11 ··· 28 29 return {}; 29 30 } 30 31 32 + async function fetchStatsForSlice( 33 + client: AtProtoClient, 34 + sliceUri: string 35 + ): Promise<SliceStatsOutput | null> { 36 + try { 37 + const statsResponse = await client.network.slices.slice.stats({ 38 + slice: sliceUri, 39 + }); 40 + if (statsResponse.success) { 41 + return statsResponse; 42 + } 43 + } catch (error) { 44 + console.warn("Failed to fetch stats for slice:", sliceUri, error); 45 + } 46 + return null; 47 + } 48 + 49 + async function fetchStatsForSlices( 50 + client: AtProtoClient, 51 + sliceUris: string[] 52 + ): Promise<Record<string, SliceStatsOutput | null>> { 53 + const statsMap: Record<string, SliceStatsOutput | null> = {}; 54 + 55 + // Fetch stats for each slice individually (no batch stats endpoint yet) 56 + await Promise.all( 57 + sliceUris.map(async (uri) => { 58 + statsMap[uri] = await fetchStatsForSlice(client, uri); 59 + }) 60 + ); 61 + 62 + return statsMap; 63 + } 64 + 31 65 export async function getSlice( 32 66 client: AtProtoClient, 33 67 uri: string ··· 42 76 const sparklinesMap = await fetchSparklinesForSlices(client, [uri]); 43 77 const sparklineData = sparklinesMap[uri]; 44 78 45 - return sliceToView(sliceRecord, creatorProfile, sparklineData); 79 + const stats = await fetchStatsForSlice(client, uri); 80 + 81 + return sliceToView(sliceRecord, creatorProfile, sparklineData, stats); 46 82 } catch (error) { 47 83 console.error("Failed to get slice:", error); 48 84 return null; ··· 81 117 export function sliceToView( 82 118 sliceRecord: RecordResponse<NetworkSlicesSlice>, 83 119 creator: NetworkSlicesActorDefsProfileViewBasic, 84 - sparkline?: NetworkSlicesSliceDefsSparklinePoint[] 120 + sparkline?: NetworkSlicesSliceDefsSparklinePoint[], 121 + stats?: SliceStatsOutput | null 85 122 ): NetworkSlicesSliceDefsSliceView { 86 123 return { 87 124 uri: sliceRecord.uri, ··· 91 128 creator, 92 129 createdAt: sliceRecord.value.createdAt, 93 130 sparkline, 131 + indexedRecordCount: stats?.totalRecords || 0, 132 + indexedActorCount: stats?.totalActors || 0, 133 + indexedCollectionCount: stats?.collectionStats.length || 0, 94 134 }; 95 135 } 96 136 ··· 121 161 sortBy: [{ field: "createdAt", direction: "desc" }], 122 162 }); 123 163 124 - // Collect slice URIs for batch sparkline fetch 164 + // Collect slice URIs for batch sparkline and stats fetch 125 165 const sliceUris = slicesResponse.records.map((record) => record.uri); 126 166 127 - // Fetch sparklines for all slices at once 128 - const sparklinesMap = await fetchSparklinesForSlices(client, sliceUris); 167 + // Fetch sparklines and stats for all slices at once 168 + const [sparklinesMap, statsMap] = await Promise.all([ 169 + fetchSparklinesForSlices(client, sliceUris), 170 + fetchStatsForSlices(client, sliceUris), 171 + ]); 129 172 130 173 const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 131 174 for (const sliceRecord of slicesResponse.records) { 132 175 const creator = await getSliceActor(client, sliceRecord.did); 133 176 if (creator) { 134 177 const sparklineData = sparklinesMap[sliceRecord.uri]; 135 - sliceViews.push(sliceToView(sliceRecord, creator, sparklineData)); 178 + const statsData = statsMap[sliceRecord.uri]; 179 + sliceViews.push(sliceToView(sliceRecord, creator, sparklineData, statsData)); 136 180 } 137 181 } 138 182 ··· 156 200 limit, 157 201 }); 158 202 159 - // Collect slice URIs for batch sparkline fetch 203 + // Collect slice URIs for batch sparkline and stats fetch 160 204 const sliceUris = slicesResponse.records.map((record) => record.uri); 161 205 162 - // Fetch sparklines for all slices at once 163 - const sparklinesMap = await fetchSparklinesForSlices(client, sliceUris); 206 + // Fetch sparklines and stats for all slices at once 207 + const [sparklinesMap, statsMap] = await Promise.all([ 208 + fetchSparklinesForSlices(client, sliceUris), 209 + fetchStatsForSlices(client, sliceUris), 210 + ]); 164 211 165 212 const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 166 213 for (const sliceRecord of slicesResponse.records) { 167 214 const creator = await getSliceActor(client, sliceRecord.did); 168 215 if (creator) { 169 216 const sparklineData = sparklinesMap[sliceRecord.uri]; 170 - sliceViews.push(sliceToView(sliceRecord, creator, sparklineData)); 217 + const statsData = statsMap[sliceRecord.uri]; 218 + sliceViews.push(sliceToView(sliceRecord, creator, sparklineData, statsData)); 171 219 } 172 220 } 173 221 ··· 188 236 sortBy: [{ field: "createdAt", direction: "desc" }], 189 237 }); 190 238 191 - // Collect slice URIs for batch sparkline fetch 239 + // Collect slice URIs for batch sparkline and stats fetch 192 240 const sliceUris = slicesResponse.records.map((record) => record.uri); 193 241 194 - // Fetch sparklines for all slices at once 195 - const sparklinesMap = await fetchSparklinesForSlices(client, sliceUris); 242 + // Fetch sparklines and stats for all slices at once 243 + const [sparklinesMap, statsMap] = await Promise.all([ 244 + fetchSparklinesForSlices(client, sliceUris), 245 + fetchStatsForSlices(client, sliceUris), 246 + ]); 196 247 197 248 const sliceViews: NetworkSlicesSliceDefsSliceView[] = []; 198 249 for (const sliceRecord of slicesResponse.records) { 199 250 const creator = await getSliceActor(client, sliceRecord.did); 200 251 if (creator) { 201 252 const sparklineData = sparklinesMap[sliceRecord.uri]; 202 - sliceViews.push(sliceToView(sliceRecord, creator, sparklineData)); 253 + const statsData = statsMap[sliceRecord.uri]; 254 + sliceViews.push(sliceToView(sliceRecord, creator, sparklineData, statsData)); 203 255 } 204 256 } 205 257
+5 -2
frontend/src/shared/fragments/ActivitySparkline.tsx
··· 13 13 height = 30, 14 14 className = "", 15 15 }: ActivitySparklineProps) { 16 + // Generate a unique ID for this sparkline instance 17 + const gradientId = `sparklineGradient-${Math.random().toString(36).substr(2, 9)}`; 18 + 16 19 // Convert sparkline data to numbers, or create flat line for no data 17 20 let dataPoints: number[]; 18 21 if (!sparklineData || sparklineData.length === 0) { ··· 48 51 {/* Gradient definition */} 49 52 <defs> 50 53 <linearGradient 51 - id="sparklineGradient" 54 + id={gradientId} 52 55 x1="0%" 53 56 y1="0%" 54 57 x2="0%" ··· 68 71 {/* Area fill */} 69 72 <path 70 73 d={areaPath} 71 - fill="url(#sparklineGradient)" 74 + fill={`url(#${gradientId})`} 72 75 strokeWidth="0" 73 76 /> 74 77
+40 -13
frontend/src/shared/fragments/SliceCard.tsx
··· 3 3 import { timeAgo } from "../../utils/time.ts"; 4 4 import { buildSliceUrlFromView } from "../../utils/slice-params.ts"; 5 5 import type { NetworkSlicesSliceDefsSliceView } from "../../client.ts"; 6 + import { Database, Layers, Users } from "lucide-preact"; 6 7 7 8 interface SliceCardProps { 8 9 slice: NetworkSlicesSliceDefsSliceView; ··· 15 16 return ( 16 17 <a href={sliceUrl} className="block"> 17 18 <div className="bg-white border border-zinc-200 rounded-lg p-4 hover:border-zinc-300 hover:shadow-sm transition-all cursor-pointer"> 18 - <div className="flex items-start justify-between"> 19 - {/* Left side - avatar and content */} 20 - <div className="flex items-start space-x-3 flex-1"> 21 - <ActorAvatar profile={slice.creator} size={40} /> 19 + <div className="flex items-start space-x-3"> 20 + {/* Avatar */} 21 + <ActorAvatar profile={slice.creator} size={40} /> 22 + 23 + {/* Content wrapper */} 24 + <div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> 25 + {/* Main content */} 22 26 <div className="flex-1 min-w-0"> 23 - <div className="flex items-center space-x-2 mb-1"> 24 - <span className="text-sm font-medium text-zinc-900"> 27 + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 mb-1"> 28 + <span className="text-sm font-medium text-zinc-900 truncate"> 25 29 {slice.creator.displayName || slice.creator.handle} 26 30 </span> 27 - <span className="text-sm text-zinc-500"> 31 + <span className="text-sm text-zinc-500 truncate"> 28 32 @{slice.creator.handle} 29 33 </span> 30 34 <time ··· 35 39 </time> 36 40 </div> 37 41 <div className="group"> 38 - <h3 className="text-lg font-semibold text-zinc-900 group-hover:text-zinc-700 mb-1"> 42 + <h3 className="text-lg font-semibold text-zinc-900 group-hover:text-zinc-700 mb-1 break-words"> 39 43 {slice.name} 40 44 </h3> 41 - <p className="text-sm text-zinc-600 mb-2">{slice.domain}</p> 45 + <p className="text-sm text-zinc-600 mb-2 break-all">{slice.domain}</p> 46 + {/* Stats badges */} 47 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-500"> 48 + {(slice.indexedRecordCount ?? 0) > 0 && ( 49 + <> 50 + <span className="flex items-center gap-1 whitespace-nowrap" title="Indexed Records"> 51 + <Database size={12} /> 52 + <span className="font-semibold">{slice.indexedRecordCount?.toLocaleString() ?? 0}</span> 53 + </span> 54 + <span className="flex items-center gap-1 whitespace-nowrap" title="Collections"> 55 + <Layers size={12} /> 56 + <span className="font-semibold">{slice.indexedCollectionCount ?? 0}</span> 57 + </span> 58 + <span className="flex items-center gap-1 whitespace-nowrap" title="Actors"> 59 + <Users size={12} /> 60 + <span className="font-semibold">{slice.indexedActorCount?.toLocaleString() ?? 0}</span> 61 + </span> 62 + </> 63 + )} 64 + </div> 65 + {/* Sparkline - on mobile, shows below stats aligned with content */} 66 + <div className="mt-2 sm:hidden"> 67 + <ActivitySparkline sparklineData={slice.sparkline} /> 68 + </div> 42 69 </div> 43 70 </div> 44 - </div> 45 71 46 - {/* Right side - activity sparkline */} 47 - <div className="flex items-center ml-4 self-center"> 48 - <ActivitySparkline sparklineData={slice.sparkline} /> 72 + {/* Sparkline - on desktop only */} 73 + <div className="hidden sm:flex items-center ml-4 self-center"> 74 + <ActivitySparkline sparklineData={slice.sparkline} /> 75 + </div> 49 76 </div> 50 77 </div> 51 78 </div>
+12
lexicons/network/slices/slice/defs.json
··· 38 38 "ref": "#sparklinePoint" 39 39 }, 40 40 "description": "Recent activity sparkline data points for the last 24 hours" 41 + }, 42 + "indexedRecordCount": { 43 + "type": "integer", 44 + "description": "Total number of indexed records in this slice" 45 + }, 46 + "indexedActorCount": { 47 + "type": "integer", 48 + "description": "Total number of unique indexed actors in this slice" 49 + }, 50 + "indexedCollectionCount": { 51 + "type": "integer", 52 + "description": "Number of collections with indexed records" 41 53 } 42 54 } 43 55 },