Highly ambitious ATProtocol AppView service and sdks
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}