tanuki-telemetry 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/Dockerfile +22 -0
  2. package/bin/tanuki.mjs +251 -0
  3. package/frontend/eslint.config.js +23 -0
  4. package/frontend/index.html +13 -0
  5. package/frontend/package.json +39 -0
  6. package/frontend/src/App.tsx +232 -0
  7. package/frontend/src/assets/hero.png +0 -0
  8. package/frontend/src/assets/react.svg +1 -0
  9. package/frontend/src/assets/vite.svg +1 -0
  10. package/frontend/src/components/ArtifactsPanel.tsx +429 -0
  11. package/frontend/src/components/ChildStreams.tsx +176 -0
  12. package/frontend/src/components/CoordinatorPage.tsx +317 -0
  13. package/frontend/src/components/Header.tsx +108 -0
  14. package/frontend/src/components/InsightsPanel.tsx +142 -0
  15. package/frontend/src/components/IterationsTable.tsx +98 -0
  16. package/frontend/src/components/KnowledgePage.tsx +308 -0
  17. package/frontend/src/components/LoginPage.tsx +55 -0
  18. package/frontend/src/components/PlanProgress.tsx +163 -0
  19. package/frontend/src/components/QualityReport.tsx +276 -0
  20. package/frontend/src/components/ScreenshotUpload.tsx +117 -0
  21. package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
  22. package/frontend/src/components/SessionDetail.tsx +265 -0
  23. package/frontend/src/components/SessionList.tsx +234 -0
  24. package/frontend/src/components/SettingsPage.tsx +213 -0
  25. package/frontend/src/components/StreamComms.tsx +228 -0
  26. package/frontend/src/components/TanukiLogo.tsx +16 -0
  27. package/frontend/src/components/Timeline.tsx +416 -0
  28. package/frontend/src/components/WalkthroughPage.tsx +458 -0
  29. package/frontend/src/hooks/useApi.ts +81 -0
  30. package/frontend/src/hooks/useAuth.ts +54 -0
  31. package/frontend/src/hooks/useKnowledge.ts +33 -0
  32. package/frontend/src/hooks/useWebSocket.ts +95 -0
  33. package/frontend/src/index.css +66 -0
  34. package/frontend/src/lib/api.ts +15 -0
  35. package/frontend/src/lib/utils.ts +58 -0
  36. package/frontend/src/main.tsx +10 -0
  37. package/frontend/src/types.ts +181 -0
  38. package/frontend/tsconfig.app.json +32 -0
  39. package/frontend/tsconfig.json +7 -0
  40. package/frontend/vite.config.ts +25 -0
  41. package/install.sh +87 -0
  42. package/package.json +63 -0
  43. package/src/api-keys.ts +97 -0
  44. package/src/auth.ts +165 -0
  45. package/src/coordinator.ts +136 -0
  46. package/src/dashboard-server.ts +5 -0
  47. package/src/dashboard.ts +826 -0
  48. package/src/db.ts +1009 -0
  49. package/src/index.ts +20 -0
  50. package/src/middleware.ts +76 -0
  51. package/src/tools.ts +864 -0
  52. package/src/types-shim.d.ts +18 -0
  53. package/src/types.ts +171 -0
  54. package/tsconfig.json +19 -0
@@ -0,0 +1,416 @@
1
+ import { useRef, useEffect, useState, useMemo, type ReactNode } from "react"
2
+ import { createPortal } from "react-dom"
3
+ import gsap from "gsap"
4
+ import { HelpCircle, Play, AlertTriangle, Wrench, Minus, Hash, CheckCircle2, XCircle, RotateCw, ArrowRight, DollarSign, Layers, Star } from "lucide-react"
5
+ import type { Event, Screenshot } from "@/types"
6
+ import { relativeTime, screenshotUrl, cn } from "@/lib/utils"
7
+
8
+ interface Props {
9
+ events: Event[]
10
+ sessionStart: string
11
+ screenshots?: Screenshot[]
12
+ }
13
+
14
+ export function Timeline({ events, sessionStart, screenshots = [] }: Props) {
15
+ const ref = useRef<HTMLDivElement>(null)
16
+ const prevCountRef = useRef(0)
17
+
18
+ useEffect(() => {
19
+ if (!ref.current) return
20
+ // Only animate new items, not on every refresh
21
+ const currentCount = events.length
22
+ if (currentCount === prevCountRef.current) return
23
+ const isInitial = prevCountRef.current === 0
24
+ prevCountRef.current = currentCount
25
+
26
+ if (isInitial) {
27
+ const items = ref.current.querySelectorAll(".tl-item")
28
+ gsap.fromTo(
29
+ items,
30
+ { opacity: 0, x: -8 },
31
+ { opacity: 1, x: 0, duration: 0.25, stagger: 0.03, ease: "power2.out" }
32
+ )
33
+ }
34
+ }, [events])
35
+
36
+ // Build map of event_id → linked screenshots
37
+ const eventScreenshots = useMemo(() => {
38
+ const map = new Map<number, Screenshot[]>()
39
+ for (const sc of screenshots) {
40
+ if (sc.event_id) {
41
+ const existing = map.get(sc.event_id) || []
42
+ existing.push(sc)
43
+ map.set(sc.event_id, existing)
44
+ }
45
+ }
46
+ return map
47
+ }, [screenshots])
48
+
49
+ return (
50
+ <div ref={ref} className="mt-2 space-y-0">
51
+ {events.map((ev) => (
52
+ <TimelineItem key={ev.id} event={ev} sessionStart={sessionStart} linkedScreenshots={eventScreenshots.get(ev.id)} />
53
+ ))}
54
+ </div>
55
+ )
56
+ }
57
+
58
+ function TimelineItem({
59
+ event,
60
+ sessionStart,
61
+ linkedScreenshots,
62
+ }: {
63
+ event: Event
64
+ sessionStart: string
65
+ linkedScreenshots?: Screenshot[]
66
+ }) {
67
+ const [expanded, setExpanded] = useState(false)
68
+ const hasMeta = event.metadata && event.metadata !== "null"
69
+ const detailRef = useRef<HTMLDivElement>(null)
70
+
71
+ useEffect(() => {
72
+ if (!detailRef.current) return
73
+ if (expanded) {
74
+ gsap.fromTo(
75
+ detailRef.current,
76
+ { height: 0, opacity: 0 },
77
+ { height: "auto", opacity: 1, duration: 0.25, ease: "power2.out" }
78
+ )
79
+ }
80
+ }, [expanded])
81
+
82
+ const parsedMeta: object | string | null = hasMeta ? (safeParse(event.metadata!) as object | string) : null
83
+ const tokensUsed = event.tokens_used
84
+ const hasMetaScreenshot = parsedMeta && typeof parsedMeta === "object" && !Array.isArray(parsedMeta) &&
85
+ ["screenshot", "screenshot_path", "file_path", "image", "image_path"].some((k) => {
86
+ const v = (parsedMeta as Record<string, unknown>)[k]
87
+ return typeof v === "string" && /\.(png|jpg|jpeg|gif|webp)$/i.test(v)
88
+ })
89
+ const hasLinkedScreenshots = linkedScreenshots && linkedScreenshots.length > 0
90
+ const hasScreenshot = hasMetaScreenshot || hasLinkedScreenshots
91
+
92
+ const phaseColors: Record<string, string> = {
93
+ setup: "text-info",
94
+ scope: "text-purple",
95
+ implementation: "text-warning",
96
+ verification: "text-accent",
97
+ deliverables: "text-accent",
98
+ }
99
+
100
+ const eventColors: Record<string, string> = {
101
+ decision: "text-info",
102
+ action: "text-accent",
103
+ error: "text-error",
104
+ fix: "text-warning",
105
+ info: "text-text-muted",
106
+ phase_change: "text-purple",
107
+ review_pass: "text-accent",
108
+ review_flag: "text-error",
109
+ review_dispatch: "text-info",
110
+ error_resolve: "text-accent",
111
+ cost_checkpoint: "text-text-dim",
112
+ pattern_detect: "text-purple",
113
+ knowledge_extract: "text-warning",
114
+ }
115
+
116
+ const eventIcons: Record<string, ReactNode> = {
117
+ decision: <HelpCircle size={10} />,
118
+ action: <Play size={10} />,
119
+ error: <AlertTriangle size={10} />,
120
+ fix: <Wrench size={10} />,
121
+ info: <Minus size={10} />,
122
+ phase_change: <Hash size={10} />,
123
+ review_pass: <CheckCircle2 size={10} />,
124
+ review_flag: <XCircle size={10} />,
125
+ review_dispatch: <RotateCw size={10} />,
126
+ error_resolve: <ArrowRight size={10} />,
127
+ cost_checkpoint: <DollarSign size={10} />,
128
+ pattern_detect: <Layers size={10} />,
129
+ knowledge_extract: <Star size={10} />,
130
+ }
131
+
132
+ const isExpandable = hasMeta || tokensUsed > 0
133
+ const isError = event.event_type === "error" || event.event_type === "review_flag"
134
+ const isPhaseChange = event.event_type === "phase_change"
135
+ return (
136
+ <div
137
+ className={cn(
138
+ "tl-item border-l-2 ml-1 transition-colors duration-150",
139
+ event.event_type === "review_flag"
140
+ ? "border-l-error/40 bg-error/[0.03]"
141
+ : event.event_type === "review_pass"
142
+ ? "border-l-accent/40 bg-accent/[0.03]"
143
+ : event.event_type === "review_dispatch"
144
+ ? "border-l-info/30 bg-info/[0.02]"
145
+ : event.event_type === "error_resolve"
146
+ ? "border-l-accent/40 bg-accent/[0.02]"
147
+ : event.event_type === "knowledge_extract"
148
+ ? "border-l-warning/40 bg-warning/[0.03]"
149
+ : event.event_type === "pattern_detect"
150
+ ? "border-l-purple/40 bg-purple/[0.03]"
151
+ : isError
152
+ ? "border-l-error/40 bg-error/[0.03]"
153
+ : isPhaseChange
154
+ ? "border-l-purple/60"
155
+ : "border-l-border-bright",
156
+ isExpandable && "cursor-pointer",
157
+ expanded && "bg-bg-hover"
158
+ )}
159
+ onClick={() => isExpandable && setExpanded(!expanded)}
160
+ >
161
+ {/* Main row */}
162
+ <div className="flex gap-2 pt-2 pb-1.5 px-2 hover:bg-bg-hover transition-colors duration-150 group items-baseline">
163
+ {/* Timestamp */}
164
+ <span className="text-[10px] text-text-dim w-11 flex-shrink-0 font-mono tabular-nums leading-none">
165
+ {relativeTime(event.timestamp, sessionStart)}
166
+ </span>
167
+
168
+ {/* Icon */}
169
+ <span
170
+ className={cn(
171
+ "text-[10px] w-3 flex-shrink-0 font-bold text-center",
172
+ eventColors[event.event_type] || "text-text-muted"
173
+ )}
174
+ >
175
+ {eventIcons[event.event_type] || "-"}
176
+ </span>
177
+
178
+ {/* Phase tag */}
179
+ <span
180
+ className={cn(
181
+ "text-[10px] w-14 flex-shrink-0 uppercase tracking-wider truncate leading-none",
182
+ phaseColors[event.phase.toLowerCase()] || "text-text-muted"
183
+ )}
184
+ >
185
+ {event.phase.slice(0, 8)}
186
+ </span>
187
+
188
+ {/* Event type tag */}
189
+ <span
190
+ className={cn(
191
+ "text-[9px] px-1 py-px border rounded flex-shrink-0 uppercase tracking-wider",
192
+ event.event_type === "error"
193
+ ? "border-error/30 text-error"
194
+ : event.event_type === "fix"
195
+ ? "border-warning/30 text-warning"
196
+ : event.event_type === "action"
197
+ ? "border-accent/20 text-accent/70"
198
+ : event.event_type === "decision"
199
+ ? "border-info/30 text-info"
200
+ : event.event_type === "phase_change"
201
+ ? "border-purple/30 text-purple"
202
+ : event.event_type === "review_pass"
203
+ ? "border-accent/40 text-accent bg-accent/5"
204
+ : event.event_type === "review_flag"
205
+ ? "border-error/40 text-error bg-error/5"
206
+ : event.event_type === "review_dispatch"
207
+ ? "border-info/30 text-info bg-info/5"
208
+ : event.event_type === "error_resolve"
209
+ ? "border-accent/30 text-accent bg-accent/5"
210
+ : event.event_type === "cost_checkpoint"
211
+ ? "border-border text-text-dim"
212
+ : event.event_type === "pattern_detect"
213
+ ? "border-purple/30 text-purple bg-purple/5"
214
+ : event.event_type === "knowledge_extract"
215
+ ? "border-warning/30 text-warning bg-warning/5"
216
+ : "border-border text-text-dim"
217
+ )}
218
+ >
219
+ {event.event_type.replace("_", " ")}
220
+ </span>
221
+
222
+ {/* Message */}
223
+ <div className="flex-1 min-w-0">
224
+ <span
225
+ className={cn(
226
+ "text-xs break-words",
227
+ isError ? "text-error/90" : "text-text"
228
+ )}
229
+ >
230
+ {event.message}
231
+ </span>
232
+ </div>
233
+
234
+ {/* Screenshot + expand indicator */}
235
+ <span className="flex items-center gap-1 flex-shrink-0">
236
+ {hasScreenshot && (
237
+ <span className="text-[10px] text-accent" title="has screenshot">
238
+ {"\uD83D\uDDBC"}
239
+ </span>
240
+ )}
241
+ {isExpandable && (
242
+ <span className="text-[10px] text-text-dim opacity-0 group-hover:opacity-100 transition-opacity">
243
+ {expanded ? "\u25BE" : "\u25B8"}
244
+ </span>
245
+ )}
246
+ </span>
247
+ </div>
248
+
249
+ {/* Expanded detail panel */}
250
+ {expanded && isExpandable && (
251
+ <div
252
+ ref={detailRef}
253
+ data-expand
254
+ className="overflow-hidden"
255
+ >
256
+ <div className="mx-2 mb-2 ml-8 border border-border rounded bg-bg">
257
+ {/* Detail header */}
258
+ <div className="flex items-center gap-3 px-3 py-1.5 border-b border-border text-[10px] text-text-dim">
259
+ <span>
260
+ EVENT #{event.id}
261
+ </span>
262
+ <span className="text-border-bright">|</span>
263
+ <span>
264
+ phase: <span className={phaseColors[event.phase.toLowerCase()] || "text-text-muted"}>{event.phase}</span>
265
+ </span>
266
+ <span className="text-border-bright">|</span>
267
+ <span>
268
+ type: <span className={eventColors[event.event_type] || "text-text-muted"}>{event.event_type}</span>
269
+ </span>
270
+ {tokensUsed > 0 && (
271
+ <>
272
+ <span className="text-border-bright">|</span>
273
+ <span>
274
+ tokens: <span className="text-text">{tokensUsed.toLocaleString()}</span>
275
+ </span>
276
+ </>
277
+ )}
278
+ </div>
279
+
280
+ {/* Metadata */}
281
+ {parsedMeta && (
282
+ <div className="px-3 py-2">
283
+ {typeof parsedMeta === "object" && parsedMeta !== null && !Array.isArray(parsedMeta) ? (
284
+ <MetadataTable data={parsedMeta as Record<string, unknown>} />
285
+ ) : (
286
+ <pre className="text-[10px] text-text-muted whitespace-pre-wrap overflow-auto max-h-64">
287
+ {JSON.stringify(parsedMeta, null, 2)}
288
+ </pre>
289
+ )}
290
+ </div>
291
+ )}
292
+
293
+ {/* No metadata but has tokens */}
294
+ {!parsedMeta && tokensUsed > 0 && (
295
+ <div className="px-3 py-2 text-[10px] text-text-dim">
296
+ <span className="text-text-muted">tokens_used:</span>{" "}
297
+ <span className="text-text">{tokensUsed.toLocaleString()}</span>
298
+ </div>
299
+ )}
300
+
301
+ {/* Inline screenshot if metadata contains screenshot/image path */}
302
+ {parsedMeta && <InlineScreenshot meta={parsedMeta as Record<string, unknown>} />}
303
+
304
+ {/* Linked screenshots via event_id */}
305
+ {hasLinkedScreenshots && (
306
+ <div className="px-3 py-2 border-t border-border">
307
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-1">LINKED SCREENSHOTS</div>
308
+ <div className="flex gap-2 overflow-x-auto">
309
+ {linkedScreenshots!.map((sc) => (
310
+ <img
311
+ key={sc.id}
312
+ src={screenshotUrl(sc.file_path, sc.id, "thumb")}
313
+ alt={sc.description}
314
+ title={sc.description}
315
+ className="h-20 w-auto rounded border border-border cursor-pointer hover:border-accent transition-colors flex-shrink-0"
316
+ onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
317
+ />
318
+ ))}
319
+ </div>
320
+ </div>
321
+ )}
322
+ </div>
323
+ </div>
324
+ )}
325
+ </div>
326
+ )
327
+ }
328
+
329
+ function MetadataTable({ data }: { data: Record<string, unknown> }) {
330
+ return (
331
+ <div className="space-y-1">
332
+ {Object.entries(data).map(([key, value]) => (
333
+ <div key={key} className="flex gap-2 text-[10px]">
334
+ <span className="text-accent/60 flex-shrink-0 min-w-[100px]">{key}:</span>
335
+ <span className="text-text-muted break-all">
336
+ {renderValue(value)}
337
+ </span>
338
+ </div>
339
+ ))}
340
+ </div>
341
+ )
342
+ }
343
+
344
+ function renderValue(value: unknown): string {
345
+ if (value === null || value === undefined) return "null"
346
+ if (typeof value === "string") return value
347
+ if (typeof value === "number" || typeof value === "boolean") return String(value)
348
+ if (Array.isArray(value)) {
349
+ if (value.length === 0) return "[]"
350
+ if (value.every((v) => typeof v === "string")) return value.join(", ")
351
+ return JSON.stringify(value, null, 2)
352
+ }
353
+ return JSON.stringify(value, null, 2)
354
+ }
355
+
356
+ function InlineScreenshot({ meta }: { meta: Record<string, unknown> }) {
357
+ // Look for screenshot paths in metadata
358
+ const screenshotKeys = ["screenshot", "screenshot_path", "file_path", "image", "image_path"]
359
+ let imgPath: string | null = null
360
+
361
+ for (const key of screenshotKeys) {
362
+ const val = meta[key]
363
+ if (typeof val === "string" && (val.endsWith(".png") || val.endsWith(".jpg") || val.endsWith(".jpeg") || val.endsWith(".gif") || val.endsWith(".webp"))) {
364
+ imgPath = val
365
+ break
366
+ }
367
+ }
368
+
369
+ if (!imgPath) return null
370
+
371
+ const url = screenshotUrl(imgPath)
372
+ const [lightbox, setLightbox] = useState(false)
373
+
374
+ return (
375
+ <>
376
+ <div className="px-3 py-2 border-t border-border">
377
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-1">SCREENSHOT</div>
378
+ <img
379
+ src={url}
380
+ alt=""
381
+ className="max-w-full max-h-48 rounded border border-border cursor-pointer hover:border-accent transition-colors"
382
+ onClick={(e) => { e.stopPropagation(); setLightbox(true) }}
383
+ onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
384
+ />
385
+ </div>
386
+ {lightbox && createPortal(
387
+ <div
388
+ className="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center"
389
+ onClick={(e) => { e.stopPropagation(); setLightbox(false) }}
390
+ >
391
+ <div
392
+ className="bg-bg-elevated border border-border rounded-lg shadow-2xl w-[85vw] h-[85vh] flex flex-col overflow-hidden"
393
+ onClick={(e) => e.stopPropagation()}
394
+ >
395
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border flex-shrink-0">
396
+ <span className="text-[10px] text-text-muted font-mono">screenshot preview</span>
397
+ <button onClick={(e) => { e.stopPropagation(); setLightbox(false) }} className="text-text-dim hover:text-text text-sm">&times;</button>
398
+ </div>
399
+ <div className="flex-1 flex items-center justify-center p-6 overflow-hidden">
400
+ <img src={url} alt="" className="max-w-full max-h-full object-contain rounded" />
401
+ </div>
402
+ </div>
403
+ </div>,
404
+ document.body
405
+ )}
406
+ </>
407
+ )
408
+ }
409
+
410
+ function safeParse(str: string): unknown {
411
+ try {
412
+ return JSON.parse(str)
413
+ } catch {
414
+ return str
415
+ }
416
+ }