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.
package/server.js CHANGED
@@ -431,6 +431,294 @@ app.get('/api/artifact-content', (req, res) => {
431
431
  }
432
432
  });
433
433
 
434
+ // ============================================================
435
+ // MCPs — collect MCP servers from all editors + match tool calls
436
+ // ============================================================
437
+
438
+ // Cache: MCP server tool lists are queried once at startup via initMcpToolsCache()
439
+ let _mcpToolsCache = null; // { servers, serverToolResults, toolToServer, serverToolPatterns }
440
+
441
+ async function initMcpToolsCache() {
442
+ const { getAllMCPServers } = require('./editors');
443
+ const { queryMcpServerTools } = require('./editors/base');
444
+ const projects = cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
445
+ const projectFolders = projects.map(p => p.folder).filter(Boolean);
446
+
447
+ const servers = getAllMCPServers(projectFolders);
448
+
449
+ const queryPromises = servers.map(async (server) => {
450
+ if (server.disabled) return { server, tools: [] };
451
+ try {
452
+ const tools = await queryMcpServerTools(server);
453
+ return { server, tools };
454
+ } catch {
455
+ return { server, tools: [] };
456
+ }
457
+ });
458
+
459
+ const serverToolResults = await Promise.all(queryPromises);
460
+
461
+ const toolToServer = {};
462
+ for (const { server, tools } of serverToolResults) {
463
+ server.tools = tools;
464
+ for (const toolName of tools) {
465
+ toolToServer[toolName] = server.name;
466
+ }
467
+ }
468
+
469
+ const serverToolPatterns = {};
470
+ for (const { server, tools } of serverToolResults) {
471
+ if (tools.length === 0) continue;
472
+ serverToolPatterns[server.name] = new Set(tools.map(t => t.toLowerCase()));
473
+ }
474
+
475
+ _mcpToolsCache = { servers, serverToolResults, toolToServer, serverToolPatterns };
476
+ return _mcpToolsCache;
477
+ }
478
+
479
+ app.initMcpToolsCache = initMcpToolsCache;
480
+
481
+ app.get('/api/mcps', async (req, res) => {
482
+ try {
483
+ const db = cache.getDb();
484
+
485
+ // Use cached MCP tool data (queried once at startup)
486
+ if (!_mcpToolsCache) await initMcpToolsCache();
487
+ const { servers, serverToolResults, toolToServer, serverToolPatterns } = _mcpToolsCache;
488
+
489
+ // 3. Get tool call stats from the SQLite cache
490
+ const toolRows = db.prepare(`
491
+ SELECT tc.tool_name, tc.source, tc.chat_id, tc.folder, tc.timestamp, c.name as chat_name
492
+ FROM tool_calls tc JOIN chats c ON tc.chat_id = c.id
493
+ ORDER BY tc.timestamp DESC
494
+ `).all();
495
+
496
+ const toolCallMap = {}; // toolName -> { count, editors: Set, sessions: Set, folders: Set }
497
+ const sessionMap = {}; // chatId -> { ... }
498
+
499
+ for (const row of toolRows) {
500
+ const name = row.tool_name;
501
+ if (!toolCallMap[name]) toolCallMap[name] = { count: 0, editors: new Set(), sessions: new Set(), folders: new Set() };
502
+ toolCallMap[name].count++;
503
+ toolCallMap[name].editors.add(row.source);
504
+ toolCallMap[name].sessions.add(row.chat_id);
505
+ if (row.folder) toolCallMap[name].folders.add(row.folder);
506
+
507
+ if (!sessionMap[row.chat_id]) {
508
+ sessionMap[row.chat_id] = {
509
+ composerId: row.chat_id,
510
+ source: row.source,
511
+ name: row.chat_name,
512
+ folder: row.folder,
513
+ createdAt: row.timestamp,
514
+ totalToolCalls: 0,
515
+ tools: {},
516
+ };
517
+ }
518
+ sessionMap[row.chat_id].totalToolCalls++;
519
+ sessionMap[row.chat_id].tools[name] = (sessionMap[row.chat_id].tools[name] || 0) + 1;
520
+ }
521
+
522
+ // 4. Build tool call summary
523
+ const toolCalls = Object.entries(toolCallMap)
524
+ .map(([name, data]) => ({
525
+ name,
526
+ count: data.count,
527
+ editors: [...data.editors],
528
+ sessionCount: data.sessions.size,
529
+ folders: [...data.folders],
530
+ }))
531
+ .sort((a, b) => b.count - a.count);
532
+
533
+ // 5. Match tool calls to MCP servers using actual queried tool names.
534
+ // Editors prefix MCP tool names in various ways:
535
+ // - Windsurf: mcp{N}_{toolName} (e.g. mcp1_query-docs)
536
+ // - Cursor: mcp_{ServerName}_{toolName} (e.g. mcp_Figma_get_figma_data)
537
+ // - VS Code: mcp_{sanitizedId}_{toolName} (e.g. mcp_io_github_byt_execute_sql)
538
+ // - Others: {server}_{sep}_{toolName} (e.g. prompts_chat__search_prompts)
539
+ //
540
+ // IMPORTANT: Only match tool calls that have an explicit MCP prefix.
541
+ // Tool calls without a prefix (e.g. "read_file", "edit_file") are built-in
542
+ // editor tools even if an MCP server happens to expose a tool with the same name.
543
+ const matchedTools = {};
544
+
545
+ for (const tc of toolCalls) {
546
+ const tcName = tc.name;
547
+ let serverName = null;
548
+
549
+ // Pattern 1: Windsurf — mcp{N}_{toolName}
550
+ const windsurfMatch = tcName.match(/^mcp(\d+)_(.+)$/);
551
+ if (windsurfMatch) {
552
+ const stripped = windsurfMatch[2];
553
+ serverName = toolToServer[stripped];
554
+ if (!serverName) {
555
+ // Fallback: search all server tool sets
556
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
557
+ if (toolSet.has(stripped.toLowerCase())) { serverName = sn; break; }
558
+ }
559
+ }
560
+ }
561
+
562
+ // Pattern 2: Cursor — mcp_{ServerName}_{toolName}
563
+ if (!serverName) {
564
+ const cursorMatch = tcName.match(/^mcp_([^_]+)_(.+)$/);
565
+ if (cursorMatch) {
566
+ const sName = cursorMatch[1];
567
+ const tName = cursorMatch[2];
568
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
569
+ if (sn.toLowerCase() === sName.toLowerCase() && toolSet.has(tName.toLowerCase())) {
570
+ serverName = sn; break;
571
+ }
572
+ }
573
+ // Even if we can't verify the tool, the prefix confirms it's an MCP call
574
+ if (!serverName) {
575
+ for (const s of servers) {
576
+ if (s.name.toLowerCase() === sName.toLowerCase()) { serverName = s.name; break; }
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ // Pattern 3: VS Code / generic — mcp_{sanitizedServerId}_{toolName}
583
+ // Server IDs like "io.github.f/prompts.chat-mcp" become "io_github_f_prompts_chat_mcp"
584
+ if (!serverName && tcName.startsWith('mcp_') && !tcName.match(/^mcp\d/)) {
585
+ const suffix = tcName.slice(4).toLowerCase(); // after "mcp_"
586
+ // First try matching via queried tool sets
587
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
588
+ for (const tool of toolSet) {
589
+ if (suffix.endsWith('_' + tool) || suffix.endsWith(tool)) {
590
+ serverName = sn; break;
591
+ }
592
+ }
593
+ if (serverName) break;
594
+ }
595
+ // Fallback: match by sanitized server name (for servers whose tools couldn't be queried)
596
+ // VS Code may truncate sanitized server IDs, so find the best (longest) prefix match
597
+ if (!serverName) {
598
+ let bestLen = 0;
599
+ let bestServer = null;
600
+ for (const s of servers) {
601
+ const sanitized = s.name.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/_+/g, '_');
602
+ // Full sanitized name match
603
+ if (suffix.startsWith(sanitized + '_') && sanitized.length > bestLen) {
604
+ bestLen = sanitized.length; bestServer = s.name;
605
+ }
606
+ // Truncated prefix: suffix must start with at least 60% of sanitized name
607
+ const minLen = Math.max(6, Math.ceil(sanitized.length * 0.6));
608
+ for (let len = sanitized.length; len >= minLen; len--) {
609
+ const prefix = sanitized.slice(0, len);
610
+ if (suffix.startsWith(prefix + '_') && len > bestLen) {
611
+ bestLen = len; bestServer = s.name; break;
612
+ }
613
+ }
614
+ }
615
+ if (bestServer) serverName = bestServer;
616
+ }
617
+ }
618
+
619
+ // Pattern 4: Double-underscore separator — {server_name}__{toolName}
620
+ if (!serverName) {
621
+ const sepMatch = tcName.match(/^(.+?)__(.+)$/);
622
+ if (sepMatch) {
623
+ const tName = sepMatch[2];
624
+ for (const [sn, toolSet] of Object.entries(serverToolPatterns)) {
625
+ if (toolSet.has(tName.toLowerCase())) { serverName = sn; break; }
626
+ }
627
+ }
628
+ }
629
+
630
+ if (serverName) {
631
+ if (!matchedTools[serverName]) matchedTools[serverName] = [];
632
+ matchedTools[serverName].push(tc);
633
+ }
634
+ }
635
+
636
+ // 6. Top sessions by tool calls
637
+ const topSessions = Object.values(sessionMap)
638
+ .sort((a, b) => b.totalToolCalls - a.totalToolCalls)
639
+ .slice(0, 50);
640
+
641
+ // 7. Per-project MCP stats
642
+ const projects = cache.getCachedProjects({ hiddenFolders: getHiddenFolders() });
643
+ const projectMcpConfigs = [
644
+ { file: '.mcp.json', editor: 'claude-code', label: 'Claude Code' },
645
+ { file: '.cursor/mcp.json', editor: 'cursor', label: 'Cursor' },
646
+ { file: '.vscode/mcp.json', editor: 'vscode', label: 'VS Code' },
647
+ { file: '.gemini/settings.json', editor: 'gemini-cli', label: 'Gemini CLI' },
648
+ { file: '.kiro/settings/mcp.json', editor: 'kiro', label: 'Kiro' },
649
+ ];
650
+
651
+ const projectMcps = [];
652
+ for (const proj of projects) {
653
+ if (!proj.folder) continue;
654
+ const configs = [];
655
+ for (const pc of projectMcpConfigs) {
656
+ const configPath = path.join(proj.folder, pc.file);
657
+ if (!fs.existsSync(configPath)) continue;
658
+ try {
659
+ const raw = fs.readFileSync(configPath, 'utf-8');
660
+ const parsed = JSON.parse(raw);
661
+ const mcpServers = parsed.mcpServers || parsed.mcp_servers || parsed.servers || {};
662
+ configs.push({
663
+ file: pc.file,
664
+ editor: pc.editor,
665
+ editorLabel: pc.label,
666
+ serverCount: Object.keys(mcpServers).length,
667
+ serverNames: Object.keys(mcpServers),
668
+ });
669
+ } catch { /* skip invalid configs */ }
670
+ }
671
+ if (configs.length === 0) continue;
672
+
673
+ // Count MCP tool calls from this project's sessions
674
+ const projToolCalls = toolRows.filter(r => r.folder === proj.folder);
675
+ const mcpToolCallCount = projToolCalls.filter(r => {
676
+ const n = r.tool_name;
677
+ return n.startsWith('mcp') || n.includes('__');
678
+ }).length;
679
+
680
+ // Which configured servers are used (matched to tool calls)
681
+ const configuredServerNames = new Set(configs.flatMap(c => c.serverNames));
682
+ const matchedServerNames = [];
683
+ for (const sn of configuredServerNames) {
684
+ if (matchedTools[sn]) matchedServerNames.push(sn);
685
+ }
686
+
687
+ projectMcps.push({
688
+ folder: proj.folder,
689
+ name: proj.name,
690
+ configs,
691
+ totalServers: [...configuredServerNames].length,
692
+ matchedServers: matchedServerNames.length,
693
+ mcpToolCalls: mcpToolCallCount,
694
+ totalSessions: proj.totalSessions || 0,
695
+ });
696
+ }
697
+
698
+ projectMcps.sort((a, b) => b.totalServers - a.totalServers || b.mcpToolCalls - a.mcpToolCalls);
699
+
700
+ // Strip _env from response (security)
701
+ const safeServers = servers.map(({ _env, ...rest }) => rest);
702
+
703
+ res.json({
704
+ servers: safeServers,
705
+ toolCalls,
706
+ matchedTools,
707
+ topSessions,
708
+ projectMcps,
709
+ summary: {
710
+ totalServers: servers.length,
711
+ totalToolCalls: toolRows.length,
712
+ uniqueTools: toolCalls.length,
713
+ sessionsWithTools: Object.keys(sessionMap).length,
714
+ editorsWithServers: [...new Set(servers.map(s => s.editor))],
715
+ },
716
+ });
717
+ } catch (err) {
718
+ res.status(500).json({ error: err.message });
719
+ }
720
+ });
721
+
434
722
  app.get('/api/all-projects', (req, res) => {
435
723
  try {
436
724
  res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), includeHidden: true }));
package/ui/src/App.jsx CHANGED
@@ -17,6 +17,7 @@ import SqlViewer from './pages/SqlViewer'
17
17
  import Artifacts from './pages/Artifacts'
18
18
  import Settings from './pages/Settings'
19
19
  import Subscriptions from './pages/Subscriptions'
20
+ import MCPs from './pages/MCPs'
20
21
  import RelayDashboard from './pages/RelayDashboard'
21
22
  import RelayUserDetail from './pages/RelayUserDetail'
22
23
 
@@ -148,6 +149,7 @@ export default function App() {
148
149
  { to: '/compare', icon: GitCompare, label: 'Compare' },
149
150
  ]},
150
151
  { to: '/artifacts', icon: Package, label: 'Artifacts' },
152
+ { to: '/mcps', icon: Plug, label: 'MCPs' },
151
153
  { to: '/sql', icon: Database, label: 'SQL' },
152
154
  ]
153
155
 
@@ -275,6 +277,7 @@ export default function App() {
275
277
  <Route path="/compare" element={<Compare overview={overview} />} />
276
278
  <Route path="/subscriptions" element={<Subscriptions />} />
277
279
  <Route path="/artifacts" element={<Artifacts />} />
280
+ <Route path="/mcps" element={<MCPs />} />
278
281
  <Route path="/sql" element={<SqlViewer />} />
279
282
  <Route path="/settings" element={<Settings />} />
280
283
  </Routes>
@@ -0,0 +1,11 @@
1
+ export default function PageHeader({ icon: Icon, title, children }) {
2
+ return (
3
+ <div className="flex items-center gap-3">
4
+ <div className="flex items-center gap-1.5 text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
5
+ {Icon && <Icon size={14} style={{ color: '#6366f1' }} />}
6
+ {title}
7
+ </div>
8
+ {children}
9
+ </div>
10
+ )
11
+ }
package/ui/src/lib/api.js CHANGED
@@ -227,6 +227,13 @@ export async function fetchArtifactContent(filePath) {
227
227
  return res.json();
228
228
  }
229
229
 
230
+ // ── MCPs API ──
231
+
232
+ export async function fetchMCPs() {
233
+ const res = await fetch(`${BASE}/api/mcps`);
234
+ return res.json();
235
+ }
236
+
230
237
  // ── Relay API ──
231
238
 
232
239
  export async function fetchMode() {
@@ -6,6 +6,7 @@ import { fetchArtifacts, fetchArtifactContent } from '../lib/api'
6
6
  import { editorColor, editorLabel } from '../lib/constants'
7
7
  import EditorIcon from '../components/EditorIcon'
8
8
  import AnimatedLoader from '../components/AnimatedLoader'
9
+ import PageHeader from '../components/PageHeader'
9
10
 
10
11
  const MONO = 'JetBrains Mono, monospace'
11
12
 
@@ -395,36 +396,22 @@ export default function Artifacts() {
395
396
 
396
397
  return (
397
398
  <div className="h-full">
398
- {/* Header stats */}
399
- <div className="flex items-center gap-4 mb-0 px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
400
- <div className="flex items-center gap-4">
401
- <div className="flex items-center gap-2 px-3 py-1.5 rounded" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)' }}>
402
- <Package size={13} style={{ color: '#6366f1' }} />
403
- <span className="text-[12px] font-medium" style={{ color: 'var(--c-white)' }}>{totalArtifacts}</span>
404
- <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>artifacts</span>
399
+ {/* Header */}
400
+ <div className="px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
401
+ <PageHeader icon={Package} title="Artifacts">
402
+ <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>{totalArtifacts} artifacts · {totalProjects} projects · {allEditors.size} editors</span>
403
+ <div className="ml-auto relative">
404
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
405
+ <input
406
+ type="text"
407
+ placeholder="Search artifacts..."
408
+ value={search}
409
+ onChange={e => setSearch(e.target.value)}
410
+ className="pl-7 pr-3 py-1 text-[12px] outline-none w-[200px]"
411
+ style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
412
+ />
405
413
  </div>
406
- <div className="flex items-center gap-2 px-3 py-1.5 rounded" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)' }}>
407
- <FolderOpen size={13} style={{ color: '#6366f1' }} />
408
- <span className="text-[12px] font-medium" style={{ color: 'var(--c-white)' }}>{totalProjects}</span>
409
- <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>projects</span>
410
- </div>
411
- <div className="flex items-center gap-2 px-3 py-1.5 rounded" style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)' }}>
412
- <Hash size={13} style={{ color: '#6366f1' }} />
413
- <span className="text-[12px] font-medium" style={{ color: 'var(--c-white)' }}>{allEditors.size}</span>
414
- <span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>editors</span>
415
- </div>
416
- </div>
417
- <div className="ml-auto relative">
418
- <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
419
- <input
420
- type="text"
421
- placeholder="Search artifacts..."
422
- value={search}
423
- onChange={e => setSearch(e.target.value)}
424
- className="pl-7 pr-3 py-1.5 text-[12px] rounded w-[240px]"
425
- style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-text)', outline: 'none' }}
426
- />
427
- </div>
414
+ </PageHeader>
428
415
  </div>
429
416
 
430
417
  {filtered.length === 0 ? (
@@ -5,6 +5,7 @@ import { Bar } from 'react-chartjs-2'
5
5
  import { fetchDeepAnalytics, fetchChats } from '../lib/api'
6
6
  import { editorColor, editorLabel, formatNumber } from '../lib/constants'
7
7
  import { useTheme } from '../lib/theme'
8
+ import PageHeader from '../components/PageHeader'
8
9
 
9
10
  ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend)
10
11
 
@@ -152,7 +153,7 @@ export default function Compare({ overview }) {
152
153
 
153
154
  return (
154
155
  <div className="fade-in space-y-3">
155
- <div className="flex items-center gap-2">
156
+ <PageHeader icon={ArrowLeftRight} title="Compare">
156
157
  <select
157
158
  value={editorA}
158
159
  onChange={e => setEditorA(e.target.value)}
@@ -161,7 +162,7 @@ export default function Compare({ overview }) {
161
162
  >
162
163
  {editors.map(e => <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>)}
163
164
  </select>
164
- <ArrowLeftRight size={12} style={{ color: 'var(--c-text3)' }} />
165
+ <span style={{ color: 'var(--c-text3)' }}>vs</span>
165
166
  <select
166
167
  value={editorB}
167
168
  onChange={e => setEditorB(e.target.value)}
@@ -171,7 +172,7 @@ export default function Compare({ overview }) {
171
172
  {editors.map(e => <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>)}
172
173
  </select>
173
174
  {loading && <Loader2 size={11} className="animate-spin" style={{ color: 'var(--c-text3)' }} />}
174
- </div>
175
+ </PageHeader>
175
176
 
176
177
  {result && (
177
178
  <div className="space-y-3">
@@ -12,6 +12,7 @@ import AnimatedLoader from '../components/AnimatedLoader'
12
12
  import SectionTitle from '../components/SectionTitle'
13
13
  import DateRangePicker from '../components/DateRangePicker'
14
14
  import ChatSidebar from '../components/ChatSidebar'
15
+ import PageHeader from '../components/PageHeader'
15
16
 
16
17
  ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler)
17
18
 
@@ -102,17 +103,13 @@ export default function CostAnalysis({ overview }) {
102
103
  }
103
104
 
104
105
  return (
105
- <div className="fade-in space-y-4">
106
+ <div className="fade-in space-y-3">
106
107
  {/* Filters */}
107
- <div className="flex items-center gap-3">
108
- <div className="flex items-center gap-1.5 text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
109
- <DollarSign size={14} style={{ color: '#6366f1' }} />
110
- Cost Analysis
111
- </div>
108
+ <PageHeader icon={DollarSign} title="Cost Analysis">
112
109
  <select
113
110
  value={editor}
114
111
  onChange={e => setEditor(e.target.value)}
115
- className="px-2 py-1 text-[12px] outline-none appearance-none cursor-pointer"
112
+ className="px-2 py-1 text-[12px] outline-none"
116
113
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
117
114
  >
118
115
  <option value="">All Editors</option>
@@ -121,7 +118,7 @@ export default function CostAnalysis({ overview }) {
121
118
  ))}
122
119
  </select>
123
120
  <div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
124
- </div>
121
+ </PageHeader>
125
122
 
126
123
  {/* Disclaimer */}
127
124
  <div className="flex items-start gap-2 px-3 py-2 text-[11px] rounded" style={{ background: 'rgba(234,179,8,0.06)', border: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
@@ -130,7 +127,7 @@ export default function CostAnalysis({ overview }) {
130
127
  </div>
131
128
 
132
129
  {/* KPIs */}
133
- <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))' }}>
130
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
134
131
  <KpiCard label="total est. cost" value={formatCost(totalCost)} sub="all models" />
135
132
  <KpiCard label="avg / session" value={formatCost(summary.avgPerSession)} sub={`${formatNumber(summary.totalSessions)} sessions`} />
136
133
  <KpiCard label="avg / day" value={formatCost(summary.avgPerDay)} sub={`${summary.totalDays} days`} />
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { useNavigate } from 'react-router-dom'
3
- import { ArrowRight, X, Flame, Zap, MessageSquare, Wrench, Share2, AlertTriangle } from 'lucide-react'
3
+ import { ArrowRight, X, Flame, Zap, MessageSquare, Wrench, Share2, AlertTriangle, LayoutDashboard } from 'lucide-react'
4
4
  import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler } from 'chart.js'
5
5
  import { Doughnut, Bar, Line } from 'react-chartjs-2'
6
6
  import KpiCard from '../components/KpiCard'
@@ -14,6 +14,7 @@ import AnimatedLoader from '../components/AnimatedLoader'
14
14
  import ShareModal from '../components/ShareModal'
15
15
  import { useTheme } from '../lib/theme'
16
16
  import SectionTitle from '../components/SectionTitle'
17
+ import PageHeader from '../components/PageHeader'
17
18
 
18
19
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
19
20
 
@@ -175,17 +176,19 @@ export default function Dashboard({ overview }) {
175
176
  return (
176
177
  <div className="fade-in space-y-3">
177
178
  {/* Top bar */}
178
- <div className="flex items-center justify-end gap-3">
179
- <DateRangePicker value={dateRange} onChange={setDateRange} />
180
- <button
181
- onClick={() => setShareOpen(true)}
182
- className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
183
- style={{ background: '#6366f1', color: '#fff' }}
184
- >
185
- <Share2 size={12} />
186
- Share Stats
187
- </button>
188
- </div>
179
+ <PageHeader icon={LayoutDashboard} title="Dashboard">
180
+ <div className="ml-auto flex items-center gap-3">
181
+ <DateRangePicker value={dateRange} onChange={setDateRange} />
182
+ <button
183
+ onClick={() => setShareOpen(true)}
184
+ className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
185
+ style={{ background: '#6366f1', color: '#fff' }}
186
+ >
187
+ <Share2 size={12} />
188
+ Share Stats
189
+ </button>
190
+ </div>
191
+ </PageHeader>
189
192
 
190
193
  {/* Editor breakdown - compact row */}
191
194
  <div className="card p-3">
@@ -9,6 +9,7 @@ import KpiCard from '../components/KpiCard'
9
9
  import EditorIcon from '../components/EditorIcon'
10
10
  import SectionTitle from '../components/SectionTitle'
11
11
  import DateRangePicker from '../components/DateRangePicker'
12
+ import PageHeader from '../components/PageHeader'
12
13
 
13
14
  ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
14
15
 
@@ -247,13 +248,13 @@ export default function DeepAnalysis({ overview }) {
247
248
  }
248
249
 
249
250
  return (
250
- <div className="fade-in space-y-4">
251
+ <div className="fade-in space-y-3">
251
252
  {/* Filters */}
252
- <div className="flex items-center gap-2">
253
+ <PageHeader icon={TrendingUp} title="Deep Analysis">
253
254
  <select
254
255
  value={editor}
255
256
  onChange={e => setEditor(e.target.value)}
256
- className="px-2 py-1.5 text-[12px] outline-none rounded-sm"
257
+ className="px-2 py-1 text-[12px] outline-none"
257
258
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
258
259
  >
259
260
  <option value="">All Editors</option>
@@ -264,7 +265,7 @@ export default function DeepAnalysis({ overview }) {
264
265
  <select
265
266
  value={folder}
266
267
  onChange={e => setFolder(e.target.value)}
267
- className="px-2 py-1.5 text-[12px] outline-none max-w-[200px] truncate rounded-sm"
268
+ className="px-2 py-1 text-[12px] outline-none max-w-[200px] truncate"
268
269
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
269
270
  >
270
271
  <option value="">All Projects</option>
@@ -275,7 +276,7 @@ export default function DeepAnalysis({ overview }) {
275
276
  {loading && <Loader2 size={11} className="animate-spin" style={{ color: 'var(--c-text3)' }} />}
276
277
  {data && <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>{data.analyzedChats} sessions analyzed</span>}
277
278
  <div className="ml-auto"><DateRangePicker value={dateRange} onChange={setDateRange} /></div>
278
- </div>
279
+ </PageHeader>
279
280
 
280
281
  {data && insights && (
281
282
  <>