agentfit 0.1.0 → 0.1.1
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/README.md +30 -34
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/(dashboard)/settings/page.tsx +180 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +25 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +258 -8
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +404 -83
- package/components/dashboard-shell.tsx +9 -24
- package/components/data-provider.tsx +66 -2
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +96 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +20 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +187 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +530 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +1 -1
- package/lib/parse-codex.ts +5 -0
- package/lib/parse-logs.ts +65 -0
- package/lib/queries-codex.ts +22 -0
- package/lib/queries.ts +42 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +77 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +16 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/schema.prisma +17 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma/migrations/migration_lock.toml +0 -3
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
|
+
import { Badge } from '@/components/ui/badge'
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
7
|
+
import type { SessionSummary } from '@/lib/parse-logs'
|
|
8
|
+
import { formatCost, formatDuration, formatNumber } from '@/lib/format'
|
|
9
|
+
|
|
10
|
+
const MODEL_COLORS: Record<string, string> = {
|
|
11
|
+
'claude-opus-4-6': 'oklch(0.65 0.12 250)',
|
|
12
|
+
'claude-sonnet-4-6': 'oklch(0.70 0.12 200)',
|
|
13
|
+
'claude-haiku-4-5': 'oklch(0.75 0.10 155)',
|
|
14
|
+
'default': 'oklch(0.70 0.08 200)',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getModelColor(model: string): string {
|
|
18
|
+
for (const [key, color] of Object.entries(MODEL_COLORS)) {
|
|
19
|
+
if (key !== 'default' && model.includes(key.replace('claude-', ''))) return color
|
|
20
|
+
}
|
|
21
|
+
return MODEL_COLORS.default
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DayRow {
|
|
25
|
+
date: string
|
|
26
|
+
sessions: SessionSummary[]
|
|
27
|
+
earliest: number // ms timestamp
|
|
28
|
+
latest: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SessionTimeline({ sessions }: { sessions: SessionSummary[] }) {
|
|
32
|
+
const [hoveredSession, setHoveredSession] = useState<string | null>(null)
|
|
33
|
+
|
|
34
|
+
const { days, dayStart, dayEnd } = useMemo(() => {
|
|
35
|
+
const dayMap = new Map<string, SessionSummary[]>()
|
|
36
|
+
for (const s of sessions) {
|
|
37
|
+
if (!s.startTime) continue
|
|
38
|
+
const date = s.startTime.slice(0, 10)
|
|
39
|
+
if (!dayMap.has(date)) dayMap.set(date, [])
|
|
40
|
+
dayMap.get(date)!.push(s)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows: DayRow[] = []
|
|
44
|
+
for (const [date, daySessions] of dayMap) {
|
|
45
|
+
const times = daySessions.flatMap(s => {
|
|
46
|
+
const start = new Date(s.startTime).getTime()
|
|
47
|
+
const end = s.endTime ? new Date(s.endTime).getTime() : start + s.durationMinutes * 60000
|
|
48
|
+
return [start, end]
|
|
49
|
+
})
|
|
50
|
+
rows.push({
|
|
51
|
+
date,
|
|
52
|
+
sessions: daySessions.sort((a, b) => a.startTime.localeCompare(b.startTime)),
|
|
53
|
+
earliest: Math.min(...times),
|
|
54
|
+
latest: Math.max(...times),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
rows.sort((a, b) => b.date.localeCompare(a.date))
|
|
59
|
+
|
|
60
|
+
// Use 0:00 - 24:00 as the timeline range
|
|
61
|
+
return {
|
|
62
|
+
days: rows.slice(0, 30), // last 30 days
|
|
63
|
+
dayStart: 0,
|
|
64
|
+
dayEnd: 24 * 60, // in minutes
|
|
65
|
+
}
|
|
66
|
+
}, [sessions])
|
|
67
|
+
|
|
68
|
+
if (days.length === 0) {
|
|
69
|
+
return (
|
|
70
|
+
<Card>
|
|
71
|
+
<CardHeader>
|
|
72
|
+
<CardTitle>Session Timeline</CardTitle>
|
|
73
|
+
<CardDescription>No sessions with timestamp data</CardDescription>
|
|
74
|
+
</CardHeader>
|
|
75
|
+
</Card>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const totalMinutes = dayEnd - dayStart
|
|
80
|
+
const hourMarkers = Array.from({ length: 25 }, (_, i) => i)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Card>
|
|
84
|
+
<CardHeader>
|
|
85
|
+
<CardTitle>Session Timeline</CardTitle>
|
|
86
|
+
<CardDescription>
|
|
87
|
+
Daily session activity — each bar is a session, width = duration, color = model.
|
|
88
|
+
Showing last {days.length} active days.
|
|
89
|
+
</CardDescription>
|
|
90
|
+
</CardHeader>
|
|
91
|
+
<CardContent>
|
|
92
|
+
<div className="overflow-x-auto">
|
|
93
|
+
{/* Hour labels */}
|
|
94
|
+
<div className="flex items-end mb-1" style={{ paddingLeft: 90 }}>
|
|
95
|
+
<div className="relative w-full" style={{ height: 16 }}>
|
|
96
|
+
{hourMarkers.filter(h => h % 3 === 0).map(h => (
|
|
97
|
+
<span
|
|
98
|
+
key={h}
|
|
99
|
+
className="absolute text-[10px] text-muted-foreground -translate-x-1/2"
|
|
100
|
+
style={{ left: `${(h * 60 / totalMinutes) * 100}%` }}
|
|
101
|
+
>
|
|
102
|
+
{h === 0 ? '12a' : h < 12 ? `${h}a` : h === 12 ? '12p' : `${h - 12}p`}
|
|
103
|
+
</span>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Rows */}
|
|
109
|
+
<div className="space-y-0.5">
|
|
110
|
+
{days.map(day => (
|
|
111
|
+
<div key={day.date} className="flex items-center gap-2" style={{ height: 28 }}>
|
|
112
|
+
{/* Date label */}
|
|
113
|
+
<div className="w-[80px] shrink-0 text-right text-xs text-muted-foreground font-mono">
|
|
114
|
+
{day.date.slice(5)}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Timeline bar */}
|
|
118
|
+
<div className="relative flex-1 h-full rounded-sm bg-muted/30">
|
|
119
|
+
{/* Hour gridlines */}
|
|
120
|
+
{hourMarkers.filter(h => h % 6 === 0).map(h => (
|
|
121
|
+
<div
|
|
122
|
+
key={h}
|
|
123
|
+
className="absolute top-0 h-full w-px bg-border/50"
|
|
124
|
+
style={{ left: `${(h * 60 / totalMinutes) * 100}%` }}
|
|
125
|
+
/>
|
|
126
|
+
))}
|
|
127
|
+
|
|
128
|
+
{/* Session blocks */}
|
|
129
|
+
{day.sessions.map(s => {
|
|
130
|
+
const startDate = new Date(s.startTime)
|
|
131
|
+
const startMin = startDate.getHours() * 60 + startDate.getMinutes()
|
|
132
|
+
const duration = Math.max(s.durationMinutes, 3) // min 3min width for visibility
|
|
133
|
+
const leftPct = (startMin / totalMinutes) * 100
|
|
134
|
+
const widthPct = Math.min((duration / totalMinutes) * 100, 100 - leftPct)
|
|
135
|
+
const isHovered = hoveredSession === s.sessionId
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Tooltip key={s.sessionId}>
|
|
139
|
+
<TooltipTrigger
|
|
140
|
+
render={
|
|
141
|
+
<div
|
|
142
|
+
className="absolute top-0.5 bottom-0.5 rounded-sm cursor-default transition-opacity"
|
|
143
|
+
style={{
|
|
144
|
+
left: `${leftPct}%`,
|
|
145
|
+
width: `${Math.max(widthPct, 0.3)}%`,
|
|
146
|
+
backgroundColor: getModelColor(s.model),
|
|
147
|
+
opacity: isHovered ? 1 : 0.75,
|
|
148
|
+
zIndex: isHovered ? 10 : 1,
|
|
149
|
+
}}
|
|
150
|
+
onMouseEnter={() => setHoveredSession(s.sessionId)}
|
|
151
|
+
onMouseLeave={() => setHoveredSession(null)}
|
|
152
|
+
/>
|
|
153
|
+
}
|
|
154
|
+
/>
|
|
155
|
+
<TooltipContent side="top" className="text-xs">
|
|
156
|
+
<div className="space-y-1">
|
|
157
|
+
<div className="font-semibold">{s.project}</div>
|
|
158
|
+
<div>{s.model}</div>
|
|
159
|
+
<div>{formatDuration(s.durationMinutes)} | {formatNumber(s.totalMessages)} msgs | {formatCost(s.costUSD)}</div>
|
|
160
|
+
<div>{new Date(s.startTime).toLocaleTimeString()} – {s.endTime ? new Date(s.endTime).toLocaleTimeString() : '?'}</div>
|
|
161
|
+
</div>
|
|
162
|
+
</TooltipContent>
|
|
163
|
+
</Tooltip>
|
|
164
|
+
)
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Legend */}
|
|
172
|
+
<div className="mt-4 flex items-center gap-4 text-xs text-muted-foreground">
|
|
173
|
+
{Object.entries(MODEL_COLORS).filter(([k]) => k !== 'default').map(([model, color]) => (
|
|
174
|
+
<span key={model} className="flex items-center gap-1.5">
|
|
175
|
+
<span className="inline-block h-3 w-3 rounded-sm" style={{ backgroundColor: color }} />
|
|
176
|
+
{model.replace('claude-', '')}
|
|
177
|
+
</span>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</CardContent>
|
|
182
|
+
</Card>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useCallback } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Background,
|
|
7
|
+
Controls,
|
|
8
|
+
MiniMap,
|
|
9
|
+
useReactFlow,
|
|
10
|
+
ReactFlowProvider,
|
|
11
|
+
type Node,
|
|
12
|
+
type Edge,
|
|
13
|
+
Position,
|
|
14
|
+
MarkerType,
|
|
15
|
+
} from '@xyflow/react'
|
|
16
|
+
import '@xyflow/react/dist/style.css'
|
|
17
|
+
import dagre from 'dagre'
|
|
18
|
+
import type { WorkflowNode, WorkflowEdge } from '@/lib/session-detail'
|
|
19
|
+
|
|
20
|
+
// ─── Node Colors ─────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
const NODE_STYLES: Record<string, { bg: string; border: string; text: string }> = {
|
|
23
|
+
user_input: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
|
24
|
+
thinking: { bg: '#faf5ff', border: '#8b5cf6', text: '#6d28d9' },
|
|
25
|
+
text_response: { bg: '#f0fdf4', border: '#22c55e', text: '#166534' },
|
|
26
|
+
tool_call: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' },
|
|
27
|
+
tool_result: { bg: '#f0fdf4', border: '#22c55e', text: '#166534' },
|
|
28
|
+
system: { bg: '#f8fafc', border: '#94a3b8', text: '#475569' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ERROR_STYLE = { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' }
|
|
32
|
+
|
|
33
|
+
// ─── Dagre Layout ────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function layoutNodes(
|
|
36
|
+
wfNodes: WorkflowNode[],
|
|
37
|
+
wfEdges: WorkflowEdge[]
|
|
38
|
+
): { nodes: Node[]; edges: Edge[] } {
|
|
39
|
+
const g = new dagre.graphlib.Graph()
|
|
40
|
+
g.setDefaultEdgeLabel(() => ({}))
|
|
41
|
+
g.setGraph({ rankdir: 'TB', ranksep: 50, nodesep: 40, marginx: 20, marginy: 20 })
|
|
42
|
+
|
|
43
|
+
const limitedNodes = wfNodes.slice(0, 200)
|
|
44
|
+
const nodeIds = new Set(limitedNodes.map(n => n.id))
|
|
45
|
+
const nodeWidth = 260
|
|
46
|
+
|
|
47
|
+
for (const node of limitedNodes) {
|
|
48
|
+
g.setNode(node.id, { width: nodeWidth, height: 80 })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const edge of wfEdges) {
|
|
52
|
+
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
|
53
|
+
g.setEdge(edge.source, edge.target)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
dagre.layout(g)
|
|
58
|
+
|
|
59
|
+
const nodes: Node[] = limitedNodes.map(node => {
|
|
60
|
+
const pos = g.node(node.id)
|
|
61
|
+
const style = node.isError ? ERROR_STYLE : (NODE_STYLES[node.nodeType] || NODE_STYLES.system)
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: node.id,
|
|
65
|
+
position: { x: (pos?.x || 0) - nodeWidth / 2, y: (pos?.y || 0) - 40 },
|
|
66
|
+
width: nodeWidth,
|
|
67
|
+
height: 80,
|
|
68
|
+
measured: { width: nodeWidth, height: 80 },
|
|
69
|
+
data: {
|
|
70
|
+
color: style.border,
|
|
71
|
+
label: (
|
|
72
|
+
<div className="text-left px-2 py-1 overflow-hidden">
|
|
73
|
+
<div className="flex items-center gap-1.5 mb-0.5">
|
|
74
|
+
<span
|
|
75
|
+
className="inline-block h-2 w-2 rounded-full shrink-0"
|
|
76
|
+
style={{ backgroundColor: style.border }}
|
|
77
|
+
/>
|
|
78
|
+
<span className="text-[11px] font-semibold truncate" style={{ color: style.text }}>
|
|
79
|
+
{node.label}
|
|
80
|
+
</span>
|
|
81
|
+
{node.toolName && (
|
|
82
|
+
<span className="text-[10px] text-muted-foreground">
|
|
83
|
+
#{node.stepIndex}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
<div className="text-[10px] text-muted-foreground leading-tight line-clamp-2">
|
|
88
|
+
{node.contentPreview || '...'}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
style: {
|
|
94
|
+
width: nodeWidth,
|
|
95
|
+
minHeight: 60,
|
|
96
|
+
backgroundColor: style.bg,
|
|
97
|
+
border: `1.5px solid ${style.border}`,
|
|
98
|
+
borderRadius: 8,
|
|
99
|
+
fontSize: 11,
|
|
100
|
+
padding: 0,
|
|
101
|
+
},
|
|
102
|
+
sourcePosition: Position.Bottom,
|
|
103
|
+
targetPosition: Position.Top,
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const edges: Edge[] = wfEdges
|
|
108
|
+
.filter(e => nodeIds.has(e.source) && nodeIds.has(e.target))
|
|
109
|
+
.map(e => ({
|
|
110
|
+
id: e.id,
|
|
111
|
+
source: e.source,
|
|
112
|
+
target: e.target,
|
|
113
|
+
style: { stroke: '#94a3b8', strokeWidth: 1.5 },
|
|
114
|
+
markerEnd: { type: MarkerType.ArrowClosed, width: 10, height: 10, color: '#94a3b8' },
|
|
115
|
+
}))
|
|
116
|
+
|
|
117
|
+
return { nodes, edges }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Inner component (needs ReactFlowProvider) ──────────────────────
|
|
121
|
+
|
|
122
|
+
function WorkflowInner({ nodes, edges }: { nodes: Node[]; edges: Edge[] }) {
|
|
123
|
+
const { fitView } = useReactFlow()
|
|
124
|
+
|
|
125
|
+
// On initial render, zoom to the first 5 nodes
|
|
126
|
+
const onInit = useCallback(() => {
|
|
127
|
+
const firstNodeIds = nodes.slice(0, 5).map(n => n.id)
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
fitView({ nodes: firstNodeIds.map(id => ({ id })), padding: 0.3, duration: 300 })
|
|
130
|
+
}, 100)
|
|
131
|
+
}, [nodes, fitView])
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<ReactFlow
|
|
135
|
+
nodes={nodes}
|
|
136
|
+
edges={edges}
|
|
137
|
+
onInit={onInit}
|
|
138
|
+
nodesDraggable
|
|
139
|
+
nodesConnectable={false}
|
|
140
|
+
minZoom={0.05}
|
|
141
|
+
maxZoom={2}
|
|
142
|
+
defaultEdgeOptions={{ animated: false }}
|
|
143
|
+
>
|
|
144
|
+
<Background gap={16} size={1} />
|
|
145
|
+
<Controls showInteractive={false} />
|
|
146
|
+
<MiniMap
|
|
147
|
+
nodeColor={(node) => (node.data as Record<string, string>)?.color || '#94a3b8'}
|
|
148
|
+
nodeStrokeWidth={0}
|
|
149
|
+
maskColor="rgba(0,0,0,0.08)"
|
|
150
|
+
style={{ width: 200, height: 160 }}
|
|
151
|
+
pannable
|
|
152
|
+
zoomable
|
|
153
|
+
/>
|
|
154
|
+
</ReactFlow>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Exported Component ──────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export function SessionWorkflow({
|
|
161
|
+
nodes: wfNodes,
|
|
162
|
+
edges: wfEdges,
|
|
163
|
+
}: {
|
|
164
|
+
nodes: WorkflowNode[]
|
|
165
|
+
edges: WorkflowEdge[]
|
|
166
|
+
}) {
|
|
167
|
+
const { nodes, edges } = useMemo(
|
|
168
|
+
() => layoutNodes(wfNodes, wfEdges),
|
|
169
|
+
[wfNodes, wfEdges]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if (nodes.length === 0) {
|
|
173
|
+
return <div className="text-muted-foreground text-sm p-4">No workflow data</div>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div style={{ height: 700 }} className="w-full rounded-lg border bg-background">
|
|
178
|
+
<ReactFlowProvider>
|
|
179
|
+
<WorkflowInner nodes={nodes} edges={edges} />
|
|
180
|
+
</ReactFlowProvider>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
4
5
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
5
6
|
import {
|
|
6
7
|
Table,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
import { Badge } from '@/components/ui/badge'
|
|
21
22
|
import { PaginationControls } from '@/components/pagination-controls'
|
|
22
23
|
import { usePagination } from '@/hooks/use-pagination'
|
|
24
|
+
import { Eye } from 'lucide-react'
|
|
23
25
|
import type { SessionSummary } from '@/lib/parse-logs'
|
|
24
26
|
import { formatCost, formatTokens, formatDuration, formatNumber } from '@/lib/format'
|
|
25
27
|
|
|
@@ -67,11 +69,12 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
|
67
69
|
<TableHead className="text-right">Cost</TableHead>
|
|
68
70
|
<TableHead className="text-right">Duration</TableHead>
|
|
69
71
|
<TableHead className="text-right">Tools</TableHead>
|
|
72
|
+
<TableHead className="w-[60px]"></TableHead>
|
|
70
73
|
</TableRow>
|
|
71
74
|
</TableHeader>
|
|
72
75
|
<TableBody>
|
|
73
76
|
{pagination.pageItems.map((s) => (
|
|
74
|
-
<TableRow key={s.sessionId}>
|
|
77
|
+
<TableRow key={s.sessionId} className="hover:bg-muted/50">
|
|
75
78
|
<TableCell className="whitespace-nowrap text-sm">
|
|
76
79
|
{s.startTime ? new Date(s.startTime).toLocaleDateString() : 'N/A'}
|
|
77
80
|
</TableCell>
|
|
@@ -86,6 +89,11 @@ export function SessionsTable({ sessions }: { sessions: SessionSummary[] }) {
|
|
|
86
89
|
<TableCell className="text-right">{formatCost(s.costUSD)}</TableCell>
|
|
87
90
|
<TableCell className="text-right">{formatDuration(s.durationMinutes)}</TableCell>
|
|
88
91
|
<TableCell className="text-right">{formatNumber(s.toolCallsTotal)}</TableCell>
|
|
92
|
+
<TableCell>
|
|
93
|
+
<Link href={`/sessions/${s.sessionId}`} className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
|
|
94
|
+
<Eye className="h-4 w-4" />
|
|
95
|
+
</Link>
|
|
96
|
+
</TableCell>
|
|
89
97
|
</TableRow>
|
|
90
98
|
))}
|
|
91
99
|
</TableBody>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Background,
|
|
7
|
+
Controls,
|
|
8
|
+
type Node,
|
|
9
|
+
type Edge,
|
|
10
|
+
Position,
|
|
11
|
+
MarkerType,
|
|
12
|
+
} from '@xyflow/react'
|
|
13
|
+
import '@xyflow/react/dist/style.css'
|
|
14
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
15
|
+
import type { UsageData } from '@/lib/parse-logs'
|
|
16
|
+
import { computeToolFlow } from '@/lib/tool-flow'
|
|
17
|
+
import { formatNumber } from '@/lib/format'
|
|
18
|
+
|
|
19
|
+
const TOOL_COLORS: Record<string, string> = {
|
|
20
|
+
Read: '#6b9bd2',
|
|
21
|
+
Edit: '#7bc8a4',
|
|
22
|
+
Write: '#7bc8a4',
|
|
23
|
+
Bash: '#d4a574',
|
|
24
|
+
Grep: '#6b9bd2',
|
|
25
|
+
Glob: '#6b9bd2',
|
|
26
|
+
Agent: '#b8a9d4',
|
|
27
|
+
Skill: '#b8a9d4',
|
|
28
|
+
WebSearch: '#d4a5a5',
|
|
29
|
+
WebFetch: '#d4a5a5',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getToolColor(tool: string): string {
|
|
33
|
+
return TOOL_COLORS[tool] || '#94a3b8'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ToolFlowGraph({ data }: { data: UsageData }) {
|
|
37
|
+
const flow = useMemo(() => computeToolFlow(data), [data])
|
|
38
|
+
|
|
39
|
+
const { nodes, edges } = useMemo(() => {
|
|
40
|
+
if (flow.nodes.length === 0) return { nodes: [], edges: [] }
|
|
41
|
+
|
|
42
|
+
const maxCount = Math.max(...flow.nodes.map(n => n.count))
|
|
43
|
+
const centerX = 400
|
|
44
|
+
const centerY = 300
|
|
45
|
+
const radius = 220
|
|
46
|
+
|
|
47
|
+
const rfNodes: Node[] = flow.nodes.map((n, i) => {
|
|
48
|
+
const angle = (i / flow.nodes.length) * 2 * Math.PI - Math.PI / 2
|
|
49
|
+
const size = 40 + (n.count / maxCount) * 60
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: n.id,
|
|
53
|
+
position: {
|
|
54
|
+
x: centerX + radius * Math.cos(angle) - size / 2,
|
|
55
|
+
y: centerY + radius * Math.sin(angle) - size / 2,
|
|
56
|
+
},
|
|
57
|
+
data: {
|
|
58
|
+
label: (
|
|
59
|
+
<div className="flex flex-col items-center gap-0.5">
|
|
60
|
+
<span className="text-xs font-semibold">{n.id}</span>
|
|
61
|
+
<span className="text-[10px] text-muted-foreground">{formatNumber(n.count)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
style: {
|
|
66
|
+
width: size,
|
|
67
|
+
height: size,
|
|
68
|
+
borderRadius: '50%',
|
|
69
|
+
display: 'flex',
|
|
70
|
+
alignItems: 'center',
|
|
71
|
+
justifyContent: 'center',
|
|
72
|
+
backgroundColor: getToolColor(n.id),
|
|
73
|
+
border: '2px solid rgba(255,255,255,0.2)',
|
|
74
|
+
color: '#fff',
|
|
75
|
+
fontSize: 11,
|
|
76
|
+
},
|
|
77
|
+
sourcePosition: Position.Right,
|
|
78
|
+
targetPosition: Position.Left,
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const maxEdgeCount = Math.max(...flow.edges.map(e => e.count), 1)
|
|
83
|
+
|
|
84
|
+
const rfEdges: Edge[] = flow.edges.map((e, i) => ({
|
|
85
|
+
id: `e-${i}`,
|
|
86
|
+
source: e.source,
|
|
87
|
+
target: e.target,
|
|
88
|
+
animated: e.count > maxEdgeCount * 0.5,
|
|
89
|
+
style: {
|
|
90
|
+
strokeWidth: 1 + (e.count / maxEdgeCount) * 4,
|
|
91
|
+
opacity: 0.3 + (e.count / maxEdgeCount) * 0.5,
|
|
92
|
+
stroke: getToolColor(e.source),
|
|
93
|
+
},
|
|
94
|
+
markerEnd: {
|
|
95
|
+
type: MarkerType.ArrowClosed,
|
|
96
|
+
width: 12,
|
|
97
|
+
height: 12,
|
|
98
|
+
},
|
|
99
|
+
label: e.count > maxEdgeCount * 0.2 ? String(e.count) : undefined,
|
|
100
|
+
labelStyle: { fontSize: 10, fill: '#94a3b8' },
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
return { nodes: rfNodes, edges: rfEdges }
|
|
104
|
+
}, [flow])
|
|
105
|
+
|
|
106
|
+
if (flow.nodes.length === 0) {
|
|
107
|
+
return (
|
|
108
|
+
<Card>
|
|
109
|
+
<CardHeader>
|
|
110
|
+
<CardTitle>Tool Flow</CardTitle>
|
|
111
|
+
<CardDescription>Not enough tool data for visualization</CardDescription>
|
|
112
|
+
</CardHeader>
|
|
113
|
+
</Card>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<Card>
|
|
119
|
+
<CardHeader>
|
|
120
|
+
<CardTitle>Tool Flow Graph</CardTitle>
|
|
121
|
+
<CardDescription>
|
|
122
|
+
How tools connect — node size = usage frequency, edge thickness = co-occurrence strength.
|
|
123
|
+
Top {flow.nodes.length} tools with {flow.edges.length} connections.
|
|
124
|
+
</CardDescription>
|
|
125
|
+
</CardHeader>
|
|
126
|
+
<CardContent>
|
|
127
|
+
<div style={{ height: 600 }} className="rounded-lg border bg-background">
|
|
128
|
+
<ReactFlow
|
|
129
|
+
nodes={nodes}
|
|
130
|
+
edges={edges}
|
|
131
|
+
fitView
|
|
132
|
+
nodesDraggable
|
|
133
|
+
nodesConnectable={false}
|
|
134
|
+
minZoom={0.3}
|
|
135
|
+
maxZoom={2}
|
|
136
|
+
>
|
|
137
|
+
<Background gap={20} size={1} />
|
|
138
|
+
<Controls showInteractive={false} />
|
|
139
|
+
</ReactFlow>
|
|
140
|
+
</div>
|
|
141
|
+
</CardContent>
|
|
142
|
+
</Card>
|
|
143
|
+
)
|
|
144
|
+
}
|