agentlytics 0.2.3 → 0.2.5

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.
@@ -0,0 +1,744 @@
1
+ import { useState, useEffect, useMemo } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { Plug, Server, Wrench, Terminal, Globe, FolderOpen, Search, ChevronDown, ChevronRight, Hash, Layers, ArrowUpRight, Ban, BarChart3 } 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 { fetchMCPs } from '../lib/api'
7
+ import { editorColor, editorLabel, formatNumber } from '../lib/constants'
8
+ import EditorIcon from '../components/EditorIcon'
9
+ import AnimatedLoader from '../components/AnimatedLoader'
10
+ import SectionTitle from '../components/SectionTitle'
11
+ import KpiCard from '../components/KpiCard'
12
+ import PageHeader from '../components/PageHeader'
13
+ import { useTheme } from '../lib/theme'
14
+
15
+ ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
16
+
17
+ const MONO = 'JetBrains Mono, monospace'
18
+ const CHART_COLORS = ['#6366f1', '#22c55e', '#f59e0b', '#ec4899', '#3b82f6', '#a855f7', '#14b8a6', '#f43f5e', '#84cc16', '#06b6d4', '#e879f9', '#fb923c']
19
+
20
+ function ServerCard({ server, matchedTools }) {
21
+ const [open, setOpen] = useState(false)
22
+ const matched = matchedTools || []
23
+ const totalCalls = matched.reduce((s, t) => s + t.count, 0)
24
+ const queriedTools = server.tools || []
25
+ const hasContent = queriedTools.length > 0 || matched.length > 0
26
+
27
+ return (
28
+ <div className="card overflow-hidden">
29
+ <button
30
+ onClick={() => hasContent && setOpen(!open)}
31
+ className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--c-bg3)] transition"
32
+ >
33
+ <div className="flex items-center justify-center w-6 h-6" style={{ background: `${editorColor(server.editor)}15` }}>
34
+ <Server size={11} style={{ color: editorColor(server.editor) }} />
35
+ </div>
36
+ <div className="flex-1 min-w-0">
37
+ <div className="flex items-center gap-1.5">
38
+ <span className="text-[12px] font-semibold truncate" style={{ color: 'var(--c-white)' }}>{server.name}</span>
39
+ <span className="text-[10px] px-1 py-px" style={{
40
+ background: server.scope === 'global' ? 'rgba(99,102,241,0.12)' : 'rgba(34,197,94,0.12)',
41
+ color: server.scope === 'global' ? '#818cf8' : '#22c55e',
42
+ }}>{server.scope}</span>
43
+ <span className="text-[10px] px-1 py-px" style={{
44
+ background: server.transport === 'stdio' ? 'rgba(234,179,8,0.12)' : 'rgba(59,130,246,0.12)',
45
+ color: server.transport === 'stdio' ? '#eab308' : '#3b82f6',
46
+ }}>{server.transport}</span>
47
+ {server.disabled && <span className="text-[10px] px-1 py-px" style={{ background: 'rgba(239,68,68,0.12)', color: '#ef4444' }}>disabled</span>}
48
+ </div>
49
+ <div className="flex items-center gap-2 mt-px">
50
+ <span className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
51
+ <EditorIcon source={server.editor} size={10} />
52
+ {server.editorLabel}
53
+ </span>
54
+ {server.command && (
55
+ <span className="text-[10px] truncate" style={{ color: 'var(--c-text3)', fontFamily: MONO }}>
56
+ {server.command} {(server.args || []).slice(0, 2).join(' ')}
57
+ </span>
58
+ )}
59
+ {server.url && (
60
+ <span className="flex items-center gap-1 text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>
61
+ <Globe size={9} />{server.url}
62
+ </span>
63
+ )}
64
+ </div>
65
+ </div>
66
+ <div className="text-right shrink-0">
67
+ {totalCalls > 0 && <div className="text-[12px] font-semibold" style={{ color: 'var(--c-white)' }}>{formatNumber(totalCalls)}</div>}
68
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
69
+ {queriedTools.length > 0 ? `${queriedTools.length} tools` : 'offline'}
70
+ {matched.length > 0 ? ` · ${matched.length} matched` : ''}
71
+ </div>
72
+ </div>
73
+ {hasContent ? (open ? <ChevronDown size={12} style={{ color: 'var(--c-text3)' }} /> : <ChevronRight size={12} style={{ color: 'var(--c-text3)' }} />) : <div className="w-3" />}
74
+ </button>
75
+ {open && hasContent && (
76
+ <div className="px-3 pb-2 pt-1" style={{ borderTop: '1px solid var(--c-border)' }}>
77
+ {queriedTools.length > 0 && (
78
+ <div className="mb-1.5">
79
+ <div className="text-[10px] font-medium mb-0.5" style={{ color: 'var(--c-text3)' }}>
80
+ <Wrench size={9} className="inline mr-0.5" />Available ({queriedTools.length})
81
+ </div>
82
+ <div className="flex flex-wrap gap-0.5">
83
+ {queriedTools.map(t => (
84
+ <span key={t} className="text-[10px] px-1 py-px" style={{ background: 'var(--c-bg3)', color: 'var(--c-text3)', fontFamily: MONO }}>{t}</span>
85
+ ))}
86
+ </div>
87
+ </div>
88
+ )}
89
+ {matched.length > 0 && (
90
+ <div>
91
+ <div className="text-[10px] font-medium mb-0.5" style={{ color: 'var(--c-text3)' }}>
92
+ <Hash size={9} className="inline mr-0.5" />Matched Calls
93
+ </div>
94
+ <div className="space-y-0.5">
95
+ {matched.map(t => (
96
+ <div key={t.name} className="flex items-center justify-between py-0.5 px-1.5" style={{ background: 'var(--c-bg3)' }}>
97
+ <span className="text-[10px] truncate" style={{ fontFamily: MONO, color: 'var(--c-text)' }}>{t.name}</span>
98
+ <div className="flex items-center gap-2 shrink-0 ml-2">
99
+ <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>{t.sessionCount}s</span>
100
+ <span className="text-[11px] font-semibold" style={{ color: 'var(--c-white)' }}>{formatNumber(t.count)}</span>
101
+ </div>
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </div>
106
+ )}
107
+ </div>
108
+ )}
109
+ </div>
110
+ )
111
+ }
112
+
113
+ export default function MCPs() {
114
+ const navigate = useNavigate()
115
+ const [data, setData] = useState(null)
116
+ const [loading, setLoading] = useState(true)
117
+ const [search, setSearch] = useState('')
118
+ const [tab, setTab] = useState('servers') // servers | tools | sessions
119
+ const [toolProjectFilter, setToolProjectFilter] = useState('')
120
+ const [toolServerFilter, setToolServerFilter] = useState('')
121
+ const { dark } = useTheme()
122
+ const txtColor = dark ? '#a0a0a0' : '#444'
123
+ const txtDim = dark ? '#555' : '#999'
124
+ const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
125
+ const legendColor = dark ? '#9ca3af' : '#555'
126
+
127
+ useEffect(() => {
128
+ fetchMCPs().then(d => { setData(d); setLoading(false) }).catch(() => setLoading(false))
129
+ }, [])
130
+
131
+ const filteredServers = useMemo(() => {
132
+ if (!data) return []
133
+ const q = search.toLowerCase()
134
+ return data.servers.filter(s =>
135
+ !q || s.name.toLowerCase().includes(q) || s.editor.toLowerCase().includes(q) || (s.command || '').toLowerCase().includes(q)
136
+ )
137
+ }, [data, search])
138
+
139
+ // Resolve tool name → matched server name
140
+ const toolServerMap = useMemo(() => {
141
+ if (!data) return {}
142
+ const map = {}
143
+ for (const [serverName, tools] of Object.entries(data.matchedTools || {})) {
144
+ for (const t of tools) map[t.name] = serverName
145
+ }
146
+ return map
147
+ }, [data])
148
+
149
+ // Unique projects and servers for filter dropdowns
150
+ const toolFilterOptions = useMemo(() => {
151
+ if (!data) return { projects: [], servers: [] }
152
+ const projectSet = new Set()
153
+ const serverSet = new Set()
154
+ for (const t of data.toolCalls) {
155
+ for (const f of (t.folders || [])) projectSet.add(f)
156
+ const sn = toolServerMap[t.name]
157
+ if (sn) serverSet.add(sn); else serverSet.add('__builtin__')
158
+ }
159
+ return {
160
+ projects: [...projectSet].sort((a, b) => a.split('/').pop().localeCompare(b.split('/').pop())),
161
+ servers: [...serverSet].filter(s => s !== '__builtin__').sort(),
162
+ hasBuiltIn: serverSet.has('__builtin__'),
163
+ }
164
+ }, [data, toolServerMap])
165
+
166
+ const filteredTools = useMemo(() => {
167
+ if (!data) return []
168
+ const q = search.toLowerCase()
169
+ return data.toolCalls.filter(t => {
170
+ if (q && !t.name.toLowerCase().includes(q)) return false
171
+ if (toolProjectFilter && !(t.folders || []).includes(toolProjectFilter)) return false
172
+ if (toolServerFilter === '__builtin__' && toolServerMap[t.name]) return false
173
+ if (toolServerFilter && toolServerFilter !== '__builtin__' && toolServerMap[t.name] !== toolServerFilter) return false
174
+ return true
175
+ })
176
+ }, [data, search, toolProjectFilter, toolServerFilter, toolServerMap])
177
+
178
+ const filteredSessions = useMemo(() => {
179
+ if (!data) return []
180
+ const q = search.toLowerCase()
181
+ return data.topSessions.filter(s =>
182
+ !q || (s.name || '').toLowerCase().includes(q) || s.source.toLowerCase().includes(q) || (s.folder || '').toLowerCase().includes(q)
183
+ )
184
+ }, [data, search])
185
+
186
+ // Group servers by editor
187
+ const serversByEditor = useMemo(() => {
188
+ const groups = {}
189
+ for (const s of filteredServers) {
190
+ if (!groups[s.editor]) groups[s.editor] = []
191
+ groups[s.editor].push(s)
192
+ }
193
+ return groups
194
+ }, [filteredServers])
195
+
196
+ // ── Chart data computations ──
197
+
198
+ // Most used MCP servers — stacked by editor
199
+ const mostUsedServersChart = useMemo(() => {
200
+ if (!data) return null
201
+ // Group calls by server name + editor
202
+ const serverCalls = {} // { serverName: { editor: count, ... } }
203
+ for (const s of data.servers) {
204
+ const matched = data.matchedTools[s.name] || []
205
+ const calls = matched.reduce((sum, t) => sum + t.count, 0)
206
+ if (calls === 0) continue
207
+ if (!serverCalls[s.name]) serverCalls[s.name] = {}
208
+ serverCalls[s.name][s.editor] = (serverCalls[s.name][s.editor] || 0) + calls
209
+ }
210
+ // Sort by total calls descending
211
+ const sorted = Object.entries(serverCalls)
212
+ .map(([name, byEditor]) => ({ name, byEditor, total: Object.values(byEditor).reduce((s, v) => s + v, 0) }))
213
+ .sort((a, b) => b.total - a.total)
214
+ .slice(0, 10)
215
+ if (sorted.length === 0) return null
216
+ // Collect all editors that appear
217
+ const allEditors = [...new Set(sorted.flatMap(s => Object.keys(s.byEditor)))]
218
+ return {
219
+ labels: sorted.map(s => s.name),
220
+ datasets: allEditors.map(ed => ({
221
+ label: editorLabel(ed),
222
+ data: sorted.map(s => s.byEditor[ed] || 0),
223
+ backgroundColor: editorColor(ed) + 'CC',
224
+ borderRadius: 2,
225
+ })),
226
+ }
227
+ }, [data])
228
+
229
+ // Never used MCP servers (no matched tool calls)
230
+ const neverUsedServers = useMemo(() => {
231
+ if (!data) return []
232
+ return data.servers.filter(s => {
233
+ const matched = data.matchedTools[s.name] || []
234
+ return matched.length === 0
235
+ })
236
+ }, [data])
237
+
238
+ // Most used MCP tools (only those matched to a server)
239
+ const mostUsedMcpTools = useMemo(() => {
240
+ if (!data) return []
241
+ const allMatched = []
242
+ for (const [serverName, tools] of Object.entries(data.matchedTools)) {
243
+ for (const t of tools) allMatched.push({ ...t, server: serverName })
244
+ }
245
+ return allMatched.sort((a, b) => b.count - a.count).slice(0, 12)
246
+ }, [data])
247
+
248
+ // Never used MCP tools (queried from server but no matching calls)
249
+ const neverUsedMcpTools = useMemo(() => {
250
+ if (!data) return []
251
+ const matchedToolNames = new Set()
252
+ for (const tools of Object.values(data.matchedTools)) {
253
+ for (const t of tools) matchedToolNames.add(t.name)
254
+ }
255
+ const unused = []
256
+ for (const s of data.servers) {
257
+ const sTools = s.tools || []
258
+ if (sTools.length === 0) continue
259
+ for (const toolName of sTools) {
260
+ if (!matchedToolNames.has(toolName)) {
261
+ unused.push({ name: toolName, server: s.name, editor: s.editor })
262
+ }
263
+ }
264
+ }
265
+ return unused
266
+ }, [data])
267
+
268
+ // Servers by editor (for doughnut)
269
+ const serversByEditorChart = useMemo(() => {
270
+ if (!data) return null
271
+ const counts = {}
272
+ for (const s of data.servers) {
273
+ const lbl = editorLabel(s.editor)
274
+ counts[lbl] = (counts[lbl] || 0) + 1
275
+ }
276
+ const entries = Object.entries(counts).sort((a, b) => b[1] - a[1])
277
+ if (entries.length === 0) return null
278
+ return {
279
+ labels: entries.map(e => e[0]),
280
+ colors: entries.map(e => {
281
+ const srv = data.servers.find(s => editorLabel(s.editor) === e[0])
282
+ return srv ? editorColor(srv.editor) : '#666'
283
+ }),
284
+ values: entries.map(e => e[1]),
285
+ }
286
+ }, [data])
287
+
288
+ // Servers by transport (for doughnut)
289
+ const serversByTransport = useMemo(() => {
290
+ if (!data) return null
291
+ const counts = {}
292
+ for (const s of data.servers) counts[s.transport] = (counts[s.transport] || 0) + 1
293
+ const entries = Object.entries(counts).sort((a, b) => b[1] - a[1])
294
+ if (entries.length === 0) return null
295
+ const tColors = { stdio: '#eab308', http: '#3b82f6', sse: '#a855f7' }
296
+ return {
297
+ labels: entries.map(e => e[0]),
298
+ colors: entries.map(e => tColors[e[0]] || '#666'),
299
+ values: entries.map(e => e[1]),
300
+ }
301
+ }, [data])
302
+
303
+ if (loading) return <AnimatedLoader label="Loading MCP data..." />
304
+
305
+ if (!data) return (
306
+ <div className="text-center py-20 text-[13px]" style={{ color: 'var(--c-text3)' }}>
307
+ Failed to load MCP data
308
+ </div>
309
+ )
310
+
311
+ const { summary, matchedTools } = data
312
+ const tabs = [
313
+ { id: 'servers', label: 'Servers', count: data.servers.length },
314
+ { id: 'tools', label: 'Tools', count: data.toolCalls.length },
315
+ { id: 'sessions', label: 'Sessions', count: data.topSessions.length },
316
+ ]
317
+
318
+ return (
319
+ <div className="fade-in space-y-3">
320
+ <PageHeader icon={Plug} title="MCPs" />
321
+
322
+ {/* KPIs */}
323
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
324
+ <KpiCard label="servers" value={summary.totalServers} sub={`${summary.editorsWithServers.length} editors`} />
325
+ <KpiCard label="unique tools" value={formatNumber(summary.uniqueTools)} />
326
+ <KpiCard label="tool calls" value={formatNumber(summary.totalToolCalls)} />
327
+ <KpiCard label="sessions" value={formatNumber(summary.sessionsWithTools)} />
328
+ <KpiCard label="matched" value={Object.values(matchedTools).reduce((s, arr) => s + arr.length, 0)} sub={`${Object.keys(matchedTools).length} servers`} />
329
+ </div>
330
+
331
+ {/* Charts row */}
332
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-2">
333
+ {/* Most Used MCP Servers — stacked horizontal bar by editor */}
334
+ <div className="card p-3">
335
+ <SectionTitle>most used servers <span style={{ color: 'var(--c-text3)' }}>(by editor)</span></SectionTitle>
336
+ {mostUsedServersChart ? (
337
+ <div style={{ height: Math.max(mostUsedServersChart.labels.length * 22 + 10, 80) }}>
338
+ <Bar
339
+ data={mostUsedServersChart}
340
+ options={{
341
+ indexAxis: 'y', responsive: true, maintainAspectRatio: false,
342
+ scales: {
343
+ x: { stacked: true, grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO } } },
344
+ y: { stacked: true, grid: { display: false }, ticks: { color: txtColor, font: { size: 9, family: MONO } } },
345
+ },
346
+ plugins: {
347
+ legend: { position: 'top', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
348
+ tooltip: { mode: 'index', bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
349
+ },
350
+ }}
351
+ />
352
+ </div>
353
+ ) : <div className="text-[11px] text-center py-6" style={{ color: 'var(--c-text3)' }}>No MCP server usage</div>}
354
+ </div>
355
+
356
+ {/* Most Used MCP Tools — horizontal bar */}
357
+ <div className="card p-3">
358
+ <SectionTitle>most used tools</SectionTitle>
359
+ {mostUsedMcpTools.length > 0 ? (
360
+ <div style={{ height: Math.max(mostUsedMcpTools.length * 22 + 10, 80) }}>
361
+ <Bar
362
+ data={{
363
+ labels: mostUsedMcpTools.map(t => t.name),
364
+ datasets: [{
365
+ data: mostUsedMcpTools.map(t => t.count),
366
+ backgroundColor: CHART_COLORS.slice(0, mostUsedMcpTools.length),
367
+ borderRadius: 2,
368
+ }],
369
+ }}
370
+ options={{
371
+ indexAxis: 'y', responsive: true, maintainAspectRatio: false,
372
+ scales: {
373
+ x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO } } },
374
+ y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 9, family: MONO } } },
375
+ },
376
+ plugins: { legend: { display: false }, tooltip: {
377
+ bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
378
+ callbacks: { afterLabel: (ctx) => `Server: ${mostUsedMcpTools[ctx.dataIndex]?.server || '?'}` },
379
+ }},
380
+ }}
381
+ />
382
+ </div>
383
+ ) : <div className="text-[11px] text-center py-6" style={{ color: 'var(--c-text3)' }}>No MCP tool usage</div>}
384
+ </div>
385
+
386
+ {/* Doughnuts: by editor + by transport */}
387
+ <div className="space-y-2">
388
+ <div className="card p-3">
389
+ <SectionTitle>servers by editor</SectionTitle>
390
+ {serversByEditorChart ? (
391
+ <div style={{ height: 110 }}>
392
+ <Doughnut
393
+ data={{ labels: serversByEditorChart.labels, datasets: [{ data: serversByEditorChart.values, backgroundColor: serversByEditorChart.colors, borderWidth: 0 }] }}
394
+ options={{ responsive: true, maintainAspectRatio: false, cutout: '55%', plugins: {
395
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
396
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
397
+ }}}
398
+ />
399
+ </div>
400
+ ) : <div className="text-[11px] text-center py-4" style={{ color: 'var(--c-text3)' }}>no data</div>}
401
+ </div>
402
+ <div className="card p-3">
403
+ <SectionTitle>servers by transport</SectionTitle>
404
+ {serversByTransport ? (
405
+ <div style={{ height: 90 }}>
406
+ <Doughnut
407
+ data={{ labels: serversByTransport.labels, datasets: [{ data: serversByTransport.values, backgroundColor: serversByTransport.colors, borderWidth: 0 }] }}
408
+ options={{ responsive: true, maintainAspectRatio: false, cutout: '55%', plugins: {
409
+ legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
410
+ tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
411
+ }}}
412
+ />
413
+ </div>
414
+ ) : <div className="text-[11px] text-center py-4" style={{ color: 'var(--c-text3)' }}>no data</div>}
415
+ </div>
416
+ </div>
417
+ </div>
418
+
419
+ {/* Never Used Servers + Never Used Tools */}
420
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
421
+ <div className="card p-3">
422
+ <SectionTitle>never used servers <span style={{ color: 'var(--c-text3)' }}>({neverUsedServers.length})</span></SectionTitle>
423
+ {neverUsedServers.length > 0 ? (
424
+ <div className="space-y-0.5 max-h-[160px] overflow-y-auto scrollbar-thin">
425
+ {neverUsedServers.map((s, i) => (
426
+ <div key={`${s.name}-${s.editor}-${i}`} className="flex items-center gap-1.5 py-0.5 px-1.5" style={{ background: 'var(--c-bg3)' }}>
427
+ <Ban size={9} style={{ color: '#ef4444', opacity: 0.5 }} />
428
+ <span className="text-[10px] font-medium truncate" style={{ color: 'var(--c-text)' }}>{s.name}</span>
429
+ <span className="flex items-center gap-1 ml-auto shrink-0">
430
+ <EditorIcon source={s.editor} size={10} />
431
+ <span className="text-[10px]" style={{ color: 'var(--c-text3)' }}>{s.editorLabel}</span>
432
+ </span>
433
+ {s.disabled && <span className="text-[9px] px-0.5" style={{ background: 'rgba(239,68,68,0.12)', color: '#ef4444' }}>off</span>}
434
+ </div>
435
+ ))}
436
+ </div>
437
+ ) : <div className="text-[11px] text-center py-4" style={{ color: 'var(--c-text3)' }}>All servers have been used</div>}
438
+ </div>
439
+ <div className="card p-3">
440
+ <SectionTitle>never used tools <span style={{ color: 'var(--c-text3)' }}>({neverUsedMcpTools.length})</span></SectionTitle>
441
+ {neverUsedMcpTools.length > 0 ? (
442
+ <div className="space-y-0.5 max-h-[160px] overflow-y-auto scrollbar-thin">
443
+ {neverUsedMcpTools.map((t, i) => (
444
+ <div key={`${t.name}-${t.server}-${i}`} className="flex items-center gap-1.5 py-0.5 px-1.5" style={{ background: 'var(--c-bg3)' }}>
445
+ <Ban size={9} style={{ color: '#f59e0b', opacity: 0.5 }} />
446
+ <span className="text-[10px] truncate" style={{ fontFamily: MONO, color: 'var(--c-text)' }}>{t.name}</span>
447
+ <span className="ml-auto shrink-0 text-[10px] px-1 py-px" style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}>{t.server}</span>
448
+ </div>
449
+ ))}
450
+ </div>
451
+ ) : <div className="text-[11px] text-center py-4" style={{ color: 'var(--c-text3)' }}>All queried tools have been used</div>}
452
+ </div>
453
+ </div>
454
+
455
+ {/* Per-Project MCP Configs */}
456
+ {data.projectMcps && data.projectMcps.length > 0 && (
457
+ <div className="card overflow-hidden">
458
+ <div className="px-3 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
459
+ <SectionTitle>MCP servers by project <span style={{ color: 'var(--c-text3)' }}>({data.projectMcps.length})</span></SectionTitle>
460
+ </div>
461
+ <table className="w-full text-[12px]">
462
+ <thead>
463
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
464
+ <th className="text-left px-3 py-2 font-medium">project</th>
465
+ <th className="text-left px-3 py-2 font-medium">config</th>
466
+ <th className="text-left px-3 py-2 font-medium">servers</th>
467
+ <th className="text-right px-3 py-2 font-medium">mcp calls</th>
468
+ </tr>
469
+ </thead>
470
+ <tbody>
471
+ {data.projectMcps.map(p =>
472
+ p.configs.map((c, ci) => (
473
+ <tr
474
+ key={`${p.folder}-${c.file}`}
475
+ className="transition"
476
+ style={{ borderBottom: '1px solid var(--c-border)' }}
477
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
478
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
479
+ >
480
+ {ci === 0 ? (
481
+ <td className="px-3 py-2" rowSpan={p.configs.length}>
482
+ <span
483
+ className="flex items-center gap-1.5 cursor-pointer hover:underline"
484
+ style={{ color: 'var(--c-accent)' }}
485
+ onClick={() => navigate(`/projects/detail?folder=${encodeURIComponent(p.folder)}`)}
486
+ >
487
+ <FolderOpen size={11} />
488
+ <span className="font-medium truncate max-w-[200px]">{p.name}</span>
489
+ </span>
490
+ </td>
491
+ ) : null}
492
+ <td className="px-3 py-2">
493
+ <span className="flex items-center gap-1.5">
494
+ <EditorIcon source={c.editor} size={12} />
495
+ <span style={{ color: 'var(--c-text2)' }}>{c.file}</span>
496
+ </span>
497
+ </td>
498
+ <td className="px-3 py-2">
499
+ <div className="flex flex-wrap gap-1">
500
+ {c.serverNames.map(sn => (
501
+ <span key={sn} className="text-[11px] px-1.5 py-px" style={{
502
+ background: matchedTools[sn] ? 'rgba(99,102,241,0.12)' : 'var(--c-bg3)',
503
+ color: matchedTools[sn] ? '#818cf8' : 'var(--c-text2)',
504
+ }}>
505
+ <Plug size={8} className="inline mr-0.5" />{sn}
506
+ {matchedTools[sn] && <span className="ml-1 opacity-60">{matchedTools[sn].reduce((s, t) => s + t.count, 0)}</span>}
507
+ </span>
508
+ ))}
509
+ </div>
510
+ </td>
511
+ {ci === 0 ? (
512
+ <td className="px-3 py-2 text-right" rowSpan={p.configs.length}>
513
+ {p.mcpToolCalls > 0 ? (
514
+ <span className="font-semibold" style={{ color: 'var(--c-white)' }}>{formatNumber(p.mcpToolCalls)}</span>
515
+ ) : (
516
+ <span style={{ color: 'var(--c-text3)' }}>—</span>
517
+ )}
518
+ </td>
519
+ ) : null}
520
+ </tr>
521
+ ))
522
+ )}
523
+ </tbody>
524
+ </table>
525
+ </div>
526
+ )}
527
+
528
+ {/* Tabs + Search */}
529
+ <div className="flex items-center gap-3">
530
+ <div className="flex gap-0.5">
531
+ {tabs.map(t => (
532
+ <button
533
+ key={t.id}
534
+ onClick={() => setTab(t.id)}
535
+ className="px-2.5 py-1 text-[12px] transition"
536
+ style={{
537
+ background: tab === t.id ? 'var(--c-card)' : 'transparent',
538
+ color: tab === t.id ? 'var(--c-white)' : 'var(--c-text3)',
539
+ border: tab === t.id ? '1px solid var(--c-border)' : '1px solid transparent',
540
+ }}
541
+ >
542
+ {t.label} <span className="opacity-40 ml-0.5">{t.count}</span>
543
+ </button>
544
+ ))}
545
+ </div>
546
+ <div className="flex-1" />
547
+ {tab === 'tools' && (
548
+ <>
549
+ <select
550
+ value={toolProjectFilter}
551
+ onChange={e => setToolProjectFilter(e.target.value)}
552
+ className="px-2 py-1 text-[12px] outline-none"
553
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
554
+ >
555
+ <option value="">All Projects</option>
556
+ {toolFilterOptions.projects.map(f => (
557
+ <option key={f} value={f}>{f.split('/').pop()}</option>
558
+ ))}
559
+ </select>
560
+ <select
561
+ value={toolServerFilter}
562
+ onChange={e => setToolServerFilter(e.target.value)}
563
+ className="px-2 py-1 text-[12px] outline-none"
564
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
565
+ >
566
+ <option value="">All Servers</option>
567
+ {toolFilterOptions.hasBuiltIn && <option value="__builtin__">built-in</option>}
568
+ {toolFilterOptions.servers.map(s => (
569
+ <option key={s} value={s}>{s}</option>
570
+ ))}
571
+ </select>
572
+ </>
573
+ )}
574
+ <div className="relative">
575
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
576
+ <input
577
+ value={search}
578
+ onChange={e => setSearch(e.target.value)}
579
+ placeholder="Search..."
580
+ className="pl-7 pr-3 py-1 text-[12px] outline-none w-[180px]"
581
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
582
+ />
583
+ </div>
584
+ </div>
585
+
586
+ {/* Servers Tab */}
587
+ {tab === 'servers' && (
588
+ <div>
589
+ {filteredServers.length === 0 ? (
590
+ <div className="text-center py-12 text-[12px]" style={{ color: 'var(--c-text3)' }}>
591
+ <Server size={24} className="mx-auto mb-2 opacity-30" />
592
+ <div>No MCP servers detected</div>
593
+ <div className="text-[10px] mt-0.5 opacity-60">Configure MCP servers in your editors</div>
594
+ </div>
595
+ ) : (
596
+ Object.entries(serversByEditor).map(([editor, servers]) => (
597
+ <div key={editor} className="mb-3">
598
+ <div className="flex items-center gap-1.5 mb-1">
599
+ <EditorIcon source={editor} size={13} />
600
+ <span className="text-[12px] font-semibold" style={{ color: 'var(--c-white)' }}>{editorLabel(editor)}</span>
601
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>({servers.length})</span>
602
+ </div>
603
+ <div className="space-y-1">
604
+ {servers.map(s => (
605
+ <ServerCard key={`${s.name}-${s.editor}-${s.scope}`} server={s} matchedTools={matchedTools[s.name]} />
606
+ ))}
607
+ </div>
608
+ </div>
609
+ ))
610
+ )}
611
+ </div>
612
+ )}
613
+
614
+ {/* Tools Tab */}
615
+ {tab === 'tools' && (
616
+ <div className="card overflow-hidden">
617
+ <table className="w-full text-[12px]">
618
+ <thead>
619
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
620
+ <th className="text-left px-3 py-2 font-medium">tool</th>
621
+ <th className="text-right px-3 py-2 font-medium">calls</th>
622
+ <th className="text-right px-3 py-2 font-medium">sessions</th>
623
+ <th className="text-left px-3 py-2 font-medium">editors</th>
624
+ <th className="text-left px-3 py-2 font-medium">server</th>
625
+ </tr>
626
+ </thead>
627
+ <tbody>
628
+ {filteredTools.slice(0, 100).map((t, i) => {
629
+ const sn = toolServerMap[t.name]
630
+ return (
631
+ <tr
632
+ key={t.name}
633
+ className="transition"
634
+ style={{ borderBottom: '1px solid var(--c-border)' }}
635
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
636
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
637
+ >
638
+ <td className="px-3 py-2">
639
+ <span style={{ fontFamily: MONO, color: 'var(--c-white)' }}>{t.name}</span>
640
+ </td>
641
+ <td className="px-3 py-2 text-right">
642
+ <span className="font-semibold" style={{ color: 'var(--c-white)' }}>{formatNumber(t.count)}</span>
643
+ </td>
644
+ <td className="px-3 py-2 text-right" style={{ color: 'var(--c-text2)' }}>{t.sessionCount}</td>
645
+ <td className="px-3 py-2">
646
+ <div className="flex items-center gap-1">
647
+ {t.editors.map(e => (
648
+ <span key={e} className="inline-flex items-center gap-0.5">
649
+ <EditorIcon source={e} size={12} />
650
+ </span>
651
+ ))}
652
+ </div>
653
+ </td>
654
+ <td className="px-3 py-2">
655
+ {sn ? (
656
+ <span className="text-[11px] px-1.5 py-px" style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}>
657
+ <Plug size={9} className="inline mr-0.5" />{sn}
658
+ </span>
659
+ ) : (
660
+ <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>built-in</span>
661
+ )}
662
+ </td>
663
+ </tr>
664
+ )
665
+ })}
666
+ </tbody>
667
+ </table>
668
+ {filteredTools.length > 100 && (
669
+ <div className="px-3 py-1 text-[10px] text-center" style={{ color: 'var(--c-text3)', background: 'var(--c-card)' }}>
670
+ Showing 100 of {filteredTools.length}
671
+ </div>
672
+ )}
673
+ </div>
674
+ )}
675
+
676
+ {/* Sessions Tab */}
677
+ {tab === 'sessions' && (
678
+ <div className="card overflow-hidden">
679
+ <table className="w-full text-[12px]">
680
+ <thead>
681
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
682
+ <th className="text-left px-3 py-2 font-medium">session</th>
683
+ <th className="text-left px-3 py-2 font-medium">editor</th>
684
+ <th className="text-left px-3 py-2 font-medium">project</th>
685
+ <th className="text-right px-3 py-2 font-medium">calls</th>
686
+ <th className="text-left px-3 py-2 font-medium">top tools</th>
687
+ </tr>
688
+ </thead>
689
+ <tbody>
690
+ {filteredSessions.map((s, i) => {
691
+ const topTools = Object.entries(s.tools).sort((a, b) => b[1] - a[1]).slice(0, 3)
692
+ return (
693
+ <tr
694
+ key={s.composerId}
695
+ className="transition"
696
+ style={{ borderBottom: '1px solid var(--c-border)' }}
697
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
698
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
699
+ >
700
+ <td className="px-3 py-2 max-w-[220px] font-medium">
701
+ <span className="truncate block" style={{ color: 'var(--c-white)' }}>
702
+ {s.name || <span style={{ color: 'var(--c-text3)', fontStyle: 'italic' }}>Untitled</span>}
703
+ </span>
704
+ </td>
705
+ <td className="px-3 py-2">
706
+ <span className="flex items-center gap-1">
707
+ <EditorIcon source={s.source} size={12} />
708
+ <span style={{ color: 'var(--c-text2)' }}>{editorLabel(s.source)}</span>
709
+ </span>
710
+ </td>
711
+ <td className="px-3 py-2 truncate max-w-[160px]" title={s.folder}>
712
+ {s.folder ? (
713
+ <span
714
+ className="cursor-pointer hover:underline"
715
+ style={{ color: 'var(--c-accent)' }}
716
+ onClick={e => { e.stopPropagation(); navigate(`/projects/detail?folder=${encodeURIComponent(s.folder)}`) }}
717
+ >{s.folder.split(/[/\\]/).pop()}</span>
718
+ ) : <span style={{ color: 'var(--c-text3)' }}>—</span>}
719
+ </td>
720
+ <td className="px-3 py-2 text-right">
721
+ <span className="font-semibold" style={{ color: 'var(--c-white)' }}>{formatNumber(s.totalToolCalls)}</span>
722
+ </td>
723
+ <td className="px-3 py-2">
724
+ <div className="flex gap-0.5 flex-wrap">
725
+ {topTools.map(([name, count]) => (
726
+ <span key={name} className="text-[10px] px-1 py-px" style={{ background: 'var(--c-bg3)', color: 'var(--c-text2)', fontFamily: MONO }}>
727
+ {name} <span style={{ opacity: 0.5 }}>×{count}</span>
728
+ </span>
729
+ ))}
730
+ </div>
731
+ </td>
732
+ </tr>
733
+ )
734
+ })}
735
+ </tbody>
736
+ </table>
737
+ {data.topSessions.length === 0 && (
738
+ <div className="text-center py-8 text-[11px]" style={{ color: 'var(--c-text3)' }}>No sessions with tool calls</div>
739
+ )}
740
+ </div>
741
+ )}
742
+ </div>
743
+ )
744
+ }