agentlytics 0.0.10 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -1
- package/cache.js +12 -7
- package/editors/index.js +7 -1
- package/editors/windsurf.js +199 -47
- package/index.js +125 -3
- package/mcp-server.js +279 -0
- package/package.json +6 -1
- package/relay-client.js +307 -0
- package/relay-server.js +552 -0
- package/server.js +4 -0
- package/ui/src/App.jsx +154 -45
- package/ui/src/components/ChatSidebar.jsx +27 -155
- package/ui/src/components/EditorBreakdown.jsx +22 -0
- package/ui/src/components/LiveFeed.jsx +138 -0
- package/ui/src/components/LoginScreen.jsx +79 -0
- package/ui/src/components/MessageRenderer.jsx +167 -0
- package/ui/src/components/ModelBreakdown.jsx +23 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/lib/api.js +115 -0
- package/ui/src/pages/ChatDetail.jsx +5 -164
- package/ui/src/pages/Dashboard.jsx +1 -4
- package/ui/src/pages/RelayDashboard.jsx +380 -0
- package/ui/src/pages/RelaySessionDetail.jsx +32 -0
- package/ui/src/pages/RelayUserDetail.jsx +204 -0
- package/ui/src/pages/Sessions.jsx +14 -1
- package/ui/vite.config.js +2 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { Users, Cpu, ArrowRight, Search, Merge } 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 KpiCard from '../components/KpiCard'
|
|
7
|
+
import EditorDot from '../components/EditorDot'
|
|
8
|
+
import SectionTitle from '../components/SectionTitle'
|
|
9
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
10
|
+
import LiveFeed from '../components/LiveFeed'
|
|
11
|
+
import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
|
|
12
|
+
import { fetchRelayTeamStats, fetchRelaySearch, fetchRelaySession, mergeRelayUsers } from '../lib/api'
|
|
13
|
+
import { useTheme } from '../lib/theme'
|
|
14
|
+
|
|
15
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
16
|
+
|
|
17
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
18
|
+
|
|
19
|
+
export default function RelayDashboard() {
|
|
20
|
+
const navigate = useNavigate()
|
|
21
|
+
const [stats, setStats] = useState(null)
|
|
22
|
+
const [search, setSearch] = useState('')
|
|
23
|
+
const [searchResults, setSearchResults] = useState(null)
|
|
24
|
+
const [searching, setSearching] = useState(false)
|
|
25
|
+
const [selectedChat, setSelectedChat] = useState(null)
|
|
26
|
+
const [selectedUsername, setSelectedUsername] = useState(null)
|
|
27
|
+
const [mergeFrom, setMergeFrom] = useState('')
|
|
28
|
+
const [mergeTo, setMergeTo] = useState('')
|
|
29
|
+
const [merging, setMerging] = useState(false)
|
|
30
|
+
const [mergeResult, setMergeResult] = useState(null)
|
|
31
|
+
const { dark } = useTheme()
|
|
32
|
+
|
|
33
|
+
const txtColor = dark ? '#888' : '#555'
|
|
34
|
+
const legendColor = dark ? '#888' : '#555'
|
|
35
|
+
const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
|
|
36
|
+
const txtDim = dark ? '#555' : '#999'
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetchRelayTeamStats().then(setStats)
|
|
40
|
+
const iv = setInterval(() => fetchRelayTeamStats().then(setStats), 15000)
|
|
41
|
+
return () => clearInterval(iv)
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
const handleSearch = async (e) => {
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
if (!search.trim()) return
|
|
47
|
+
setSearching(true)
|
|
48
|
+
try {
|
|
49
|
+
const results = await fetchRelaySearch(search.trim(), { limit: 30 })
|
|
50
|
+
setSearchResults(results)
|
|
51
|
+
} catch { setSearchResults([]) }
|
|
52
|
+
setSearching(false)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!stats) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading relay data...</div>
|
|
56
|
+
|
|
57
|
+
const editorData = stats.editors || []
|
|
58
|
+
const userList = stats.users || []
|
|
59
|
+
|
|
60
|
+
const editorChartData = {
|
|
61
|
+
labels: editorData.map(e => editorLabel(e.source)),
|
|
62
|
+
datasets: [{
|
|
63
|
+
data: editorData.map(e => e.count),
|
|
64
|
+
backgroundColor: editorData.map(e => editorColor(e.source)),
|
|
65
|
+
borderWidth: 0,
|
|
66
|
+
}],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const userSessionData = {
|
|
70
|
+
labels: userList.map(u => u.username),
|
|
71
|
+
datasets: [{
|
|
72
|
+
label: 'Sessions',
|
|
73
|
+
data: userList.map(u => u.sessions),
|
|
74
|
+
backgroundColor: userList.map((_, i) => {
|
|
75
|
+
const colors = ['#6366f1', '#8b5cf6', '#a855f7', '#c084fc', '#d8b4fe', '#e879f9', '#f472b6', '#fb7185']
|
|
76
|
+
return colors[i % colors.length]
|
|
77
|
+
}),
|
|
78
|
+
borderWidth: 0,
|
|
79
|
+
}],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const chartOpts = {
|
|
83
|
+
responsive: true, maintainAspectRatio: false,
|
|
84
|
+
plugins: {
|
|
85
|
+
legend: { position: 'right', labels: { color: legendColor, font: { size: 10, family: MONO }, padding: 12, usePointStyle: true, pointStyle: 'circle' } },
|
|
86
|
+
tooltip: { bodyFont: { family: MONO, size: 11 }, titleFont: { family: MONO, size: 11 } },
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const barOpts = {
|
|
91
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
92
|
+
plugins: {
|
|
93
|
+
legend: { display: false },
|
|
94
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
95
|
+
},
|
|
96
|
+
scales: {
|
|
97
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } }, beginAtZero: true },
|
|
98
|
+
y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 9, family: MONO } } },
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const handleFeedClick = (chatId, username) => {
|
|
103
|
+
setSelectedChat(chatId)
|
|
104
|
+
setSelectedUsername(username)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="fade-in flex gap-4">
|
|
109
|
+
{/* ── Main content ── */}
|
|
110
|
+
<div className="flex-1 min-w-0 space-y-4">
|
|
111
|
+
{/* KPIs */}
|
|
112
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
113
|
+
<KpiCard label="Team Members" value={stats.totalUsers} />
|
|
114
|
+
<KpiCard label="Total Sessions" value={formatNumber(stats.totalSessions)} />
|
|
115
|
+
<KpiCard label="Active Users" value={stats.activeUsers} />
|
|
116
|
+
<KpiCard label="Projects" value={stats.totalProjects} />
|
|
117
|
+
<KpiCard label="Messages" value={formatNumber(stats.totalMessages)} />
|
|
118
|
+
<KpiCard label="Input Tokens" value={formatNumber(stats.totalInputTokens)} />
|
|
119
|
+
<KpiCard label="Output Tokens" value={formatNumber(stats.totalOutputTokens)} />
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{/* Search */}
|
|
123
|
+
<div className="card p-3">
|
|
124
|
+
<SectionTitle>Search across team</SectionTitle>
|
|
125
|
+
<form onSubmit={handleSearch} className="flex gap-2">
|
|
126
|
+
<div className="relative flex-1">
|
|
127
|
+
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
128
|
+
<input
|
|
129
|
+
type="text"
|
|
130
|
+
value={search}
|
|
131
|
+
onChange={e => setSearch(e.target.value)}
|
|
132
|
+
placeholder="Search messages, files, topics across all users..."
|
|
133
|
+
className="w-full pl-7 pr-3 py-1.5 text-[11px] bg-transparent border outline-none"
|
|
134
|
+
style={{ borderColor: 'var(--c-border)', color: 'var(--c-white)' }}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
<button
|
|
138
|
+
type="submit"
|
|
139
|
+
disabled={searching}
|
|
140
|
+
className="px-3 py-1.5 text-[10px] font-medium transition"
|
|
141
|
+
style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-white)' }}
|
|
142
|
+
>
|
|
143
|
+
{searching ? 'Searching...' : 'Search'}
|
|
144
|
+
</button>
|
|
145
|
+
</form>
|
|
146
|
+
{searchResults && (
|
|
147
|
+
<div className="mt-3 max-h-[300px] overflow-y-auto scrollbar-thin space-y-1">
|
|
148
|
+
{searchResults.length === 0 ? (
|
|
149
|
+
<div className="text-[11px] py-2" style={{ color: 'var(--c-text3)' }}>No results found</div>
|
|
150
|
+
) : (
|
|
151
|
+
searchResults.map((r, i) => (
|
|
152
|
+
<div
|
|
153
|
+
key={i}
|
|
154
|
+
className="card px-3 py-2 cursor-pointer hover:border-[var(--c-card-hover)] transition"
|
|
155
|
+
onClick={() => { setSelectedChat(r.chatId); setSelectedUsername(r.username) }}
|
|
156
|
+
>
|
|
157
|
+
<div className="flex items-center gap-2 mb-1">
|
|
158
|
+
<span className="text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>{r.username}</span>
|
|
159
|
+
<EditorDot source={r.source} showLabel size={6} />
|
|
160
|
+
<span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>{r.chatName}</span>
|
|
161
|
+
<span className="text-[9px] ml-auto" style={{ color: 'var(--c-text3)' }}>{r.role}</span>
|
|
162
|
+
</div>
|
|
163
|
+
<div className="text-[10px] line-clamp-2" style={{ color: 'var(--c-text)' }}>{r.content}</div>
|
|
164
|
+
</div>
|
|
165
|
+
))
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
172
|
+
{/* Editor breakdown */}
|
|
173
|
+
{editorData.length > 0 && (
|
|
174
|
+
<div className="card p-3">
|
|
175
|
+
<SectionTitle>Team Editor Usage</SectionTitle>
|
|
176
|
+
<div className="h-[200px]">
|
|
177
|
+
<Doughnut data={editorChartData} options={chartOpts} />
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* Sessions per user */}
|
|
183
|
+
{userList.length > 0 && (
|
|
184
|
+
<div className="card p-3">
|
|
185
|
+
<SectionTitle>Sessions per User</SectionTitle>
|
|
186
|
+
<div style={{ height: Math.max(120, userList.length * 32) }}>
|
|
187
|
+
<Bar data={userSessionData} options={barOpts} />
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Top Models */}
|
|
194
|
+
{stats.topModels && stats.topModels.length > 0 && (
|
|
195
|
+
<div className="card p-3">
|
|
196
|
+
<SectionTitle>Top Models (Team)</SectionTitle>
|
|
197
|
+
<div className="flex flex-wrap gap-2">
|
|
198
|
+
{stats.topModels.map(m => (
|
|
199
|
+
<span key={m.name} className="inline-flex items-center gap-1.5 px-2 py-1 text-[10px]" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}>
|
|
200
|
+
<Cpu size={10} style={{ color: '#818cf8' }} />
|
|
201
|
+
{m.name}
|
|
202
|
+
<span style={{ color: 'var(--c-text3)' }}>×{m.count}</span>
|
|
203
|
+
</span>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* User cards */}
|
|
210
|
+
<SectionTitle>Team Members</SectionTitle>
|
|
211
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
212
|
+
{userList.map(u => (
|
|
213
|
+
<div
|
|
214
|
+
key={u.username}
|
|
215
|
+
className="card p-3 cursor-pointer hover:border-[var(--c-card-hover)] transition"
|
|
216
|
+
onClick={() => navigate(`/relay/user/${u.username}`)}
|
|
217
|
+
>
|
|
218
|
+
<div className="flex items-center justify-between mb-2">
|
|
219
|
+
<div className="flex items-center gap-2">
|
|
220
|
+
<div className="w-7 h-7 flex items-center justify-center text-[11px] font-bold" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
221
|
+
{u.username.charAt(0).toUpperCase()}
|
|
222
|
+
</div>
|
|
223
|
+
<div>
|
|
224
|
+
<div className="text-[11px] font-medium" style={{ color: 'var(--c-white)' }}>{u.username}</div>
|
|
225
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>
|
|
226
|
+
{u.lastActive ? `Active ${formatDate(u.lastActive)}` : 'No activity'}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<ArrowRight size={12} style={{ color: 'var(--c-text3)' }} />
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="grid grid-cols-3 gap-2 mb-2">
|
|
234
|
+
<div>
|
|
235
|
+
<div className="text-[12px] font-bold" style={{ color: 'var(--c-white)' }}>{u.sessions}</div>
|
|
236
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>sessions</div>
|
|
237
|
+
</div>
|
|
238
|
+
<div>
|
|
239
|
+
<div className="text-[12px] font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(u.totalMessages)}</div>
|
|
240
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>messages</div>
|
|
241
|
+
</div>
|
|
242
|
+
<div>
|
|
243
|
+
<div className="text-[12px] font-bold" style={{ color: 'var(--c-white)' }}>{u.projects}</div>
|
|
244
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>projects</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Editor dots */}
|
|
249
|
+
<div className="flex flex-wrap gap-2">
|
|
250
|
+
{Object.entries(u.editors).sort((a, b) => b[1] - a[1]).map(([src, count]) => (
|
|
251
|
+
<span key={src} className="inline-flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text2)' }}>
|
|
252
|
+
<EditorDot source={src} size={6} />
|
|
253
|
+
{editorLabel(src)} <span style={{ color: 'var(--c-text3)' }}>{count}</span>
|
|
254
|
+
</span>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
{/* Top models */}
|
|
259
|
+
{u.topModels && u.topModels.length > 0 && (
|
|
260
|
+
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
261
|
+
{u.topModels.slice(0, 3).map(m => (
|
|
262
|
+
<span key={m.name} className="text-[8px] px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.08)', color: '#818cf8' }}>
|
|
263
|
+
{m.name}
|
|
264
|
+
</span>
|
|
265
|
+
))}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Merge users */}
|
|
273
|
+
{userList.length >= 2 && (
|
|
274
|
+
<div className="card p-3">
|
|
275
|
+
<SectionTitle><Merge size={12} className="inline mr-1" />Merge Users</SectionTitle>
|
|
276
|
+
<div className="flex items-end gap-2 flex-wrap">
|
|
277
|
+
<div>
|
|
278
|
+
<div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Merge from</div>
|
|
279
|
+
<select
|
|
280
|
+
value={mergeFrom}
|
|
281
|
+
onChange={e => setMergeFrom(e.target.value)}
|
|
282
|
+
className="text-[11px] px-2 py-1.5 outline-none"
|
|
283
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
284
|
+
>
|
|
285
|
+
<option value="">Select user...</option>
|
|
286
|
+
{userList.filter(u => u.username !== mergeTo).map(u => (
|
|
287
|
+
<option key={u.username} value={u.username}>{u.username} ({u.sessions} sessions)</option>
|
|
288
|
+
))}
|
|
289
|
+
</select>
|
|
290
|
+
</div>
|
|
291
|
+
<div className="text-[11px] pb-1.5" style={{ color: 'var(--c-text3)' }}>→</div>
|
|
292
|
+
<div>
|
|
293
|
+
<div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>Merge into</div>
|
|
294
|
+
<select
|
|
295
|
+
value={mergeTo}
|
|
296
|
+
onChange={e => setMergeTo(e.target.value)}
|
|
297
|
+
className="text-[11px] px-2 py-1.5 outline-none"
|
|
298
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
299
|
+
>
|
|
300
|
+
<option value="">Select user...</option>
|
|
301
|
+
{userList.filter(u => u.username !== mergeFrom).map(u => (
|
|
302
|
+
<option key={u.username} value={u.username}>{u.username} ({u.sessions} sessions)</option>
|
|
303
|
+
))}
|
|
304
|
+
</select>
|
|
305
|
+
</div>
|
|
306
|
+
<button
|
|
307
|
+
disabled={!mergeFrom || !mergeTo || merging}
|
|
308
|
+
onClick={async () => {
|
|
309
|
+
if (!confirm(`Merge all data from "${mergeFrom}" into "${mergeTo}"? This cannot be undone.`)) return
|
|
310
|
+
setMerging(true)
|
|
311
|
+
setMergeResult(null)
|
|
312
|
+
try {
|
|
313
|
+
const r = await mergeRelayUsers(mergeFrom, mergeTo)
|
|
314
|
+
setMergeResult(r)
|
|
315
|
+
setMergeFrom('')
|
|
316
|
+
setMergeTo('')
|
|
317
|
+
fetchRelayTeamStats().then(setStats)
|
|
318
|
+
} catch (err) {
|
|
319
|
+
setMergeResult({ error: err.message })
|
|
320
|
+
}
|
|
321
|
+
setMerging(false)
|
|
322
|
+
}}
|
|
323
|
+
className="text-[10px] px-3 py-1.5 font-medium transition"
|
|
324
|
+
style={{
|
|
325
|
+
background: mergeFrom && mergeTo ? 'rgba(239,68,68,0.15)' : 'var(--c-bg3)',
|
|
326
|
+
color: mergeFrom && mergeTo ? '#ef4444' : 'var(--c-text3)',
|
|
327
|
+
border: '1px solid var(--c-border)',
|
|
328
|
+
cursor: !mergeFrom || !mergeTo || merging ? 'not-allowed' : 'pointer',
|
|
329
|
+
opacity: !mergeFrom || !mergeTo || merging ? 0.5 : 1,
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
{merging ? 'Merging...' : 'Merge'}
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
{mergeResult && (
|
|
336
|
+
<div className="mt-2 text-[10px]" style={{ color: mergeResult.error ? '#ef4444' : '#22c55e' }}>
|
|
337
|
+
{mergeResult.error
|
|
338
|
+
? `Error: ${mergeResult.error}`
|
|
339
|
+
: `Merged ${mergeResult.moved?.chats || 0} chats, ${mergeResult.moved?.messages || 0} messages from "${mergeResult.merged?.from}" → "${mergeResult.merged?.to}". ${mergeResult.duplicatesSkipped || 0} duplicates skipped.`}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
|
|
345
|
+
{userList.length === 0 && (
|
|
346
|
+
<div className="card p-8 text-center">
|
|
347
|
+
<Users size={32} className="mx-auto mb-3" style={{ color: 'var(--c-text3)' }} />
|
|
348
|
+
<div className="text-[12px] font-medium mb-1" style={{ color: 'var(--c-white)' }}>No team members yet</div>
|
|
349
|
+
<div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
350
|
+
Share the join command with your team to start collecting data
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* ── Live Feed (right column) ── */}
|
|
357
|
+
<div
|
|
358
|
+
className="hidden xl:block w-[280px] shrink-0 card sticky top-[42px] self-start"
|
|
359
|
+
style={{ height: 'calc(100vh - 58px)', overflow: 'hidden' }}
|
|
360
|
+
>
|
|
361
|
+
<LiveFeed onSessionClick={handleFeedClick} />
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{/* Session sidebar — same component as default UI */}
|
|
365
|
+
<ChatSidebar
|
|
366
|
+
chatId={selectedChat}
|
|
367
|
+
onClose={() => { setSelectedChat(null); setSelectedUsername(null) }}
|
|
368
|
+
fetchFn={selectedUsername ? (id) => fetchRelaySession(id, selectedUsername) : undefined}
|
|
369
|
+
username={selectedUsername}
|
|
370
|
+
extraHeader={
|
|
371
|
+
selectedUsername ? (
|
|
372
|
+
<span className="text-[10px] font-medium px-1.5 py-0.5 shrink-0" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
373
|
+
{selectedUsername}
|
|
374
|
+
</span>
|
|
375
|
+
) : null
|
|
376
|
+
}
|
|
377
|
+
/>
|
|
378
|
+
</div>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { fetchRelaySession } from '../lib/api'
|
|
4
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
5
|
+
|
|
6
|
+
export default function RelaySessionDetail() {
|
|
7
|
+
const { chatId } = useParams()
|
|
8
|
+
const [searchParams] = useSearchParams()
|
|
9
|
+
const username = searchParams.get('username')
|
|
10
|
+
const navigate = useNavigate()
|
|
11
|
+
|
|
12
|
+
const fetchFn = useCallback(
|
|
13
|
+
(id) => fetchRelaySession(id, username),
|
|
14
|
+
[username]
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const extraHeader = username ? (
|
|
18
|
+
<span className="text-[10px] font-medium px-1.5 py-0.5 shrink-0" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
19
|
+
{username}
|
|
20
|
+
</span>
|
|
21
|
+
) : null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ChatSidebar
|
|
25
|
+
chatId={chatId}
|
|
26
|
+
onClose={() => navigate(-1)}
|
|
27
|
+
fetchFn={fetchFn}
|
|
28
|
+
username={username}
|
|
29
|
+
extraHeader={extraHeader}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { ArrowLeft, MessageSquare, FolderOpen } from 'lucide-react'
|
|
4
|
+
import KpiCard from '../components/KpiCard'
|
|
5
|
+
import EditorDot from '../components/EditorDot'
|
|
6
|
+
import SectionTitle from '../components/SectionTitle'
|
|
7
|
+
import EditorBreakdown from '../components/EditorBreakdown'
|
|
8
|
+
import ModelBreakdown from '../components/ModelBreakdown'
|
|
9
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
10
|
+
import LiveFeed from '../components/LiveFeed'
|
|
11
|
+
import { formatNumber, formatDate } from '../lib/constants'
|
|
12
|
+
import { fetchRelayUserActivity, fetchRelaySession } from '../lib/api'
|
|
13
|
+
|
|
14
|
+
export default function RelayUserDetail() {
|
|
15
|
+
const { username } = useParams()
|
|
16
|
+
const navigate = useNavigate()
|
|
17
|
+
const [sessions, setSessions] = useState(null)
|
|
18
|
+
const [selectedChat, setSelectedChat] = useState(null)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (username) {
|
|
22
|
+
fetchRelayUserActivity(username, { limit: 200 }).then(setSessions)
|
|
23
|
+
}
|
|
24
|
+
}, [username])
|
|
25
|
+
|
|
26
|
+
const fetchFn = useCallback(
|
|
27
|
+
(id) => fetchRelaySession(id, username),
|
|
28
|
+
[username]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if (!sessions) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading...</div>
|
|
32
|
+
|
|
33
|
+
// Aggregate stats
|
|
34
|
+
const totalSessions = sessions.length
|
|
35
|
+
const totalMessages = sessions.reduce((s, c) => s + (c.totalMessages || 0), 0)
|
|
36
|
+
const totalInputTokens = sessions.reduce((s, c) => s + (c.totalInputTokens || 0), 0)
|
|
37
|
+
const totalOutputTokens = sessions.reduce((s, c) => s + (c.totalOutputTokens || 0), 0)
|
|
38
|
+
|
|
39
|
+
// Editor breakdown
|
|
40
|
+
const editorMap = {}
|
|
41
|
+
for (const s of sessions) {
|
|
42
|
+
if (s.source) editorMap[s.source] = (editorMap[s.source] || 0) + 1
|
|
43
|
+
}
|
|
44
|
+
const editors = Object.entries(editorMap).sort((a, b) => b[1] - a[1])
|
|
45
|
+
|
|
46
|
+
// Project breakdown
|
|
47
|
+
const projectMap = {}
|
|
48
|
+
for (const s of sessions) {
|
|
49
|
+
if (s.folder) {
|
|
50
|
+
if (!projectMap[s.folder]) projectMap[s.folder] = { count: 0, lastActive: 0 }
|
|
51
|
+
projectMap[s.folder].count++
|
|
52
|
+
if (s.lastUpdatedAt > projectMap[s.folder].lastActive) projectMap[s.folder].lastActive = s.lastUpdatedAt
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projects = Object.entries(projectMap).sort((a, b) => b[1].count - a[1].count)
|
|
56
|
+
|
|
57
|
+
// Model breakdown
|
|
58
|
+
const modelMap = {}
|
|
59
|
+
for (const s of sessions) {
|
|
60
|
+
if (s.models) {
|
|
61
|
+
for (const m of s.models) modelMap[m] = (modelMap[m] || 0) + 1
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const models = Object.entries(modelMap).sort((a, b) => b[1] - a[1]).slice(0, 10)
|
|
65
|
+
|
|
66
|
+
const handleFeedClick = (chatId, feedUsername) => {
|
|
67
|
+
setSelectedChat(chatId)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="fade-in flex gap-4">
|
|
72
|
+
{/* ── Main content ── */}
|
|
73
|
+
<div className="flex-1 min-w-0 space-y-4">
|
|
74
|
+
{/* Header */}
|
|
75
|
+
<div className="flex items-center gap-3">
|
|
76
|
+
<button
|
|
77
|
+
onClick={() => navigate('/')}
|
|
78
|
+
className="p-1.5 transition hover:bg-[var(--c-card)]"
|
|
79
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text2)' }}
|
|
80
|
+
>
|
|
81
|
+
<ArrowLeft size={14} />
|
|
82
|
+
</button>
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
<div className="w-8 h-8 flex items-center justify-center text-[13px] font-bold" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
85
|
+
{username.charAt(0).toUpperCase()}
|
|
86
|
+
</div>
|
|
87
|
+
<div>
|
|
88
|
+
<div className="text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>{username}</div>
|
|
89
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>Team Member</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* KPIs */}
|
|
95
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
|
96
|
+
<KpiCard label="Sessions" value={totalSessions} />
|
|
97
|
+
<KpiCard label="Messages" value={formatNumber(totalMessages)} />
|
|
98
|
+
<KpiCard label="Editors" value={editors.length} />
|
|
99
|
+
<KpiCard label="Projects" value={projects.length} />
|
|
100
|
+
<KpiCard label="Input Tokens" value={formatNumber(totalInputTokens)} />
|
|
101
|
+
<KpiCard label="Output Tokens" value={formatNumber(totalOutputTokens)} />
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
105
|
+
{/* Editors */}
|
|
106
|
+
<div className="card p-3">
|
|
107
|
+
<SectionTitle>Editors</SectionTitle>
|
|
108
|
+
<EditorBreakdown editors={editors} total={totalSessions} />
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Models */}
|
|
112
|
+
<div className="card p-3">
|
|
113
|
+
<SectionTitle>Models Used</SectionTitle>
|
|
114
|
+
<ModelBreakdown models={models} />
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{/* Projects */}
|
|
119
|
+
{projects.length > 0 && (
|
|
120
|
+
<div className="card p-3">
|
|
121
|
+
<SectionTitle>Projects</SectionTitle>
|
|
122
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
123
|
+
{projects.map(([folder, info]) => (
|
|
124
|
+
<div key={folder} className="card px-3 py-2">
|
|
125
|
+
<div className="flex items-center gap-1.5 mb-1">
|
|
126
|
+
<FolderOpen size={10} style={{ color: '#818cf8' }} />
|
|
127
|
+
<span className="text-[10px] font-medium truncate" style={{ color: 'var(--c-white)' }}>{folder.split('/').pop()}</span>
|
|
128
|
+
</div>
|
|
129
|
+
<div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>{folder}</div>
|
|
130
|
+
<div className="flex items-center gap-3 mt-1">
|
|
131
|
+
<span className="text-[9px]" style={{ color: 'var(--c-text2)' }}>{info.count} sessions</span>
|
|
132
|
+
<span className="text-[9px]" style={{ color: 'var(--c-text3)' }}>{formatDate(info.lastActive)}</span>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Sessions list */}
|
|
141
|
+
<div className="card p-3">
|
|
142
|
+
<SectionTitle>Recent Sessions</SectionTitle>
|
|
143
|
+
<div className="max-h-[500px] overflow-y-auto scrollbar-thin space-y-1">
|
|
144
|
+
{sessions.map(s => (
|
|
145
|
+
<div
|
|
146
|
+
key={s.id}
|
|
147
|
+
className="card px-3 py-2 cursor-pointer hover:border-[var(--c-card-hover)] transition"
|
|
148
|
+
onClick={() => setSelectedChat(s.id)}
|
|
149
|
+
>
|
|
150
|
+
<div className="flex items-center gap-2">
|
|
151
|
+
<EditorDot source={s.source} size={6} />
|
|
152
|
+
<span className="text-[10px] font-medium truncate flex-1" style={{ color: 'var(--c-white)' }}>
|
|
153
|
+
{s.name || 'Untitled'}
|
|
154
|
+
</span>
|
|
155
|
+
{s.mode && (
|
|
156
|
+
<span className="text-[8px] px-1.5 py-0.5" style={{ background: 'rgba(168,85,247,0.1)', color: '#a855f7' }}>{s.mode}</span>
|
|
157
|
+
)}
|
|
158
|
+
<span className="text-[9px]" style={{ color: 'var(--c-text3)' }}>{formatDate(s.lastUpdatedAt)}</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="flex items-center gap-3 mt-1">
|
|
161
|
+
{s.totalMessages > 0 && (
|
|
162
|
+
<span className="flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
|
|
163
|
+
<MessageSquare size={8} /> {s.totalMessages}
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
166
|
+
{s.folder && (
|
|
167
|
+
<span className="flex items-center gap-1 text-[9px] truncate" style={{ color: 'var(--c-text3)' }}>
|
|
168
|
+
<FolderOpen size={8} /> {s.folder.split('/').pop()}
|
|
169
|
+
</span>
|
|
170
|
+
)}
|
|
171
|
+
{s.models && s.models.length > 0 && (
|
|
172
|
+
<span className="text-[8px] px-1" style={{ color: '#818cf8' }}>{[...new Set(s.models)][0]}</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
{/* ── Live Feed (right column) ── */}
|
|
183
|
+
<div
|
|
184
|
+
className="hidden xl:block w-[280px] shrink-0 card sticky top-[42px] self-start"
|
|
185
|
+
style={{ height: 'calc(100vh - 58px)', overflow: 'hidden' }}
|
|
186
|
+
>
|
|
187
|
+
<LiveFeed onSessionClick={handleFeedClick} />
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* Session sidebar — same component as default UI */}
|
|
191
|
+
<ChatSidebar
|
|
192
|
+
chatId={selectedChat}
|
|
193
|
+
onClose={() => setSelectedChat(null)}
|
|
194
|
+
fetchFn={fetchFn}
|
|
195
|
+
username={username}
|
|
196
|
+
extraHeader={
|
|
197
|
+
<span className="text-[10px] font-medium px-1.5 py-0.5 shrink-0" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
198
|
+
{username}
|
|
199
|
+
</span>
|
|
200
|
+
}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
2
2
|
import { useSearchParams } from 'react-router-dom'
|
|
3
|
-
import { Search, Filter, List, FolderOpen, ChevronDown, ChevronRight, X } from 'lucide-react'
|
|
3
|
+
import { Search, Filter, List, FolderOpen, ChevronDown, ChevronRight, X, AlertTriangle } 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'
|
|
@@ -223,6 +223,19 @@ export default function Sessions({ overview }) {
|
|
|
223
223
|
<td className="py-2.5 px-4 font-medium truncate max-w-[300px]" style={{ color: 'var(--c-white)' }}>
|
|
224
224
|
{c.name || <span style={{ color: 'var(--c-text3)' }}>(untitled)</span>}
|
|
225
225
|
{c.encrypted && <span className="ml-2 text-[10px] text-yellow-500/60">locked</span>}
|
|
226
|
+
{c.bubbleCount >= 200 && (
|
|
227
|
+
<span
|
|
228
|
+
className="ml-2 inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-sm"
|
|
229
|
+
style={{
|
|
230
|
+
color: c.bubbleCount >= 500 ? '#ef4444' : '#f59e0b',
|
|
231
|
+
background: c.bubbleCount >= 500 ? 'rgba(239,68,68,0.1)' : 'rgba(245,158,11,0.1)',
|
|
232
|
+
}}
|
|
233
|
+
title={`${c.bubbleCount} messages — large context may degrade AI performance`}
|
|
234
|
+
>
|
|
235
|
+
<AlertTriangle size={9} />
|
|
236
|
+
{c.bubbleCount >= 500 ? 'context bloat' : 'large context'}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
226
239
|
</td>
|
|
227
240
|
{!groupByProject && (
|
|
228
241
|
<td className="py-2.5 px-4 truncate max-w-[200px] text-xs" style={{ color: 'var(--c-text2)' }} title={c.folder}>
|
package/ui/vite.config.js
CHANGED