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,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 && <> &middot; 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
+ }