Highly ambitious ATProtocol AppView service and sdks
at main 280 lines 8.8 kB view raw
1import { useParams } from "react-router-dom"; 2import { graphql, useLazyLoadQuery, useSubscription } from "react-relay"; 3import { useEffect, useMemo, useState } from "react"; 4import type { GraphQLSubscriptionConfig } from "relay-runtime"; 5import type { SliceJetstreamQuery } from "../__generated__/SliceJetstreamQuery.graphql.ts"; 6import type { SliceJetstreamLogsQuery } from "../__generated__/SliceJetstreamLogsQuery.graphql.ts"; 7import type { SliceJetstreamSubscription } from "../__generated__/SliceJetstreamSubscription.graphql.ts"; 8import Layout from "../components/Layout.tsx"; 9import { Avatar } from "../components/Avatar.tsx"; 10import { SliceSubNav } from "../components/SliceSubNav.tsx"; 11import { ChevronRight } from "lucide-react"; 12import { useSessionContext } from "../lib/useSession.ts"; 13import { isSliceOwner } from "../lib/permissions.ts"; 14 15// Use the GraphQL-generated type for log entries 16type LogEntry = SliceJetstreamLogsQuery["response"]["jetstreamLogs"][number]; 17 18export default function SliceJetstream() { 19 const { handle, rkey } = useParams<{ handle: string; rkey: string }>(); 20 const { session } = useSessionContext(); 21 const [realtimeLogs, setRealtimeLogs] = useState<LogEntry[]>([]); 22 const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set()); 23 24 // First query to get the slice URI 25 const data = useLazyLoadQuery<SliceJetstreamQuery>( 26 graphql` 27 query SliceJetstreamQuery($where: NetworkSlicesSliceWhereInput) { 28 networkSlicesSlices(first: 1, where: $where) { 29 edges { 30 node { 31 uri 32 name 33 domain 34 actorHandle 35 did 36 networkSlicesActorProfile { 37 avatar { 38 url(preset: "avatar") 39 } 40 } 41 } 42 } 43 } 44 } 45 `, 46 { 47 where: { 48 actorHandle: { eq: handle }, 49 uri: { contains: rkey }, 50 }, 51 }, 52 ); 53 54 const slice = data.networkSlicesSlices.edges[0]?.node; 55 56 const isOwner = isSliceOwner(slice, session); 57 58 // Second query for logs using the actual slice URI 59 const logsData = useLazyLoadQuery<SliceJetstreamLogsQuery>( 60 graphql` 61 query SliceJetstreamLogsQuery($slice: String) { 62 jetstreamLogs(slice: $slice, limit: 50) { 63 id 64 createdAt 65 level 66 message 67 metadata 68 sliceUri 69 } 70 } 71 `, 72 { 73 slice: slice?.uri, 74 }, 75 { 76 fetchPolicy: "store-and-network", 77 }, 78 ); 79 80 // Setup subscription for real-time logs 81 const subscriptionConfig = useMemo< 82 GraphQLSubscriptionConfig<SliceJetstreamSubscription> 83 >( 84 () => ({ 85 subscription: graphql` 86 subscription SliceJetstreamSubscription($slice: String) { 87 jetstreamLogsCreated(slice: $slice) { 88 id 89 createdAt 90 level 91 message 92 metadata 93 sliceUri 94 } 95 } 96 `, 97 variables: { 98 slice: slice?.uri || "", 99 }, 100 onNext: (response) => { 101 if (response?.jetstreamLogsCreated) { 102 const newLog = response.jetstreamLogsCreated; 103 setRealtimeLogs((prev) => [newLog, ...prev].slice(0, 100)); // Keep last 100 104 } 105 }, 106 }), 107 [slice?.uri], 108 ); 109 110 useSubscription(subscriptionConfig); 111 112 // Combine historical and real-time logs 113 const allLogs = useMemo(() => { 114 const historical = logsData.jetstreamLogs || []; 115 116 // Merge and dedupe by id, real-time logs take precedence 117 const logsMap = new Map<string, (typeof historical)[number]>(); 118 [...historical, ...realtimeLogs].filter(Boolean).forEach((log) => { 119 if (log?.id) { 120 logsMap.set(log.id, log); 121 } 122 }); 123 124 return Array.from(logsMap.values()).sort( 125 (a, b) => 126 new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), 127 ); 128 }, [logsData.jetstreamLogs, realtimeLogs]); 129 130 const getLevelColor = (level: string) => { 131 switch (level.toLowerCase()) { 132 case "error": 133 return "text-red-400 bg-red-950/30"; 134 case "warn": 135 return "text-yellow-400 bg-yellow-950/30"; 136 case "info": 137 return "text-blue-400 bg-blue-950/30"; 138 default: 139 return "text-zinc-400 bg-zinc-900"; 140 } 141 }; 142 143 const formatTimestamp = (timestamp: string) => { 144 const date = new Date(timestamp); 145 return date.toLocaleString(); 146 }; 147 148 const toggleExpanded = (logId: string) => { 149 setExpandedLogs((prev) => { 150 const next = new Set(prev); 151 if (next.has(logId)) { 152 next.delete(logId); 153 } else { 154 next.add(logId); 155 } 156 return next; 157 }); 158 }; 159 160 // Expand all logs with metadata by default 161 useEffect(() => { 162 const logsWithMetadata = allLogs 163 .filter((log) => log.metadata) 164 .map((log) => log.id); 165 setExpandedLogs(new Set(logsWithMetadata)); 166 }, [allLogs]); 167 168 // Highlight NSIDs in log messages 169 const highlightNsid = (message: string) => { 170 // Match NSID pattern (e.g., fm.teal.alpha.feed.play or app.bsky.feed.post) 171 const nsidPattern = /\b([a-z]+\.[a-z0-9]+(?:\.[a-z0-9]+)+)\b/gi; 172 const parts = message.split(nsidPattern); 173 174 return parts.map((part, index) => { 175 // Check if this part matches an NSID pattern 176 if (index % 2 === 1) { 177 return ( 178 <span key={index} className="text-cyan-400 font-mono"> 179 {part} 180 </span> 181 ); 182 } 183 return part; 184 }); 185 }; 186 187 return ( 188 <Layout 189 subNav={ 190 <SliceSubNav 191 handle={handle!} 192 rkey={rkey!} 193 sliceName={slice?.name} 194 activeTab="jetstream" 195 isOwner={isOwner} 196 /> 197 } 198 > 199 <div className="mb-8"> 200 <div className="flex items-center gap-3"> 201 <Avatar 202 src={slice?.networkSlicesActorProfile?.avatar?.url} 203 alt={`${handle} avatar`} 204 size="md" 205 /> 206 <div className="flex-1"> 207 <div className="flex items-center gap-2 mb-2"> 208 <h1 className="text-2xl font-medium text-zinc-200"> 209 Jetstream Logs 210 </h1> 211 <div className="flex items-center gap-1.5 px-2 py-1 bg-zinc-900/50 rounded"> 212 <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"> 213 </div> 214 <span className="text-xs text-zinc-400">Live</span> 215 </div> 216 </div> 217 <p className="text-sm text-zinc-500"> 218 Real-time event streaming for {slice?.name} 219 </p> 220 </div> 221 </div> 222 </div> 223 224 <div> 225 {allLogs.length === 0 226 ? ( 227 <div className="p-8 text-center text-zinc-500"> 228 No logs yet. Logs will appear here in real-time. 229 </div> 230 ) 231 : ( 232 allLogs.map((log) => { 233 const isExpanded = expandedLogs.has(log.id); 234 return ( 235 <div key={log.id}> 236 <div 237 className="flex items-center gap-2 py-1.5 cursor-pointer hover:bg-zinc-900/50 rounded px-2" 238 onClick={() => log.metadata && toggleExpanded(log.id)} 239 > 240 {log.metadata 241 ? ( 242 <ChevronRight 243 size={14} 244 className={`text-zinc-500 transition-transform flex-shrink-0 ${ 245 isExpanded ? "rotate-90" : "" 246 }`} 247 /> 248 ) 249 : <div className="w-3.5 flex-shrink-0" />} 250 <span 251 className={`px-2 py-0.5 text-xs font-medium rounded ${ 252 getLevelColor( 253 log.level, 254 ) 255 }`} 256 > 257 {log.level.toUpperCase()} 258 </span> 259 <span className="text-sm text-zinc-400 flex-1"> 260 {highlightNsid(log.message)} 261 </span> 262 <span className="text-xs text-zinc-600"> 263 {formatTimestamp(log.createdAt)} 264 </span> 265 </div> 266 {log.metadata && isExpanded && ( 267 <div className="pl-6 pr-2 pb-2"> 268 <pre className="text-xs text-zinc-400 bg-zinc-950 p-2 rounded overflow-x-auto"> 269 {JSON.stringify(log.metadata, null, 2)} 270 </pre> 271 </div> 272 )} 273 </div> 274 ); 275 }) 276 )} 277 </div> 278 </Layout> 279 ); 280}