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,265 @@
1
+ import { useRef, useEffect } from "react"
2
+ import gsap from "gsap"
3
+ import type { SessionDetail as SessionDetailType, FinalResult } from "@/types"
4
+ import { formatDuration, formatTokens, cn } from "@/lib/utils"
5
+ import { Timeline } from "./Timeline"
6
+ import { IterationsTable } from "./IterationsTable"
7
+ import { ScreenshotsGrid } from "./ScreenshotsGrid"
8
+ import { InsightsPanel } from "./InsightsPanel"
9
+ import { PlanProgress } from "./PlanProgress"
10
+ import { ChildStreams } from "./ChildStreams"
11
+ import { StreamComms } from "./StreamComms"
12
+ import { ScreenshotUpload } from "./ScreenshotUpload"
13
+ import { ArtifactsPanel } from "./ArtifactsPanel"
14
+ import { QualityReport } from "./QualityReport"
15
+
16
+ interface Props {
17
+ detail: SessionDetailType | null
18
+ loading: boolean
19
+ onSelectSession?: (id: string) => void
20
+ onReload?: () => void
21
+ }
22
+
23
+ export function SessionDetail({ detail, loading, onSelectSession, onReload }: Props) {
24
+ const ref = useRef<HTMLDivElement>(null)
25
+ const prevSessionId = useRef<string | null>(null)
26
+
27
+ useEffect(() => {
28
+ if (!ref.current || !detail) return
29
+ // Only animate on session change, not on live refreshes
30
+ if (detail.session.id === prevSessionId.current) return
31
+ prevSessionId.current = detail.session.id
32
+ gsap.fromTo(
33
+ ref.current.children,
34
+ { opacity: 0, y: 16 },
35
+ { opacity: 1, y: 0, duration: 0.5, stagger: 0.1, ease: "power2.out" }
36
+ )
37
+ }, [detail])
38
+
39
+ if (loading) {
40
+ return (
41
+ <div className="flex-1 flex items-center justify-center text-text-dim text-xs">
42
+ <pre>
43
+ {`> loading session data...
44
+ > ████████░░░░░░░░ 50%`}
45
+ </pre>
46
+ </div>
47
+ )
48
+ }
49
+
50
+ if (!detail) {
51
+ return (
52
+ <div className="flex-1 flex items-center justify-center text-text-dim text-xs">
53
+ <pre className="text-center">
54
+ {`╔══════════════════════════════╗
55
+ ║ ║
56
+ ║ select a session to view ║
57
+ ║ session data ║
58
+ ║ ║
59
+ ║ ← click a session ║
60
+ ║ ║
61
+ ╚══════════════════════════════╝`}
62
+ </pre>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ const s = detail.session
68
+ const totalTokens = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
69
+ let finalResult: FinalResult | null = null
70
+ try {
71
+ if (s.final_result) finalResult = JSON.parse(s.final_result)
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+
76
+ return (
77
+ <div ref={ref} className="flex-1 overflow-y-auto p-5 space-y-4">
78
+ {/* Session header */}
79
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
80
+ <div className="flex items-center gap-3 mb-3">
81
+ <StatusBadge status={s.status} />
82
+ <h2 className="text-base font-bold text-text">{s.worktree_name}</h2>
83
+ </div>
84
+
85
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
86
+ {s.ticket_id && (
87
+ <MetaField label="TICKET" value={`${s.ticket_id}${s.ticket_title ? " — " + s.ticket_title : ""}`} />
88
+ )}
89
+ {s.branch_name && (
90
+ <MetaField label="BRANCH" value={s.branch_name} />
91
+ )}
92
+ <MetaField label="MODE" value={s.mode.toUpperCase()} />
93
+ <MetaField label="DURATION" value={formatDuration(s.duration_seconds)} />
94
+ <MetaField label="TOKENS" value={formatTokens(totalTokens)} />
95
+ <MetaField
96
+ label="ITERATIONS"
97
+ value={`${s.total_iterations}/${s.max_iterations}`}
98
+ />
99
+ </div>
100
+ </div>
101
+
102
+ {/* Quality report */}
103
+ <QualityReport events={detail.events} />
104
+
105
+ {/* Child streams */}
106
+ {detail.child_sessions && detail.child_sessions.length > 0 && (
107
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
108
+ <SectionHeader title={`CHILD STREAMS (${detail.child_sessions.length})`} />
109
+ <ChildStreams
110
+ children={detail.child_sessions}
111
+ onSelectSession={onSelectSession || (() => {})}
112
+ />
113
+ </div>
114
+ )}
115
+
116
+ {/* Stream communications */}
117
+ {detail.child_sessions && detail.child_sessions.length > 0 && (
118
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
119
+ <SectionHeader title="STREAM COMMS" />
120
+ <StreamComms childSessions={detail.child_sessions} parentSessionId={detail.session.id} />
121
+ </div>
122
+ )}
123
+
124
+ {/* Final result */}
125
+ {finalResult && (
126
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
127
+ <SectionHeader title="FINAL RESULT" />
128
+ <div className="flex gap-3 flex-wrap mt-2">
129
+ {finalResult.tests_passed !== undefined && (
130
+ <ResultBadge
131
+ label="TESTS"
132
+ pass={finalResult.tests_passed}
133
+ />
134
+ )}
135
+ {finalResult.lint_passed !== undefined && (
136
+ <ResultBadge
137
+ label="LINT"
138
+ pass={finalResult.lint_passed}
139
+ />
140
+ )}
141
+ {finalResult.typecheck_passed !== undefined && (
142
+ <ResultBadge
143
+ label="TYPECHECK"
144
+ pass={finalResult.typecheck_passed}
145
+ />
146
+ )}
147
+ {finalResult.pr_url && (
148
+ <a
149
+ href={finalResult.pr_url}
150
+ target="_blank"
151
+ rel="noopener noreferrer"
152
+ className="text-xs text-info hover:underline"
153
+ >
154
+ PR #{finalResult.pr_number || ""}
155
+ </a>
156
+ )}
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
+ {/* Plan */}
162
+ {detail.plan_steps && detail.plan_steps.length > 0 && (
163
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
164
+ <SectionHeader title={`PLAN (${detail.plan_steps.filter(s => s.status === "completed").length}/${detail.plan_steps.length} complete)`} />
165
+ <PlanProgress steps={detail.plan_steps} />
166
+ </div>
167
+ )}
168
+
169
+ {/* Timeline */}
170
+ {detail.events.length > 0 && (
171
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
172
+ <SectionHeader title={`TIMELINE (${detail.events.length} events)`} />
173
+ <Timeline events={detail.events} sessionStart={s.started_at} screenshots={detail.screenshots} />
174
+ </div>
175
+ )}
176
+
177
+ {/* Iterations */}
178
+ {detail.iterations.length > 0 && (
179
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
180
+ <SectionHeader title={`ITERATIONS (${detail.iterations.length})`} />
181
+ <IterationsTable iterations={detail.iterations} />
182
+ </div>
183
+ )}
184
+
185
+ {/* Screenshots */}
186
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
187
+ <SectionHeader title={`SCREENSHOTS (${detail.screenshots.length})`} />
188
+ {detail.screenshots.length > 0 && (
189
+ <ScreenshotsGrid screenshots={detail.screenshots} />
190
+ )}
191
+ <ScreenshotUpload sessionId={s.id} onUploaded={() => onReload?.()} />
192
+ </div>
193
+
194
+ {/* Artifacts */}
195
+ {detail.artifacts && detail.artifacts.length > 0 && (
196
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
197
+ <SectionHeader title={`ARTIFACTS (${detail.artifacts.length})`} />
198
+ <ArtifactsPanel artifacts={detail.artifacts} />
199
+ </div>
200
+ )}
201
+
202
+ {/* Insights — post-game analysis */}
203
+ {detail.insights && detail.insights.length > 0 && (
204
+ <div className="border border-border rounded-md p-4 bg-bg-elevated">
205
+ <SectionHeader title={`INSIGHTS (${detail.insights.length})`} />
206
+ <InsightsPanel insights={detail.insights} />
207
+ </div>
208
+ )}
209
+ </div>
210
+ )
211
+ }
212
+
213
+ function SectionHeader({ title }: { title: string }) {
214
+ return (
215
+ <div className="text-[10px] text-text-muted uppercase tracking-widest mb-2 font-bold">
216
+ <span className="text-accent mr-1">▸</span>
217
+ {title}
218
+ </div>
219
+ )
220
+ }
221
+
222
+ function MetaField({ label, value }: { label: string; value: string }) {
223
+ return (
224
+ <div>
225
+ <div className="text-[9px] text-text-dim uppercase tracking-wider mb-0.5">
226
+ {label}
227
+ </div>
228
+ <div className="text-xs text-text truncate">{value}</div>
229
+ </div>
230
+ )
231
+ }
232
+
233
+ function StatusBadge({ status }: { status: string }) {
234
+ const styles: Record<string, string> = {
235
+ completed: "border-accent text-accent",
236
+ failed: "border-error text-error",
237
+ in_progress: "border-warning text-warning",
238
+ interrupted: "border-text-dim text-text-dim",
239
+ }
240
+ return (
241
+ <span
242
+ className={cn(
243
+ "text-[10px] px-2 py-0.5 border rounded font-bold uppercase tracking-wider",
244
+ styles[status] || "border-text-dim text-text-dim"
245
+ )}
246
+ >
247
+ {status === "in_progress" ? "LIVE" : status}
248
+ </span>
249
+ )
250
+ }
251
+
252
+ function ResultBadge({ label, pass }: { label: string; pass: boolean }) {
253
+ return (
254
+ <span
255
+ className={cn(
256
+ "text-[10px] px-2 py-0.5 border rounded font-bold tracking-wider",
257
+ pass
258
+ ? "border-accent text-accent"
259
+ : "border-error text-error"
260
+ )}
261
+ >
262
+ {label}: {pass ? "PASS" : "FAIL"}
263
+ </span>
264
+ )
265
+ }
@@ -0,0 +1,234 @@
1
+ import { useRef, useEffect, useState, useMemo } from "react"
2
+ import gsap from "gsap"
3
+ import type { Session } from "@/types"
4
+ import { formatDuration, formatTokens, cn } from "@/lib/utils"
5
+
6
+ const FILTERS = [
7
+ { label: "ALL", value: "" },
8
+ { label: "PASS", value: "completed" },
9
+ { label: "FAIL", value: "failed" },
10
+ { label: "LIVE", value: "in_progress" },
11
+ ]
12
+
13
+ interface Props {
14
+ sessions: Session[]
15
+ activeId: string | null
16
+ filter: string
17
+ onSelect: (id: string) => void
18
+ onFilterChange: (f: string) => void
19
+ }
20
+
21
+ interface WorktreeGroup {
22
+ name: string
23
+ sessions: Session[]
24
+ latest: Session
25
+ hasLive: boolean
26
+ }
27
+
28
+ function workLabel(s: Session): string {
29
+ if (s.ticket_title) return s.ticket_title
30
+ if (s.ticket_id) return s.ticket_id
31
+ // Fall back to branch name or a short id
32
+ if (s.branch_name) return s.branch_name
33
+ return s.started_at.slice(0, 16).replace("T", " ")
34
+ }
35
+
36
+ export function SessionList({
37
+ sessions,
38
+ activeId,
39
+ filter,
40
+ onSelect,
41
+ onFilterChange,
42
+ }: Props) {
43
+ const listRef = useRef<HTMLDivElement>(null)
44
+ const prevCountRef = useRef(0)
45
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
46
+
47
+ // Auto-expand the group containing the active session
48
+ useEffect(() => {
49
+ if (!activeId) return
50
+ const session = sessions.find((s) => s.id === activeId)
51
+ if (session) {
52
+ setExpandedGroups((prev) => new Set([...prev, session.worktree_name]))
53
+ }
54
+ }, [activeId, sessions])
55
+
56
+ const groups = useMemo((): WorktreeGroup[] => {
57
+ const map = new Map<string, Session[]>()
58
+ for (const s of sessions) {
59
+ if (s.parent_session_id) continue
60
+ const existing = map.get(s.worktree_name) || []
61
+ existing.push(s)
62
+ map.set(s.worktree_name, existing)
63
+ }
64
+ return Array.from(map.entries())
65
+ .map(([name, sess]) => ({
66
+ name,
67
+ sessions: sess.sort((a, b) => b.created_at.localeCompare(a.created_at)),
68
+ latest: sess[0],
69
+ hasLive: sess.some((s) => s.status === "in_progress"),
70
+ }))
71
+ .sort((a, b) => b.latest.created_at.localeCompare(a.latest.created_at))
72
+ }, [sessions])
73
+
74
+ useEffect(() => {
75
+ if (!listRef.current) return
76
+ if (sessions.length === prevCountRef.current) return
77
+ const isInitial = prevCountRef.current === 0
78
+ prevCountRef.current = sessions.length
79
+ if (isInitial) {
80
+ const items = listRef.current.querySelectorAll(".session-item, .group-header")
81
+ gsap.fromTo(
82
+ items,
83
+ { opacity: 0, x: -12 },
84
+ { opacity: 1, x: 0, duration: 0.3, stagger: 0.04, ease: "power2.out" }
85
+ )
86
+ }
87
+ }, [sessions])
88
+
89
+ const toggleGroup = (name: string) => {
90
+ setExpandedGroups((prev) => {
91
+ const next = new Set(prev)
92
+ if (next.has(name)) next.delete(name)
93
+ else next.add(name)
94
+ return next
95
+ })
96
+ }
97
+
98
+ return (
99
+ <div className="w-[300px] min-w-[260px] border-r border-border flex flex-col flex-shrink-0">
100
+ {/* Filter bar */}
101
+ <div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10">
102
+ <span className="text-text-dim text-[10px]">
103
+ <span className="text-accent">$</span> sessions/
104
+ </span>
105
+ <div className="flex gap-1">
106
+ {FILTERS.map((f) => (
107
+ <button
108
+ key={f.value}
109
+ onClick={() => onFilterChange(f.value)}
110
+ className={cn(
111
+ "px-2 py-0.5 text-[10px] border font-mono transition-colors",
112
+ filter === f.value
113
+ ? "border-accent text-accent bg-accent/5"
114
+ : "border-border text-text-dim hover:border-border-bright hover:text-text-muted"
115
+ )}
116
+ >
117
+ {f.label}
118
+ </button>
119
+ ))}
120
+ </div>
121
+ </div>
122
+
123
+ {/* Sessions */}
124
+ <div ref={listRef} className="flex-1 overflow-y-auto">
125
+ {sessions.length === 0 && (
126
+ <div className="p-8 text-center text-text-dim text-xs">
127
+ <pre className="inline-block text-left">
128
+ {`> no sessions found
129
+ > waiting for data...
130
+ > _`}
131
+ </pre>
132
+ </div>
133
+ )}
134
+
135
+ {groups.map((group) => {
136
+ const isExpanded = expandedGroups.has(group.name)
137
+ return (
138
+ <div key={group.name}>
139
+ {/* Group header — workspace name */}
140
+ <div
141
+ className={cn(
142
+ "group-header px-4 py-2 border-b border-border border-l-2 cursor-pointer hover:bg-bg-hover flex items-center gap-2",
143
+ group.hasLive ? "border-l-warning" : isExpanded ? "border-l-accent/40" : "border-l-transparent"
144
+ )}
145
+ onClick={() => toggleGroup(group.name)}
146
+ >
147
+ <span className="text-[10px] text-text-dim w-3">
148
+ {isExpanded ? "\u25BE" : "\u25B8"}
149
+ </span>
150
+ <StatusIndicator status={group.latest.status} />
151
+ <span className="text-[11px] font-bold text-text truncate flex-1">
152
+ {group.name}
153
+ </span>
154
+ <span className="text-[10px] text-text-dim flex-shrink-0">
155
+ {group.sessions.length}
156
+ </span>
157
+ {group.hasLive && (
158
+ <span className="inline-block w-1.5 h-1.5 bg-warning animate-pulse flex-shrink-0" />
159
+ )}
160
+ </div>
161
+
162
+ {/* Child sessions — work items */}
163
+ {isExpanded && (
164
+ <div data-expand>
165
+ {group.sessions.map((s) => (
166
+ <SessionItem
167
+ key={s.id}
168
+ session={s}
169
+ active={activeId === s.id}
170
+ onSelect={onSelect}
171
+ />
172
+ ))}
173
+ </div>
174
+ )}
175
+ </div>
176
+ )
177
+ })}
178
+ </div>
179
+ </div>
180
+ )
181
+ }
182
+
183
+ function SessionItem({
184
+ session: s,
185
+ active,
186
+ onSelect,
187
+ }: {
188
+ session: Session
189
+ active: boolean
190
+ onSelect: (id: string) => void
191
+ }) {
192
+ const totalTokens = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
193
+ return (
194
+ <div
195
+ onClick={() => onSelect(s.id)}
196
+ className={cn(
197
+ "session-item pl-9 pr-4 py-2 border-b border-border border-l-2 cursor-pointer transition-colors hover:bg-bg-hover",
198
+ active ? "bg-bg-hover border-l-accent" : "border-l-transparent"
199
+ )}
200
+ >
201
+ <div className="flex items-center gap-2 mb-0.5">
202
+ <StatusIndicator status={s.status} />
203
+ <span className="text-[11px] text-text truncate flex-1">
204
+ {workLabel(s)}
205
+ </span>
206
+ <span className="text-[10px] text-text-dim flex-shrink-0">
207
+ {formatDuration(s.duration_seconds)}
208
+ </span>
209
+ </div>
210
+ <div className="flex gap-3 text-[10px] text-text-dim pl-4">
211
+ <span>{s.total_iterations}/{s.max_iterations} iter</span>
212
+ <span>{formatTokens(totalTokens)} tok</span>
213
+ <span>{s.status}</span>
214
+ </div>
215
+ </div>
216
+ )
217
+ }
218
+
219
+ function StatusIndicator({ status }: { status: Session["status"] }) {
220
+ const colors: Record<string, string> = {
221
+ completed: "bg-accent",
222
+ failed: "bg-error",
223
+ in_progress: "bg-warning animate-pulse",
224
+ interrupted: "bg-text-dim",
225
+ }
226
+ return (
227
+ <span
228
+ className={cn(
229
+ "inline-block w-1.5 h-1.5 flex-shrink-0",
230
+ colors[status] || "bg-text-dim"
231
+ )}
232
+ />
233
+ )
234
+ }