agentlytics 0.0.8 → 0.0.11
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 +14 -2
- package/cache.js +67 -24
- package/editors/codex.js +453 -0
- package/editors/commandcode.js +159 -0
- package/editors/index.js +3 -1
- package/index.js +13 -3
- package/package.json +3 -2
- package/server.js +15 -4
- package/ui/src/App.jsx +40 -3
- package/ui/src/components/ActivityHeatmap.jsx +11 -7
- package/ui/src/components/ChatSidebar.jsx +313 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/index.css +6 -0
- package/ui/src/lib/api.js +16 -2
- package/ui/src/lib/constants.js +16 -0
- package/ui/src/pages/Dashboard.jsx +14 -14
- package/ui/src/pages/DeepAnalysis.jsx +6 -3
- package/ui/src/pages/ProjectDetail.jsx +236 -0
- package/ui/src/pages/Projects.jsx +50 -121
- package/ui/src/pages/Sessions.jsx +58 -46
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { useSearchParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { ArrowLeft, Search } from 'lucide-react'
|
|
4
|
+
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
5
|
+
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
6
|
+
import { fetchProjects, fetchChats } from '../lib/api'
|
|
7
|
+
import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
|
|
8
|
+
import { useTheme } from '../lib/theme'
|
|
9
|
+
import KpiCard from '../components/KpiCard'
|
|
10
|
+
import EditorDot from '../components/EditorDot'
|
|
11
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
12
|
+
|
|
13
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
14
|
+
|
|
15
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
16
|
+
const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
|
|
17
|
+
|
|
18
|
+
export default function ProjectDetail() {
|
|
19
|
+
const [searchParams] = useSearchParams()
|
|
20
|
+
const navigate = useNavigate()
|
|
21
|
+
const folder = searchParams.get('folder')
|
|
22
|
+
const { dark } = useTheme()
|
|
23
|
+
const txtColor = dark ? '#888' : '#555'
|
|
24
|
+
const txtDim = dark ? '#555' : '#999'
|
|
25
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
26
|
+
|
|
27
|
+
const [project, setProject] = useState(null)
|
|
28
|
+
const [chats, setChats] = useState([])
|
|
29
|
+
const [loading, setLoading] = useState(true)
|
|
30
|
+
const [chatSearch, setChatSearch] = useState('')
|
|
31
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!folder) return
|
|
35
|
+
setLoading(true)
|
|
36
|
+
Promise.all([
|
|
37
|
+
fetchProjects(),
|
|
38
|
+
fetchChats({ folder, limit: 1000 }),
|
|
39
|
+
]).then(([projects, chatData]) => {
|
|
40
|
+
const match = projects.find(p => p.folder === folder)
|
|
41
|
+
setProject(match || null)
|
|
42
|
+
setChats(chatData.chats || [])
|
|
43
|
+
setLoading(false)
|
|
44
|
+
})
|
|
45
|
+
}, [folder])
|
|
46
|
+
|
|
47
|
+
const filteredChats = useMemo(() => {
|
|
48
|
+
if (!chatSearch) return chats
|
|
49
|
+
const q = chatSearch.toLowerCase()
|
|
50
|
+
return chats.filter(c =>
|
|
51
|
+
(c.name && c.name.toLowerCase().includes(q)) ||
|
|
52
|
+
(c.topModel && c.topModel.toLowerCase().includes(q)) ||
|
|
53
|
+
(c.source && c.source.toLowerCase().includes(q))
|
|
54
|
+
)
|
|
55
|
+
}, [chats, chatSearch])
|
|
56
|
+
|
|
57
|
+
if (!folder) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>no project specified</div>
|
|
58
|
+
if (loading) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading project...</div>
|
|
59
|
+
if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
|
|
60
|
+
|
|
61
|
+
const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
|
|
62
|
+
const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="fade-in space-y-5">
|
|
66
|
+
{/* Back + title */}
|
|
67
|
+
<div className="flex items-center gap-3">
|
|
68
|
+
<button
|
|
69
|
+
onClick={() => navigate('/projects')}
|
|
70
|
+
className="flex items-center gap-1.5 text-xs transition"
|
|
71
|
+
style={{ color: 'var(--c-text2)' }}
|
|
72
|
+
>
|
|
73
|
+
<ArrowLeft size={14} /> Projects
|
|
74
|
+
</button>
|
|
75
|
+
<div className="flex-1 min-w-0">
|
|
76
|
+
<h1 className="text-base font-semibold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
|
|
77
|
+
<div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* KPIs */}
|
|
82
|
+
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
|
83
|
+
<KpiCard label="sessions" value={project.totalSessions} />
|
|
84
|
+
<KpiCard label="messages" value={formatNumber(project.totalMessages)} />
|
|
85
|
+
<KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} />
|
|
86
|
+
<KpiCard label="input tokens" value={formatNumber(project.totalInputTokens)} />
|
|
87
|
+
<KpiCard label="output tokens" value={formatNumber(project.totalOutputTokens)} />
|
|
88
|
+
<KpiCard label="active since" value={formatDate(project.firstSeen)} />
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Charts row */}
|
|
92
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
93
|
+
{/* Editors */}
|
|
94
|
+
<div className="card p-4">
|
|
95
|
+
<h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>editors</h3>
|
|
96
|
+
<div className="space-y-2">
|
|
97
|
+
{editorEntries.map(([e, c]) => (
|
|
98
|
+
<div key={e} className="flex items-center gap-2">
|
|
99
|
+
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
|
|
100
|
+
<span className="text-xs flex-1 truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(e)}</span>
|
|
101
|
+
<div className="w-20 h-3 overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
|
|
102
|
+
<div className="h-full" style={{ width: `${(c / maxEditorCount * 100).toFixed(0)}%`, background: editorColor(e) + '60' }} />
|
|
103
|
+
</div>
|
|
104
|
+
<span className="text-[10px] w-6 text-right" style={{ color: 'var(--c-text3)' }}>{c}</span>
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Models chart */}
|
|
111
|
+
<div className="card p-4">
|
|
112
|
+
<h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>models</h3>
|
|
113
|
+
{project.topModels.length > 0 ? (
|
|
114
|
+
<div style={{ height: 180 }}>
|
|
115
|
+
<Doughnut
|
|
116
|
+
data={{
|
|
117
|
+
labels: project.topModels.map(m => m.name),
|
|
118
|
+
datasets: [{ data: project.topModels.map(m => m.count), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
|
|
119
|
+
}}
|
|
120
|
+
options={{
|
|
121
|
+
responsive: true, maintainAspectRatio: false, cutout: '55%',
|
|
122
|
+
plugins: {
|
|
123
|
+
legend: { position: 'right', labels: { color: txtColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
124
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
125
|
+
},
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Top tools */}
|
|
133
|
+
<div className="card p-4">
|
|
134
|
+
<h3 className="text-[10px] uppercase tracking-wider mb-3" style={{ color: 'var(--c-text2)' }}>top tools</h3>
|
|
135
|
+
{project.topTools.length > 0 ? (
|
|
136
|
+
<div style={{ height: 180 }}>
|
|
137
|
+
<Bar
|
|
138
|
+
data={{
|
|
139
|
+
labels: project.topTools.map(t => t.name),
|
|
140
|
+
datasets: [{
|
|
141
|
+
data: project.topTools.map(t => t.count),
|
|
142
|
+
backgroundColor: 'rgba(99,102,241,0.4)',
|
|
143
|
+
borderRadius: 2,
|
|
144
|
+
}],
|
|
145
|
+
}}
|
|
146
|
+
options={{
|
|
147
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
148
|
+
scales: {
|
|
149
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
|
|
150
|
+
y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 8, family: MONO } } },
|
|
151
|
+
},
|
|
152
|
+
plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Token breakdown */}
|
|
161
|
+
{(project.totalCacheRead > 0 || project.totalCacheWrite > 0) && (
|
|
162
|
+
<div className="flex gap-4 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
163
|
+
<span>cache read: {formatNumber(project.totalCacheRead)}</span>
|
|
164
|
+
<span>cache write: {formatNumber(project.totalCacheWrite)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Sessions list */}
|
|
169
|
+
<div>
|
|
170
|
+
<div className="flex items-center gap-3 mb-3">
|
|
171
|
+
<h3 className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>sessions ({chats.length})</h3>
|
|
172
|
+
<div className="relative max-w-xs flex-1">
|
|
173
|
+
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
174
|
+
<input
|
|
175
|
+
type="text"
|
|
176
|
+
placeholder="filter sessions..."
|
|
177
|
+
value={chatSearch}
|
|
178
|
+
onChange={e => setChatSearch(e.target.value)}
|
|
179
|
+
className="w-full pl-7 pr-3 py-1 text-[11px] outline-none"
|
|
180
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="card overflow-hidden">
|
|
186
|
+
<table className="w-full text-sm">
|
|
187
|
+
<thead>
|
|
188
|
+
<tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
|
|
189
|
+
<th className="text-left py-2.5 px-4 font-medium">editor</th>
|
|
190
|
+
<th className="text-left py-2.5 px-4 font-medium">name</th>
|
|
191
|
+
<th className="text-left py-2.5 px-4 font-medium">mode</th>
|
|
192
|
+
<th className="text-left py-2.5 px-4 font-medium">model</th>
|
|
193
|
+
<th className="text-left py-2.5 px-4 font-medium">updated</th>
|
|
194
|
+
</tr>
|
|
195
|
+
</thead>
|
|
196
|
+
<tbody>
|
|
197
|
+
{filteredChats.map(c => (
|
|
198
|
+
<tr
|
|
199
|
+
key={c.id}
|
|
200
|
+
className="cursor-pointer transition"
|
|
201
|
+
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
202
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
203
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
204
|
+
onClick={() => setSelectedChatId(c.id)}
|
|
205
|
+
>
|
|
206
|
+
<td className="py-2.5 px-4">
|
|
207
|
+
<EditorDot source={c.source} showLabel size={7} />
|
|
208
|
+
</td>
|
|
209
|
+
<td className="py-2.5 px-4 font-medium truncate max-w-[300px]" style={{ color: 'var(--c-white)' }}>
|
|
210
|
+
{c.name || <span style={{ color: 'var(--c-text3)' }}>(untitled)</span>}
|
|
211
|
+
{c.encrypted && <span className="ml-2 text-[10px] text-yellow-500/60">locked</span>}
|
|
212
|
+
</td>
|
|
213
|
+
<td className="py-2.5 px-4">
|
|
214
|
+
<span className="text-xs" style={{ color: 'var(--c-text2)' }}>{c.mode}</span>
|
|
215
|
+
</td>
|
|
216
|
+
<td className="py-2.5 px-4 text-xs truncate max-w-[180px] font-mono" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
|
|
217
|
+
{c.topModel || ''}
|
|
218
|
+
</td>
|
|
219
|
+
<td className="py-2.5 px-4 text-xs whitespace-nowrap" style={{ color: 'var(--c-text2)' }}>
|
|
220
|
+
{formatDate(c.lastUpdatedAt || c.createdAt)}
|
|
221
|
+
</td>
|
|
222
|
+
</tr>
|
|
223
|
+
))}
|
|
224
|
+
</tbody>
|
|
225
|
+
</table>
|
|
226
|
+
{filteredChats.length === 0 && (
|
|
227
|
+
<div className="text-center py-8 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Chat sidebar */}
|
|
233
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
3
3
|
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
4
|
-
import { Search,
|
|
4
|
+
import { Search, ChevronRight } from 'lucide-react'
|
|
5
|
+
import { useNavigate } from 'react-router-dom'
|
|
5
6
|
import { fetchProjects } from '../lib/api'
|
|
6
|
-
import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
|
|
7
|
+
import { editorColor, editorLabel, formatNumber, formatDate, dateRangeToApiParams } from '../lib/constants'
|
|
7
8
|
import { useTheme } from '../lib/theme'
|
|
8
9
|
import KpiCard from '../components/KpiCard'
|
|
10
|
+
import DateRangePicker from '../components/DateRangePicker'
|
|
9
11
|
|
|
10
12
|
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
11
13
|
|
|
@@ -20,12 +22,13 @@ export default function Projects({ overview }) {
|
|
|
20
22
|
const [projects, setProjects] = useState(null)
|
|
21
23
|
const [search, setSearch] = useState('')
|
|
22
24
|
const [editorFilter, setEditorFilter] = useState('')
|
|
23
|
-
const [
|
|
25
|
+
const [dateRange, setDateRange] = useState(null)
|
|
26
|
+
const navigate = useNavigate()
|
|
24
27
|
const editors = overview?.editors || []
|
|
25
28
|
|
|
26
29
|
useEffect(() => {
|
|
27
|
-
fetchProjects().then(setProjects)
|
|
28
|
-
}, [])
|
|
30
|
+
fetchProjects(dateRangeToApiParams(dateRange)).then(setProjects)
|
|
31
|
+
}, [dateRange])
|
|
29
32
|
|
|
30
33
|
if (!projects) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading projects...</div>
|
|
31
34
|
|
|
@@ -109,133 +112,59 @@ export default function Projects({ overview }) {
|
|
|
109
112
|
|
|
110
113
|
{/* Filters */}
|
|
111
114
|
<div className="flex items-center gap-2">
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
117
|
-
>
|
|
118
|
-
<option value="">All Editors</option>
|
|
119
|
-
{editors.map(e => (
|
|
120
|
-
<option key={e.id} value={e.id}>{editorLabel(e.id)}</option>
|
|
121
|
-
))}
|
|
122
|
-
</select>
|
|
123
|
-
<div className="relative max-w-sm flex-1">
|
|
124
|
-
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
125
|
-
<input
|
|
126
|
-
type="text"
|
|
127
|
-
placeholder="search projects..."
|
|
128
|
-
value={search}
|
|
129
|
-
onChange={e => setSearch(e.target.value)}
|
|
130
|
-
className="w-full pl-8 pr-3 py-2 text-sm outline-none"
|
|
115
|
+
<select
|
|
116
|
+
value={editorFilter}
|
|
117
|
+
onChange={e => setEditorFilter(e.target.value)}
|
|
118
|
+
className="px-2 py-1 text-[11px] outline-none"
|
|
131
119
|
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
132
|
-
|
|
133
|
-
|
|
120
|
+
>
|
|
121
|
+
<option value="">All Editors</option>
|
|
122
|
+
{editors.map(e => (
|
|
123
|
+
<option key={e.id} value={e.id}>{editorLabel(e.id)}</option>
|
|
124
|
+
))}
|
|
125
|
+
</select>
|
|
126
|
+
<div className="relative max-w-sm flex-1">
|
|
127
|
+
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
128
|
+
<input
|
|
129
|
+
type="text"
|
|
130
|
+
placeholder="search projects..."
|
|
131
|
+
value={search}
|
|
132
|
+
onChange={e => setSearch(e.target.value)}
|
|
133
|
+
className="w-full pl-8 pr-3 py-1 text-[11px] outline-none"
|
|
134
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="ml-auto"><DateRangePicker value={dateRange} onChange={setDateRange} /></div>
|
|
134
138
|
</div>
|
|
135
139
|
|
|
136
140
|
{/* Project list */}
|
|
137
141
|
<div className="space-y-2">
|
|
138
142
|
{filtered.map(p => {
|
|
139
|
-
const isOpen = expanded === p.folder
|
|
140
143
|
const editorEntries = Object.entries(p.editors).sort((a, b) => b[1] - a[1])
|
|
141
|
-
const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
|
|
142
144
|
|
|
143
145
|
return (
|
|
144
|
-
<div
|
|
145
|
-
{
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
>
|
|
150
|
-
<div className="
|
|
151
|
-
|
|
152
|
-
<div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{p.folder}</div>
|
|
153
|
-
</div>
|
|
154
|
-
<div className="flex items-center gap-4 text-[10px] flex-shrink-0" style={{ color: 'var(--c-text2)' }}>
|
|
155
|
-
<div className="flex items-center gap-1.5">
|
|
156
|
-
{editorEntries.slice(0, 4).map(([e]) => (
|
|
157
|
-
<span key={e} className="w-2 h-2 rounded-full" style={{ background: editorColor(e) }} title={editorLabel(e)} />
|
|
158
|
-
))}
|
|
159
|
-
</div>
|
|
160
|
-
<span>{p.totalSessions} sessions</span>
|
|
161
|
-
<span>{formatNumber(p.totalMessages)} msgs</span>
|
|
162
|
-
{p.totalToolCalls > 0 && <span>{formatNumber(p.totalToolCalls)} tools</span>}
|
|
163
|
-
{(p.totalInputTokens + p.totalOutputTokens) > 0 && <span>{formatNumber(p.totalInputTokens + p.totalOutputTokens)} tokens</span>}
|
|
164
|
-
<span>{formatDate(p.lastSeen)}</span>
|
|
165
|
-
{isOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
166
|
-
</div>
|
|
146
|
+
<div
|
|
147
|
+
key={p.folder}
|
|
148
|
+
className="card px-4 py-3 flex items-center gap-3 cursor-pointer transition"
|
|
149
|
+
onClick={() => navigate(`/projects/detail?folder=${encodeURIComponent(p.folder)}`)}
|
|
150
|
+
>
|
|
151
|
+
<div className="flex-1 min-w-0">
|
|
152
|
+
<div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>{p.name}</div>
|
|
153
|
+
<div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{p.folder}</div>
|
|
167
154
|
</div>
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
<KpiCard label="sessions" value={p.totalSessions} />
|
|
174
|
-
<KpiCard label="messages" value={formatNumber(p.totalMessages)} />
|
|
175
|
-
<KpiCard label="tool calls" value={formatNumber(p.totalToolCalls)} />
|
|
176
|
-
<KpiCard label="input tokens" value={formatNumber(p.totalInputTokens)} />
|
|
177
|
-
<KpiCard label="output tokens" value={formatNumber(p.totalOutputTokens)} />
|
|
178
|
-
<KpiCard label="active since" value={formatDate(p.firstSeen)} />
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
182
|
-
{/* Editors */}
|
|
183
|
-
<div>
|
|
184
|
-
<h4 className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>editors</h4>
|
|
185
|
-
<div className="space-y-1.5">
|
|
186
|
-
{editorEntries.map(([e, c]) => (
|
|
187
|
-
<div key={e} className="flex items-center gap-2">
|
|
188
|
-
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: editorColor(e) }} />
|
|
189
|
-
<span className="text-xs flex-1 truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(e)}</span>
|
|
190
|
-
<div className="w-24 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
|
|
191
|
-
<div className="h-full rounded-sm" style={{ width: `${(c / maxEditorCount * 100).toFixed(0)}%`, background: editorColor(e) + '60' }} />
|
|
192
|
-
</div>
|
|
193
|
-
<span className="text-[10px] w-6 text-right" style={{ color: 'var(--c-text3)' }}>{c}</span>
|
|
194
|
-
</div>
|
|
195
|
-
))}
|
|
196
|
-
</div>
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{/* Models */}
|
|
200
|
-
<div>
|
|
201
|
-
<h4 className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>models</h4>
|
|
202
|
-
{p.topModels.length > 0 ? (
|
|
203
|
-
<div className="space-y-1.5">
|
|
204
|
-
{p.topModels.map(m => (
|
|
205
|
-
<div key={m.name} className="flex justify-between text-xs py-0.5">
|
|
206
|
-
<span className="truncate" style={{ color: 'var(--c-text2)' }}>{m.name}</span>
|
|
207
|
-
<span className="ml-2" style={{ color: 'var(--c-text3)' }}>{m.count}</span>
|
|
208
|
-
</div>
|
|
209
|
-
))}
|
|
210
|
-
</div>
|
|
211
|
-
) : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>no model data</div>}
|
|
212
|
-
</div>
|
|
213
|
-
|
|
214
|
-
{/* Tools */}
|
|
215
|
-
<div>
|
|
216
|
-
<h4 className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>top tools</h4>
|
|
217
|
-
{p.topTools.length > 0 ? (
|
|
218
|
-
<div className="space-y-1.5">
|
|
219
|
-
{p.topTools.map(t => (
|
|
220
|
-
<div key={t.name} className="flex justify-between text-xs py-0.5">
|
|
221
|
-
<span className="truncate" style={{ color: 'var(--c-text2)' }}>{t.name}</span>
|
|
222
|
-
<span className="ml-2" style={{ color: 'var(--c-text3)' }}>{t.count}</span>
|
|
223
|
-
</div>
|
|
224
|
-
))}
|
|
225
|
-
</div>
|
|
226
|
-
) : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
{/* Token breakdown */}
|
|
231
|
-
{(p.totalCacheRead > 0 || p.totalCacheWrite > 0) && (
|
|
232
|
-
<div className="mt-3 flex gap-4 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
233
|
-
<span>cache read: {formatNumber(p.totalCacheRead)}</span>
|
|
234
|
-
<span>cache write: {formatNumber(p.totalCacheWrite)}</span>
|
|
235
|
-
</div>
|
|
236
|
-
)}
|
|
155
|
+
<div className="flex items-center gap-4 text-[10px] flex-shrink-0" style={{ color: 'var(--c-text2)' }}>
|
|
156
|
+
<div className="flex items-center gap-1.5">
|
|
157
|
+
{editorEntries.slice(0, 4).map(([e]) => (
|
|
158
|
+
<span key={e} className="w-2 h-2 rounded-full" style={{ background: editorColor(e) }} title={editorLabel(e)} />
|
|
159
|
+
))}
|
|
237
160
|
</div>
|
|
238
|
-
|
|
161
|
+
<span>{p.totalSessions} sessions</span>
|
|
162
|
+
<span>{formatNumber(p.totalMessages)} msgs</span>
|
|
163
|
+
{p.totalToolCalls > 0 && <span>{formatNumber(p.totalToolCalls)} tools</span>}
|
|
164
|
+
{(p.totalInputTokens + p.totalOutputTokens) > 0 && <span>{formatNumber(p.totalInputTokens + p.totalOutputTokens)} tokens</span>}
|
|
165
|
+
<span>{formatDate(p.lastSeen)}</span>
|
|
166
|
+
<ChevronRight size={14} />
|
|
167
|
+
</div>
|
|
239
168
|
</div>
|
|
240
169
|
)
|
|
241
170
|
})}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { useSearchParams } from 'react-router-dom'
|
|
3
3
|
import { Search, Filter, List, FolderOpen, ChevronDown, ChevronRight, X } from 'lucide-react'
|
|
4
4
|
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler } from 'chart.js'
|
|
5
5
|
import { Line } from 'react-chartjs-2'
|
|
6
6
|
import { fetchChats } from '../lib/api'
|
|
7
|
-
import { editorColor, editorLabel, formatDate } from '../lib/constants'
|
|
7
|
+
import { editorColor, editorLabel, formatDate, dateRangeToApiParams } from '../lib/constants'
|
|
8
8
|
import { useTheme } from '../lib/theme'
|
|
9
9
|
import EditorDot from '../components/EditorDot'
|
|
10
|
+
import DateRangePicker from '../components/DateRangePicker'
|
|
11
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
10
12
|
|
|
11
13
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler)
|
|
12
14
|
|
|
@@ -107,8 +109,9 @@ export default function Sessions({ overview }) {
|
|
|
107
109
|
const [loading, setLoading] = useState(true)
|
|
108
110
|
const [groupByProject, setGroupByProject] = useState(false)
|
|
109
111
|
const [collapsedProjects, setCollapsedProjects] = useState(new Set())
|
|
110
|
-
const [dateRange, setDateRange] = useState(null) // [startWeek, endWeek]
|
|
111
|
-
const
|
|
112
|
+
const [dateRange, setDateRange] = useState(null) // [startWeek, endWeek] — chart drag-select
|
|
113
|
+
const [apiDateRange, setApiDateRange] = useState(null) // { from, to } — server-side date filter
|
|
114
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
112
115
|
const chartRef = useRef(null)
|
|
113
116
|
|
|
114
117
|
const onRangeSelect = useCallback((start, end) => {
|
|
@@ -117,12 +120,12 @@ export default function Sessions({ overview }) {
|
|
|
117
120
|
|
|
118
121
|
useEffect(() => {
|
|
119
122
|
setLoading(true)
|
|
120
|
-
fetchChats({ editor, limit: 1000 }).then(data => {
|
|
123
|
+
fetchChats({ editor, limit: 1000, ...dateRangeToApiParams(apiDateRange) }).then(data => {
|
|
121
124
|
setChats(data.chats)
|
|
122
125
|
setTotal(data.total)
|
|
123
126
|
setLoading(false)
|
|
124
127
|
})
|
|
125
|
-
}, [editor])
|
|
128
|
+
}, [editor, apiDateRange])
|
|
126
129
|
|
|
127
130
|
const searchFiltered = search
|
|
128
131
|
? chats.filter(c =>
|
|
@@ -212,7 +215,7 @@ export default function Sessions({ overview }) {
|
|
|
212
215
|
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
213
216
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
214
217
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
215
|
-
onClick={() =>
|
|
218
|
+
onClick={() => setSelectedChatId(c.id)}
|
|
216
219
|
>
|
|
217
220
|
<td className="py-2.5 px-4">
|
|
218
221
|
<EditorDot source={c.source} showLabel size={7} />
|
|
@@ -229,6 +232,9 @@ export default function Sessions({ overview }) {
|
|
|
229
232
|
<td className="py-2.5 px-4">
|
|
230
233
|
<span className="text-xs" style={{ color: 'var(--c-text2)' }}>{c.mode}</span>
|
|
231
234
|
</td>
|
|
235
|
+
<td className="py-2.5 px-4 text-xs truncate max-w-[180px] font-mono" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
|
|
236
|
+
{c.topModel || ''}
|
|
237
|
+
</td>
|
|
232
238
|
<td className="py-2.5 px-4 text-xs whitespace-nowrap" style={{ color: 'var(--c-text2)' }}>
|
|
233
239
|
{formatDate(c.lastUpdatedAt || c.createdAt)}
|
|
234
240
|
</td>
|
|
@@ -283,46 +289,48 @@ export default function Sessions({ overview }) {
|
|
|
283
289
|
|
|
284
290
|
{/* Filters */}
|
|
285
291
|
<div className="flex items-center gap-3">
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
<div className="relative">
|
|
293
|
+
<Filter size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
294
|
+
<select
|
|
295
|
+
value={editor}
|
|
296
|
+
onChange={e => setEditor(e.target.value)}
|
|
297
|
+
className="pl-8 pr-3 py-1 text-[11px] outline-none appearance-none cursor-pointer"
|
|
298
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
299
|
+
>
|
|
300
|
+
<option value="">all editors</option>
|
|
301
|
+
{editors.map(e => (
|
|
302
|
+
<option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
|
|
303
|
+
))}
|
|
304
|
+
</select>
|
|
305
|
+
</div>
|
|
306
|
+
<div className="relative flex-1 max-w-sm">
|
|
307
|
+
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
308
|
+
<input
|
|
309
|
+
type="text"
|
|
310
|
+
placeholder="search sessions..."
|
|
311
|
+
value={search}
|
|
312
|
+
onChange={e => setSearch(e.target.value)}
|
|
313
|
+
className="w-full pl-8 pr-3 py-1 text-[11px] outline-none"
|
|
314
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
<button
|
|
318
|
+
onClick={() => setGroupByProject(!groupByProject)}
|
|
319
|
+
className="flex items-center gap-1.5 px-3 py-1 text-[11px] transition"
|
|
320
|
+
style={{
|
|
321
|
+
border: groupByProject ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
|
|
322
|
+
color: groupByProject ? 'var(--c-accent)' : 'var(--c-text2)',
|
|
323
|
+
background: groupByProject ? 'rgba(99,102,241,0.1)' : 'transparent',
|
|
324
|
+
}}
|
|
293
325
|
>
|
|
294
|
-
<
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
<input
|
|
303
|
-
type="text"
|
|
304
|
-
placeholder="search sessions..."
|
|
305
|
-
value={search}
|
|
306
|
-
onChange={e => setSearch(e.target.value)}
|
|
307
|
-
className="w-full pl-8 pr-3 py-2 text-sm outline-none"
|
|
308
|
-
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
309
|
-
/>
|
|
310
|
-
</div>
|
|
311
|
-
<button
|
|
312
|
-
onClick={() => setGroupByProject(!groupByProject)}
|
|
313
|
-
className="flex items-center gap-1.5 px-3 py-2 text-xs transition"
|
|
314
|
-
style={{
|
|
315
|
-
border: groupByProject ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
|
|
316
|
-
color: groupByProject ? 'var(--c-accent)' : 'var(--c-text2)',
|
|
317
|
-
background: groupByProject ? 'rgba(99,102,241,0.1)' : 'transparent',
|
|
318
|
-
}}
|
|
319
|
-
>
|
|
320
|
-
{groupByProject ? <FolderOpen size={13} /> : <List size={13} />}
|
|
321
|
-
{groupByProject ? 'grouped' : 'flat'}
|
|
322
|
-
</button>
|
|
323
|
-
<span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
324
|
-
{loading ? 'loading...' : `${filtered.length} of ${total}`}
|
|
325
|
-
</span>
|
|
326
|
+
{groupByProject ? <FolderOpen size={13} /> : <List size={13} />}
|
|
327
|
+
{groupByProject ? 'grouped' : 'flat'}
|
|
328
|
+
</button>
|
|
329
|
+
<span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
330
|
+
{loading ? 'loading...' : `${filtered.length} of ${total}`}
|
|
331
|
+
</span>
|
|
332
|
+
{/* Server-side date range filter */}
|
|
333
|
+
<div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
|
|
326
334
|
</div>
|
|
327
335
|
|
|
328
336
|
{/* Session table */}
|
|
@@ -335,6 +343,7 @@ export default function Sessions({ overview }) {
|
|
|
335
343
|
<th className="text-left py-2.5 px-4 font-medium">name</th>
|
|
336
344
|
<th className="text-left py-2.5 px-4 font-medium">project</th>
|
|
337
345
|
<th className="text-left py-2.5 px-4 font-medium">mode</th>
|
|
346
|
+
<th className="text-left py-2.5 px-4 font-medium">model</th>
|
|
338
347
|
<th className="text-left py-2.5 px-4 font-medium">updated</th>
|
|
339
348
|
</tr>
|
|
340
349
|
</thead>
|
|
@@ -383,6 +392,9 @@ export default function Sessions({ overview }) {
|
|
|
383
392
|
)}
|
|
384
393
|
</div>
|
|
385
394
|
)}
|
|
395
|
+
|
|
396
|
+
{/* Chat sidebar */}
|
|
397
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
386
398
|
</div>
|
|
387
399
|
)
|
|
388
400
|
}
|