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.
- package/Dockerfile +22 -0
- package/bin/tanuki.mjs +251 -0
- package/frontend/eslint.config.js +23 -0
- package/frontend/index.html +13 -0
- package/frontend/package.json +39 -0
- package/frontend/src/App.tsx +232 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/ArtifactsPanel.tsx +429 -0
- package/frontend/src/components/ChildStreams.tsx +176 -0
- package/frontend/src/components/CoordinatorPage.tsx +317 -0
- package/frontend/src/components/Header.tsx +108 -0
- package/frontend/src/components/InsightsPanel.tsx +142 -0
- package/frontend/src/components/IterationsTable.tsx +98 -0
- package/frontend/src/components/KnowledgePage.tsx +308 -0
- package/frontend/src/components/LoginPage.tsx +55 -0
- package/frontend/src/components/PlanProgress.tsx +163 -0
- package/frontend/src/components/QualityReport.tsx +276 -0
- package/frontend/src/components/ScreenshotUpload.tsx +117 -0
- package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
- package/frontend/src/components/SessionDetail.tsx +265 -0
- package/frontend/src/components/SessionList.tsx +234 -0
- package/frontend/src/components/SettingsPage.tsx +213 -0
- package/frontend/src/components/StreamComms.tsx +228 -0
- package/frontend/src/components/TanukiLogo.tsx +16 -0
- package/frontend/src/components/Timeline.tsx +416 -0
- package/frontend/src/components/WalkthroughPage.tsx +458 -0
- package/frontend/src/hooks/useApi.ts +81 -0
- package/frontend/src/hooks/useAuth.ts +54 -0
- package/frontend/src/hooks/useKnowledge.ts +33 -0
- package/frontend/src/hooks/useWebSocket.ts +95 -0
- package/frontend/src/index.css +66 -0
- package/frontend/src/lib/api.ts +15 -0
- package/frontend/src/lib/utils.ts +58 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/types.ts +181 -0
- package/frontend/tsconfig.app.json +32 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/vite.config.ts +25 -0
- package/install.sh +87 -0
- package/package.json +63 -0
- package/src/api-keys.ts +97 -0
- package/src/auth.ts +165 -0
- package/src/coordinator.ts +136 -0
- package/src/dashboard-server.ts +5 -0
- package/src/dashboard.ts +826 -0
- package/src/db.ts +1009 -0
- package/src/index.ts +20 -0
- package/src/middleware.ts +76 -0
- package/src/tools.ts +864 -0
- package/src/types-shim.d.ts +18 -0
- package/src/types.ts +171 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, type ReactNode } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import { Navigation, MousePointer2, Type, CheckSquare, Clock, Camera } from "lucide-react"
|
|
4
|
+
import type { Walkthrough, WalkthroughAction, WalkthroughScreenshot } from "@/types"
|
|
5
|
+
import { cn, timeAgo } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
interface WalkthroughDetail {
|
|
8
|
+
walkthrough: Walkthrough
|
|
9
|
+
actions: WalkthroughAction[]
|
|
10
|
+
screenshots: WalkthroughScreenshot[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
|
14
|
+
pass: { bg: "bg-success/15", text: "text-success", label: "PASS" },
|
|
15
|
+
fail: { bg: "bg-danger/15", text: "text-danger", label: "FAIL" },
|
|
16
|
+
partial: { bg: "bg-warning/15", text: "text-warning", label: "PARTIAL" },
|
|
17
|
+
in_progress: { bg: "bg-info/15", text: "text-info", label: "RUNNING" },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ACTION_ICONS: Record<string, ReactNode> = {
|
|
21
|
+
navigate: <Navigation size={10} />,
|
|
22
|
+
click: <MousePointer2 size={10} />,
|
|
23
|
+
type: <Type size={10} />,
|
|
24
|
+
assert: <CheckSquare size={10} />,
|
|
25
|
+
wait: <Clock size={10} />,
|
|
26
|
+
screenshot: <Camera size={10} />,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function screenshotUrl(walkthroughId: number, screenshotId: number): string {
|
|
30
|
+
return `/api/walkthroughs/${walkthroughId}/screenshots/${screenshotId}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
|
|
34
|
+
const overlayRef = useRef<HTMLDivElement>(null)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
38
|
+
if (e.key === "Escape") onClose()
|
|
39
|
+
}
|
|
40
|
+
document.addEventListener("keydown", handleKey)
|
|
41
|
+
return () => document.removeEventListener("keydown", handleKey)
|
|
42
|
+
}, [onClose])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
ref={overlayRef}
|
|
47
|
+
className="fixed inset-0 bg-black/85 z-50 flex items-center justify-center p-4 cursor-pointer"
|
|
48
|
+
onClick={(e) => { if (e.target === overlayRef.current) onClose() }}
|
|
49
|
+
>
|
|
50
|
+
<div className="relative max-w-[95vw] max-h-[95vh]">
|
|
51
|
+
<button
|
|
52
|
+
onClick={onClose}
|
|
53
|
+
className="absolute -top-8 right-0 text-white/70 hover:text-white text-sm cursor-pointer"
|
|
54
|
+
>
|
|
55
|
+
ESC to close
|
|
56
|
+
</button>
|
|
57
|
+
<img src={src} alt={alt} className="max-w-full max-h-[90vh] object-contain rounded shadow-2xl" />
|
|
58
|
+
<div className="text-center mt-2 text-white/60 text-xs">{alt}</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function StatusBadge({ status }: { status: string }) {
|
|
65
|
+
const style = STATUS_STYLES[status] || STATUS_STYLES.in_progress
|
|
66
|
+
return (
|
|
67
|
+
<span className={cn("text-[10px] font-bold px-1.5 py-0.5 rounded", style.bg, style.text)}>
|
|
68
|
+
{style.label}
|
|
69
|
+
</span>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function WalkthroughPage() {
|
|
74
|
+
const [walkthroughs, setWalkthroughs] = useState<Walkthrough[]>([])
|
|
75
|
+
const [activeId, setActiveId] = useState<number | null>(null)
|
|
76
|
+
const [detail, setDetail] = useState<WalkthroughDetail | null>(null)
|
|
77
|
+
const [loading, setLoading] = useState(false)
|
|
78
|
+
const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null)
|
|
79
|
+
const sidebarRef = useRef<HTMLDivElement>(null)
|
|
80
|
+
const sidebarAnimated = useRef(false)
|
|
81
|
+
|
|
82
|
+
// Fetch list
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
fetch("/api/walkthroughs")
|
|
85
|
+
.then((r) => r.json())
|
|
86
|
+
.then((data) => {
|
|
87
|
+
const wts = data.walkthroughs || []
|
|
88
|
+
setWalkthroughs(wts)
|
|
89
|
+
if (wts.length > 0 && activeId === null) {
|
|
90
|
+
setActiveId(wts[0].id)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {})
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
// Animate sidebar entries on first load
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!sidebarRef.current || sidebarAnimated.current || walkthroughs.length === 0) return
|
|
99
|
+
sidebarAnimated.current = true
|
|
100
|
+
const items = sidebarRef.current.querySelectorAll("button")
|
|
101
|
+
gsap.fromTo(items, { opacity: 0, x: -8 }, { opacity: 1, x: 0, duration: 0.25, stagger: 0.03, ease: "power2.out" })
|
|
102
|
+
}, [walkthroughs.length > 0])
|
|
103
|
+
|
|
104
|
+
// Fetch detail
|
|
105
|
+
const loadDetail = useCallback((id: number) => {
|
|
106
|
+
setLoading(true)
|
|
107
|
+
fetch(`/api/walkthroughs/${id}`)
|
|
108
|
+
.then((r) => r.json())
|
|
109
|
+
.then((data) => {
|
|
110
|
+
setDetail(data)
|
|
111
|
+
setLoading(false)
|
|
112
|
+
})
|
|
113
|
+
.catch(() => setLoading(false))
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (activeId !== null) loadDetail(activeId)
|
|
118
|
+
}, [activeId, loadDetail])
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex flex-1 overflow-hidden">
|
|
122
|
+
{/* Left sidebar — walkthrough list */}
|
|
123
|
+
<div ref={sidebarRef} className="w-[300px] min-w-[260px] border-r border-border flex flex-col overflow-hidden flex-shrink-0 bg-bg">
|
|
124
|
+
<div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10 flex-shrink-0">
|
|
125
|
+
<span className="text-text-dim text-[10px]">
|
|
126
|
+
<span className="text-accent">$</span> walkthroughs/
|
|
127
|
+
</span>
|
|
128
|
+
<span className="text-[10px] text-text-dim">{walkthroughs.length} runs</span>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="flex-1 overflow-y-auto">
|
|
132
|
+
{walkthroughs.length === 0 ? (
|
|
133
|
+
<div className="p-4 text-xs text-text-dim">
|
|
134
|
+
No walkthrough runs yet.
|
|
135
|
+
<div className="mt-2 text-[10px]">
|
|
136
|
+
Use <code className="text-accent">log_walkthrough_start</code> to create one.
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
) : (
|
|
140
|
+
walkthroughs.map((wt) => (
|
|
141
|
+
<button
|
|
142
|
+
key={wt.id}
|
|
143
|
+
onClick={() => setActiveId(wt.id)}
|
|
144
|
+
className={cn(
|
|
145
|
+
"w-full text-left px-4 py-2.5 border-b border-border transition-colors cursor-pointer",
|
|
146
|
+
activeId === wt.id
|
|
147
|
+
? "bg-bg-elevated border-l-2 border-l-accent"
|
|
148
|
+
: "hover:bg-bg-elevated border-l-2 border-l-transparent"
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex items-center gap-2">
|
|
152
|
+
<StatusBadge status={wt.status} />
|
|
153
|
+
<span className="text-xs text-text truncate font-mono">
|
|
154
|
+
{wt.scenario || wt.app_name || `#${wt.id}`}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="text-[10px] text-text-dim mt-1 truncate">{wt.url}</div>
|
|
158
|
+
<div className="flex items-center gap-2 mt-1 text-[10px] text-text-muted">
|
|
159
|
+
<span>{timeAgo(wt.started_at)}</span>
|
|
160
|
+
{wt.total_actions > 0 && (
|
|
161
|
+
<>
|
|
162
|
+
<span className="text-border-bright">|</span>
|
|
163
|
+
<span>
|
|
164
|
+
<span className="text-success">{wt.passed}</span>
|
|
165
|
+
/
|
|
166
|
+
<span className="text-danger">{wt.failed}</span>
|
|
167
|
+
/{wt.total_actions}
|
|
168
|
+
</span>
|
|
169
|
+
</>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
</button>
|
|
173
|
+
))
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Right panel — detail */}
|
|
179
|
+
<div className="flex-1 overflow-y-auto">
|
|
180
|
+
{activeId === null ? (
|
|
181
|
+
<div className="h-full flex items-center justify-center text-text-dim text-xs">
|
|
182
|
+
Select a walkthrough run
|
|
183
|
+
</div>
|
|
184
|
+
) : loading ? (
|
|
185
|
+
<div className="h-full flex items-center justify-center text-accent text-xs animate-pulse">
|
|
186
|
+
Loading...
|
|
187
|
+
</div>
|
|
188
|
+
) : detail ? (
|
|
189
|
+
<WalkthroughDetailView
|
|
190
|
+
detail={detail}
|
|
191
|
+
onImageClick={(src, alt) => setLightbox({ src, alt })}
|
|
192
|
+
/>
|
|
193
|
+
) : null}
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{lightbox && (
|
|
197
|
+
<ImageLightbox
|
|
198
|
+
src={lightbox.src}
|
|
199
|
+
alt={lightbox.alt}
|
|
200
|
+
onClose={() => setLightbox(null)}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function WalkthroughDetailView({
|
|
208
|
+
detail,
|
|
209
|
+
onImageClick,
|
|
210
|
+
}: {
|
|
211
|
+
detail: WalkthroughDetail
|
|
212
|
+
onImageClick: (src: string, alt: string) => void
|
|
213
|
+
}) {
|
|
214
|
+
const { walkthrough: wt, actions, screenshots } = detail
|
|
215
|
+
const detailRef = useRef<HTMLDivElement>(null)
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!detailRef.current) return
|
|
219
|
+
gsap.fromTo(
|
|
220
|
+
detailRef.current.children,
|
|
221
|
+
{ opacity: 0, y: 8 },
|
|
222
|
+
{ opacity: 1, y: 0, duration: 0.3, stagger: 0.05, ease: "power2.out" }
|
|
223
|
+
)
|
|
224
|
+
}, [wt.id])
|
|
225
|
+
|
|
226
|
+
// Build a map of action_id → screenshots for inline display
|
|
227
|
+
const screenshotsByAction = new Map<number, WalkthroughScreenshot[]>()
|
|
228
|
+
const unlinkedScreenshots: WalkthroughScreenshot[] = []
|
|
229
|
+
for (const ss of screenshots) {
|
|
230
|
+
if (ss.action_id) {
|
|
231
|
+
const list = screenshotsByAction.get(ss.action_id) || []
|
|
232
|
+
list.push(ss)
|
|
233
|
+
screenshotsByAction.set(ss.action_id, list)
|
|
234
|
+
} else {
|
|
235
|
+
unlinkedScreenshots.push(ss)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div ref={detailRef} className="p-6">
|
|
241
|
+
{/* Header */}
|
|
242
|
+
<div className="mb-6">
|
|
243
|
+
<div className="flex items-center gap-3 mb-2">
|
|
244
|
+
<StatusBadge status={wt.status} />
|
|
245
|
+
<h2 className="text-lg font-bold text-text font-mono">
|
|
246
|
+
{wt.scenario || wt.app_name || `Walkthrough #${wt.id}`}
|
|
247
|
+
</h2>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="text-xs text-text-dim">{wt.url}</div>
|
|
250
|
+
<div className="text-[10px] text-text-muted mt-1">
|
|
251
|
+
Started {timeAgo(wt.started_at)}
|
|
252
|
+
{wt.ended_at && <> · Ended {timeAgo(wt.ended_at)}</>}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Stats bar */}
|
|
256
|
+
{wt.total_actions > 0 && (
|
|
257
|
+
<div className="flex items-center gap-4 mt-3 text-xs">
|
|
258
|
+
<span className="text-text-muted">
|
|
259
|
+
<span className="font-bold text-text">{wt.total_actions}</span> actions
|
|
260
|
+
</span>
|
|
261
|
+
<span className="text-success">
|
|
262
|
+
<span className="font-bold">{wt.passed}</span> passed
|
|
263
|
+
</span>
|
|
264
|
+
<span className="text-danger">
|
|
265
|
+
<span className="font-bold">{wt.failed}</span> failed
|
|
266
|
+
</span>
|
|
267
|
+
{wt.total_actions > 0 && (
|
|
268
|
+
<div className="flex-1 max-w-48 h-1.5 bg-bg rounded overflow-hidden">
|
|
269
|
+
<div
|
|
270
|
+
className="h-full bg-success rounded"
|
|
271
|
+
style={{ width: `${(wt.passed / wt.total_actions) * 100}%` }}
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Summary */}
|
|
279
|
+
{wt.summary && (
|
|
280
|
+
<div className="mt-3 text-xs text-text bg-bg-elevated rounded p-3 border border-border">
|
|
281
|
+
{wt.summary}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{/* Action timeline */}
|
|
287
|
+
{actions.length > 0 && (
|
|
288
|
+
<div className="mb-6">
|
|
289
|
+
<h3 className="text-[10px] text-text-dim uppercase tracking-wider font-bold mb-3">
|
|
290
|
+
Action Timeline ({actions.length})
|
|
291
|
+
</h3>
|
|
292
|
+
<div className="space-y-0.5">
|
|
293
|
+
{actions.map((action, idx) => {
|
|
294
|
+
const actionScreenshots = screenshotsByAction.get(action.id) || []
|
|
295
|
+
return (
|
|
296
|
+
<ActionRow
|
|
297
|
+
key={action.id}
|
|
298
|
+
action={action}
|
|
299
|
+
index={idx}
|
|
300
|
+
screenshots={actionScreenshots}
|
|
301
|
+
walkthroughId={wt.id}
|
|
302
|
+
onImageClick={onImageClick}
|
|
303
|
+
isLast={idx === actions.length - 1}
|
|
304
|
+
/>
|
|
305
|
+
)
|
|
306
|
+
})}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
|
|
311
|
+
{/* Unlinked screenshots gallery */}
|
|
312
|
+
{unlinkedScreenshots.length > 0 && (
|
|
313
|
+
<div>
|
|
314
|
+
<h3 className="text-[10px] text-text-dim uppercase tracking-wider font-bold mb-3">
|
|
315
|
+
Screenshots ({unlinkedScreenshots.length})
|
|
316
|
+
</h3>
|
|
317
|
+
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
318
|
+
{unlinkedScreenshots.map((ss) => {
|
|
319
|
+
const src = screenshotUrl(wt.id, ss.id)
|
|
320
|
+
return (
|
|
321
|
+
<div
|
|
322
|
+
key={ss.id}
|
|
323
|
+
className="group cursor-pointer rounded overflow-hidden border border-border hover:border-accent transition-colors bg-bg"
|
|
324
|
+
onClick={() => onImageClick(src, ss.annotation || ss.name)}
|
|
325
|
+
>
|
|
326
|
+
<div className="aspect-video bg-bg-elevated overflow-hidden">
|
|
327
|
+
<img
|
|
328
|
+
src={src}
|
|
329
|
+
alt={ss.name}
|
|
330
|
+
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
331
|
+
loading="lazy"
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
<div className="p-2">
|
|
335
|
+
<div className="text-[11px] text-text truncate">{ss.name}</div>
|
|
336
|
+
{ss.annotation && (
|
|
337
|
+
<div className="text-[10px] text-text-dim truncate">{ss.annotation}</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
})}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{actions.length === 0 && screenshots.length === 0 && (
|
|
348
|
+
<div className="text-center text-text-dim text-xs py-12">
|
|
349
|
+
No actions or screenshots recorded yet.
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function ActionRow({
|
|
357
|
+
action,
|
|
358
|
+
index,
|
|
359
|
+
screenshots,
|
|
360
|
+
walkthroughId,
|
|
361
|
+
onImageClick,
|
|
362
|
+
isLast,
|
|
363
|
+
}: {
|
|
364
|
+
action: WalkthroughAction
|
|
365
|
+
index: number
|
|
366
|
+
screenshots: WalkthroughScreenshot[]
|
|
367
|
+
walkthroughId: number
|
|
368
|
+
onImageClick: (src: string, alt: string) => void
|
|
369
|
+
isLast: boolean
|
|
370
|
+
}) {
|
|
371
|
+
const isFail = action.status === "fail"
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<div className="flex gap-3">
|
|
375
|
+
{/* Timeline gutter */}
|
|
376
|
+
<div className="flex flex-col items-center flex-shrink-0 w-7">
|
|
377
|
+
<div
|
|
378
|
+
className={cn(
|
|
379
|
+
"w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold border",
|
|
380
|
+
isFail
|
|
381
|
+
? "border-danger text-danger bg-danger/10"
|
|
382
|
+
: "border-accent/50 text-accent bg-accent/10"
|
|
383
|
+
)}
|
|
384
|
+
>
|
|
385
|
+
{index + 1}
|
|
386
|
+
</div>
|
|
387
|
+
{!isLast && <div className="w-px flex-1 bg-border" />}
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{/* Content */}
|
|
391
|
+
<div className={cn("flex-1 pb-3", isLast ? "" : "")}>
|
|
392
|
+
<div
|
|
393
|
+
className={cn(
|
|
394
|
+
"rounded px-3 py-2 text-xs",
|
|
395
|
+
isFail ? "bg-danger/5 border border-danger/20" : "bg-bg-elevated"
|
|
396
|
+
)}
|
|
397
|
+
>
|
|
398
|
+
<div className="flex items-center gap-2">
|
|
399
|
+
<span className="text-[10px] font-mono text-text-dim w-5 text-center flex-shrink-0">
|
|
400
|
+
{ACTION_ICONS[action.action_type] || "??"}
|
|
401
|
+
</span>
|
|
402
|
+
<span className={cn("font-bold text-[10px] uppercase", isFail ? "text-danger" : "text-text-muted")}>
|
|
403
|
+
{action.action_type}
|
|
404
|
+
</span>
|
|
405
|
+
<span className="text-text font-mono truncate flex-1">{action.target}</span>
|
|
406
|
+
<span className={cn("text-[10px] font-bold", isFail ? "text-danger" : "text-success")}>
|
|
407
|
+
{action.status.toUpperCase()}
|
|
408
|
+
</span>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{action.value && (
|
|
412
|
+
<div className="mt-1 text-[10px] text-text-muted ml-7">
|
|
413
|
+
value: <span className="text-text font-mono">{action.value}</span>
|
|
414
|
+
</div>
|
|
415
|
+
)}
|
|
416
|
+
|
|
417
|
+
{action.message && (
|
|
418
|
+
<div className={cn("mt-1 text-[10px] ml-7", isFail ? "text-danger" : "text-text-dim")}>
|
|
419
|
+
{action.message}
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
<div className="text-[9px] text-text-dim mt-1 ml-7">
|
|
424
|
+
{timeAgo(action.timestamp)}
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
{/* Inline screenshots for this action */}
|
|
429
|
+
{screenshots.length > 0 && (
|
|
430
|
+
<div className="mt-2 flex gap-2 flex-wrap ml-7">
|
|
431
|
+
{screenshots.map((ss) => {
|
|
432
|
+
const src = screenshotUrl(walkthroughId, ss.id)
|
|
433
|
+
return (
|
|
434
|
+
<div
|
|
435
|
+
key={ss.id}
|
|
436
|
+
className="cursor-pointer rounded overflow-hidden border border-border hover:border-accent transition-colors"
|
|
437
|
+
onClick={() => onImageClick(src, ss.annotation || ss.name)}
|
|
438
|
+
>
|
|
439
|
+
<img
|
|
440
|
+
src={src}
|
|
441
|
+
alt={ss.name}
|
|
442
|
+
className="h-24 w-auto object-cover"
|
|
443
|
+
loading="lazy"
|
|
444
|
+
/>
|
|
445
|
+
{ss.annotation && (
|
|
446
|
+
<div className="text-[9px] text-text-dim px-1.5 py-0.5 bg-bg-elevated truncate max-w-32">
|
|
447
|
+
{ss.annotation}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
)
|
|
452
|
+
})}
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
)
|
|
458
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react"
|
|
2
|
+
import { apiFetch } from "@/lib/api"
|
|
3
|
+
import type { Session, SessionDetail, Stats } from "@/types"
|
|
4
|
+
|
|
5
|
+
export function useStats() {
|
|
6
|
+
const [stats, setStats] = useState<Stats | null>(null)
|
|
7
|
+
|
|
8
|
+
const reload = useCallback(() => {
|
|
9
|
+
apiFetch("/api/stats")
|
|
10
|
+
.then((r) => r.json())
|
|
11
|
+
.then(setStats)
|
|
12
|
+
.catch(console.error)
|
|
13
|
+
}, [])
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
reload()
|
|
17
|
+
}, [reload])
|
|
18
|
+
|
|
19
|
+
return { stats, reload }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useSessions(filter: string) {
|
|
23
|
+
const [sessions, setSessions] = useState<Session[]>([])
|
|
24
|
+
const [loading, setLoading] = useState(true)
|
|
25
|
+
|
|
26
|
+
const reload = useCallback(() => {
|
|
27
|
+
let url = "/api/sessions?limit=100"
|
|
28
|
+
if (filter) url += "&status=" + filter
|
|
29
|
+
apiFetch(url)
|
|
30
|
+
.then((r) => r.json())
|
|
31
|
+
.then((data) => {
|
|
32
|
+
setSessions(data)
|
|
33
|
+
setLoading(false)
|
|
34
|
+
})
|
|
35
|
+
.catch(() => setLoading(false))
|
|
36
|
+
}, [filter])
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
setLoading(true)
|
|
40
|
+
reload()
|
|
41
|
+
}, [reload])
|
|
42
|
+
|
|
43
|
+
return { sessions, loading, reload }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useSessionDetail(id: string | null) {
|
|
47
|
+
const [detail, setDetail] = useState<SessionDetail | null>(null)
|
|
48
|
+
const [loading, setLoading] = useState(false)
|
|
49
|
+
const currentId = useRef(id)
|
|
50
|
+
currentId.current = id
|
|
51
|
+
|
|
52
|
+
const reload = useCallback(() => {
|
|
53
|
+
if (!currentId.current) return
|
|
54
|
+
apiFetch("/api/sessions/" + currentId.current)
|
|
55
|
+
.then((r) => r.json())
|
|
56
|
+
.then((data) => {
|
|
57
|
+
// Only update if we're still viewing the same session
|
|
58
|
+
if (currentId.current === data.session?.id) {
|
|
59
|
+
setDetail(data)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.catch(() => {})
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!id) {
|
|
67
|
+
setDetail(null)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
setLoading(true)
|
|
71
|
+
apiFetch("/api/sessions/" + id)
|
|
72
|
+
.then((r) => r.json())
|
|
73
|
+
.then((data) => {
|
|
74
|
+
setDetail(data)
|
|
75
|
+
setLoading(false)
|
|
76
|
+
})
|
|
77
|
+
.catch(() => setLoading(false))
|
|
78
|
+
}, [id])
|
|
79
|
+
|
|
80
|
+
return { detail, loading, reload }
|
|
81
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export interface AuthUser {
|
|
4
|
+
id: string;
|
|
5
|
+
email: string;
|
|
6
|
+
name: string;
|
|
7
|
+
avatar_url: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface UseAuthReturn {
|
|
11
|
+
user: AuthUser | null;
|
|
12
|
+
loading: boolean;
|
|
13
|
+
authEnabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useAuth(): UseAuthReturn {
|
|
17
|
+
const [user, setUser] = useState<AuthUser | null>(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [authEnabled, setAuthEnabled] = useState(true);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
fetch("/auth/me")
|
|
23
|
+
.then((r) => {
|
|
24
|
+
if (r.status === 401) {
|
|
25
|
+
setUser(null);
|
|
26
|
+
setAuthEnabled(true);
|
|
27
|
+
setLoading(false);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (r.status === 404) {
|
|
31
|
+
// /auth/me doesn't exist — auth is disabled
|
|
32
|
+
setUser(null);
|
|
33
|
+
setAuthEnabled(false);
|
|
34
|
+
setLoading(false);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
return r.json();
|
|
38
|
+
})
|
|
39
|
+
.then((data) => {
|
|
40
|
+
if (data) {
|
|
41
|
+
setUser(data);
|
|
42
|
+
setAuthEnabled(true);
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
// Network error or auth not configured — assume auth disabled
|
|
48
|
+
setAuthEnabled(false);
|
|
49
|
+
setLoading(false);
|
|
50
|
+
});
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
return { user, loading, authEnabled };
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react"
|
|
2
|
+
import { apiFetch } from "@/lib/api"
|
|
3
|
+
import type { Insight } from "@/types"
|
|
4
|
+
|
|
5
|
+
interface KnowledgeData {
|
|
6
|
+
insights: Insight[]
|
|
7
|
+
categories: Array<{ category: string; count: number; avg_confidence: number }>
|
|
8
|
+
total: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function useKnowledge(category?: string) {
|
|
12
|
+
const [data, setData] = useState<KnowledgeData | null>(null)
|
|
13
|
+
const [loading, setLoading] = useState(true)
|
|
14
|
+
|
|
15
|
+
const reload = useCallback(() => {
|
|
16
|
+
setLoading(true)
|
|
17
|
+
let url = "/api/knowledge?limit=100"
|
|
18
|
+
if (category) url += "&category=" + encodeURIComponent(category)
|
|
19
|
+
apiFetch(url)
|
|
20
|
+
.then((r) => r.json())
|
|
21
|
+
.then((d) => {
|
|
22
|
+
setData(d)
|
|
23
|
+
setLoading(false)
|
|
24
|
+
})
|
|
25
|
+
.catch(() => setLoading(false))
|
|
26
|
+
}, [category])
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
reload()
|
|
30
|
+
}, [reload])
|
|
31
|
+
|
|
32
|
+
return { data, loading, reload }
|
|
33
|
+
}
|