agentlytics 0.1.3 → 0.1.6
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 +1 -1
- package/cache.js +420 -10
- package/editors/cursor.js +28 -6
- package/editors/vscode.js +6 -0
- package/index.js +78 -11
- package/package.json +2 -1
- package/server.js +27 -0
- package/ui/package-lock.json +60 -375
- package/ui/package.json +1 -1
- package/ui/src/App.jsx +22 -17
- package/ui/src/components/ActivityHeatmap.jsx +3 -3
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +7 -7
- package/ui/src/components/DateRangePicker.jsx +5 -5
- package/ui/src/components/EditorBreakdown.jsx +2 -2
- package/ui/src/components/EditorDot.jsx +1 -1
- package/ui/src/components/KpiCard.jsx +2 -2
- package/ui/src/components/LiveFeed.jsx +8 -8
- package/ui/src/components/LoginScreen.jsx +8 -6
- package/ui/src/components/MessageRenderer.jsx +5 -5
- package/ui/src/components/ModelBreakdown.jsx +3 -3
- package/ui/src/components/SectionTitle.jsx +1 -1
- package/ui/src/index.css +1 -1
- package/ui/src/lib/api.js +20 -0
- package/ui/src/lib/constants.js +8 -0
- package/ui/src/pages/ChatDetail.jsx +5 -2
- package/ui/src/pages/Compare.jsx +18 -18
- package/ui/src/pages/CostAnalysis.jsx +356 -0
- package/ui/src/pages/Dashboard.jsx +39 -21
- package/ui/src/pages/DeepAnalysis.jsx +38 -31
- package/ui/src/pages/ProjectDetail.jsx +23 -15
- package/ui/src/pages/Projects.jsx +14 -8
- package/ui/src/pages/RelayDashboard.jsx +29 -29
- package/ui/src/pages/RelaySessionDetail.jsx +1 -1
- package/ui/src/pages/RelayUserDetail.jsx +18 -18
- package/ui/src/pages/Sessions.jsx +24 -20
- package/ui/src/pages/SqlViewer.jsx +14 -14
package/ui/package.json
CHANGED
package/ui/src/App.jsx
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { Routes, Route, NavLink } from 'react-router-dom'
|
|
3
|
-
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
|
|
3
|
+
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
|
|
4
4
|
import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
|
+
import AnimatedLogo from './components/AnimatedLogo'
|
|
6
7
|
import LoginScreen from './components/LoginScreen'
|
|
7
8
|
import Dashboard from './pages/Dashboard'
|
|
8
9
|
import Sessions from './pages/Sessions'
|
|
@@ -11,6 +12,7 @@ import Compare from './pages/Compare'
|
|
|
11
12
|
import ChatDetail from './pages/ChatDetail'
|
|
12
13
|
import Projects from './pages/Projects'
|
|
13
14
|
import ProjectDetail from './pages/ProjectDetail'
|
|
15
|
+
import CostAnalysis from './pages/CostAnalysis'
|
|
14
16
|
import SqlViewer from './pages/SqlViewer'
|
|
15
17
|
import RelayDashboard from './pages/RelayDashboard'
|
|
16
18
|
import RelayUserDetail from './pages/RelayUserDetail'
|
|
@@ -85,6 +87,7 @@ export default function App() {
|
|
|
85
87
|
{ to: '/', icon: Activity, label: 'Dashboard' },
|
|
86
88
|
{ to: '/projects', icon: FolderOpen, label: 'Projects' },
|
|
87
89
|
{ to: '/sessions', icon: MessageSquare, label: 'Sessions' },
|
|
90
|
+
{ to: '/costs', icon: DollarSign, label: 'Costs' },
|
|
88
91
|
{ to: '/analysis', icon: BarChart3, label: 'Analysis' },
|
|
89
92
|
{ to: '/compare', icon: GitCompare, label: 'Compare' },
|
|
90
93
|
{ to: '/sql', icon: Database, label: 'SQL' },
|
|
@@ -97,8 +100,9 @@ export default function App() {
|
|
|
97
100
|
return (
|
|
98
101
|
<div className="min-h-screen">
|
|
99
102
|
<header className="border-b px-4 py-1.5 flex items-center gap-3 sticky top-0 z-50 backdrop-blur-xl" style={{ borderColor: 'var(--c-border)', background: 'var(--c-header)' }}>
|
|
100
|
-
<span className="text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
|
|
101
|
-
|
|
103
|
+
<span className="flex items-center gap-1.5 text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
|
|
104
|
+
<AnimatedLogo size={18} />
|
|
105
|
+
Agentlytics{isRelay && <span className="ml-1.5 text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
|
|
102
106
|
</span>
|
|
103
107
|
<nav className="flex gap-0.5 ml-2">
|
|
104
108
|
{nav.map(({ to, icon: Icon, label }) => (
|
|
@@ -107,7 +111,7 @@ export default function App() {
|
|
|
107
111
|
to={to}
|
|
108
112
|
end={to === '/'}
|
|
109
113
|
className={({ isActive }) =>
|
|
110
|
-
`flex items-center gap-1.5 px-2.5 py-1 text-[
|
|
114
|
+
`flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
|
|
111
115
|
isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
|
|
112
116
|
}`
|
|
113
117
|
}
|
|
@@ -122,7 +126,7 @@ export default function App() {
|
|
|
122
126
|
<>
|
|
123
127
|
<button
|
|
124
128
|
onClick={() => setLive(!live)}
|
|
125
|
-
className="flex items-center gap-1.5 px-2 py-0.5 text-[
|
|
129
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
|
|
126
130
|
style={{
|
|
127
131
|
color: live ? '#22c55e' : 'var(--c-text3)',
|
|
128
132
|
border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
|
|
@@ -139,7 +143,7 @@ export default function App() {
|
|
|
139
143
|
<button
|
|
140
144
|
onClick={handleRefetch}
|
|
141
145
|
disabled={!!refetchState}
|
|
142
|
-
className="flex items-center gap-1 px-2 py-0.5 text-[
|
|
146
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition hover:bg-[var(--c-card)]"
|
|
143
147
|
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
144
148
|
title="Clear cache and rescan all editors"
|
|
145
149
|
>
|
|
@@ -148,7 +152,7 @@ export default function App() {
|
|
|
148
152
|
? `Refetching (${refetchState.scanned}/${refetchState.total})...`
|
|
149
153
|
: 'Refetch'}
|
|
150
154
|
</button>
|
|
151
|
-
<span className="text-[
|
|
155
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
|
|
152
156
|
{overview ? `${overview.totalChats} sessions` : '...'}
|
|
153
157
|
</span>
|
|
154
158
|
</>
|
|
@@ -156,7 +160,7 @@ export default function App() {
|
|
|
156
160
|
{isRelay && (
|
|
157
161
|
<button
|
|
158
162
|
onClick={() => { setMcpOpen(true); setMcpCopied(false) }}
|
|
159
|
-
className="flex items-center gap-1.5 px-2 py-0.5 text-[
|
|
163
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition hover:bg-[var(--c-card)]"
|
|
160
164
|
style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
|
|
161
165
|
title="MCP Connection"
|
|
162
166
|
>
|
|
@@ -176,7 +180,7 @@ export default function App() {
|
|
|
176
180
|
</header>
|
|
177
181
|
|
|
178
182
|
{refetchState && (
|
|
179
|
-
<div className="flex items-center gap-2 px-4 py-1.5 text-[
|
|
183
|
+
<div className="flex items-center gap-2 px-4 py-1.5 text-[12px]" style={{ background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
|
|
180
184
|
<AlertTriangle size={12} />
|
|
181
185
|
<span>Windsurf, Windsurf Next, and Antigravity require their app to be running during refetch — otherwise their sessions won't be detected.</span>
|
|
182
186
|
</div>
|
|
@@ -198,6 +202,7 @@ export default function App() {
|
|
|
198
202
|
<Route path="/projects/detail" element={<ProjectDetail />} />
|
|
199
203
|
<Route path="/sessions" element={<Sessions overview={overview} />} />
|
|
200
204
|
{/* ChatDetail is now a sidebar in Sessions */}
|
|
205
|
+
<Route path="/costs" element={<CostAnalysis overview={overview} />} />
|
|
201
206
|
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
202
207
|
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
203
208
|
<Route path="/sql" element={<SqlViewer />} />
|
|
@@ -205,7 +210,7 @@ export default function App() {
|
|
|
205
210
|
)}
|
|
206
211
|
</main>
|
|
207
212
|
|
|
208
|
-
<footer className="border-t mt-8 px-4 py-3 flex items-center justify-between text-[
|
|
213
|
+
<footer className="border-t mt-8 px-4 py-3 flex items-center justify-between text-[11px]" style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
|
|
209
214
|
<div className="flex items-center gap-3">
|
|
210
215
|
<a href="https://github.com/f/agentlytics" target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-[var(--c-text)] transition">
|
|
211
216
|
<Github size={11} />
|
|
@@ -237,9 +242,9 @@ export default function App() {
|
|
|
237
242
|
<button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>×</button>
|
|
238
243
|
</div>
|
|
239
244
|
|
|
240
|
-
<div className="text-[
|
|
245
|
+
<div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
|
|
241
246
|
<div className="flex items-center justify-between mb-1">
|
|
242
|
-
<div className="text-[
|
|
247
|
+
<div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
|
|
243
248
|
<button
|
|
244
249
|
onClick={() => {
|
|
245
250
|
const json = JSON.stringify({ "mcpServers": { "agentlytics": { "url": `${window.location.origin}/mcp` } } }, null, 2)
|
|
@@ -247,21 +252,21 @@ export default function App() {
|
|
|
247
252
|
setMcpCopied(true)
|
|
248
253
|
setTimeout(() => setMcpCopied(false), 2000)
|
|
249
254
|
}}
|
|
250
|
-
className="flex items-center gap-1 px-1.5 py-0.5 text-[
|
|
255
|
+
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition hover:bg-[var(--c-bg3)]"
|
|
251
256
|
style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
|
|
252
257
|
>
|
|
253
258
|
{mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
|
|
254
259
|
</button>
|
|
255
260
|
</div>
|
|
256
261
|
<pre
|
|
257
|
-
className="text-[
|
|
262
|
+
className="text-[11px] px-3 py-2 overflow-x-auto mb-4"
|
|
258
263
|
style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
|
|
259
264
|
>{`{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
|
|
260
265
|
|
|
261
|
-
<div className="text-[
|
|
262
|
-
<div className="text-[
|
|
266
|
+
<div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Join Command</div>
|
|
267
|
+
<div className="text-[10px] mb-1" style={{ color: 'var(--c-text3)' }}>Share with your team to start syncing sessions</div>
|
|
263
268
|
<pre
|
|
264
|
-
className="text-[
|
|
269
|
+
className="text-[11px] px-3 py-2 overflow-x-auto"
|
|
265
270
|
style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
|
|
266
271
|
>{`cd /path/to/your-project\nRELAY_PASSWORD=${relayPassword || '<pass>'} npx agentlytics --join ${window.location.host}`}</pre>
|
|
267
272
|
</div>
|
|
@@ -144,7 +144,7 @@ export default function ActivityHeatmap({ dailyData }) {
|
|
|
144
144
|
</svg>
|
|
145
145
|
</div>
|
|
146
146
|
|
|
147
|
-
<div className="flex items-center gap-1.5 mt-1 text-[
|
|
147
|
+
<div className="flex items-center gap-1.5 mt-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
148
148
|
<span>less</span>
|
|
149
149
|
{COLORS.map((color, i) => (
|
|
150
150
|
<span key={i} className="inline-block w-[9px] h-[9px] rounded-sm" style={{ background: color }} />
|
|
@@ -158,13 +158,13 @@ export default function ActivityHeatmap({ dailyData }) {
|
|
|
158
158
|
<div className="flex items-center justify-between mb-2">
|
|
159
159
|
<div>
|
|
160
160
|
<span className="text-xs font-medium" style={{ color: 'var(--c-white)' }}>{selectedDay.key}</span>
|
|
161
|
-
<span className="text-[
|
|
161
|
+
<span className="text-[11px] ml-2" style={{ color: 'var(--c-text2)' }}>
|
|
162
162
|
{selectedDay.count} session{selectedDay.count !== 1 ? 's' : ''}
|
|
163
163
|
{' · '}
|
|
164
164
|
{Object.entries(selectedDay.data.editors || {}).map(([e, c]) => `${editorLabel(e)}: ${c}`).join(', ')}
|
|
165
165
|
</span>
|
|
166
166
|
</div>
|
|
167
|
-
<button onClick={() => setSelectedDay(null)} className="text-[
|
|
167
|
+
<button onClick={() => setSelectedDay(null)} className="text-[11px] transition" style={{ color: 'var(--c-text2)' }}>close</button>
|
|
168
168
|
</div>
|
|
169
169
|
{hourlyChart && (
|
|
170
170
|
<div style={{ height: 140 }}>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export default function AnimatedLogo({ size = 22 }) {
|
|
2
|
+
// Carousel: a horizontal strip of bots slides left continuously.
|
|
3
|
+
// The viewport shows one bot at a time. The next bot pushes the current one out.
|
|
4
|
+
// 5 slots (4 bots + repeat of first) for seamless loop.
|
|
5
|
+
const step = 28 // each bot cell width
|
|
6
|
+
// Keyframes: hold on each bot, then slide to the next.
|
|
7
|
+
// 4 stops: 0→1→2→3→0 (repeat first = slot 4)
|
|
8
|
+
// Each stop: 5% slide + 20% hold = 25% per bot
|
|
9
|
+
return (
|
|
10
|
+
<svg
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
width={size}
|
|
13
|
+
height={size}
|
|
14
|
+
fill="none"
|
|
15
|
+
style={{ overflow: 'hidden' }}
|
|
16
|
+
>
|
|
17
|
+
<style>{`
|
|
18
|
+
@keyframes carousel {
|
|
19
|
+
0% { transform: translateX(0); }
|
|
20
|
+
5% { transform: translateX(0); }
|
|
21
|
+
25% { transform: translateX(-${step}px); }
|
|
22
|
+
30% { transform: translateX(-${step}px); }
|
|
23
|
+
50% { transform: translateX(-${step * 2}px); }
|
|
24
|
+
55% { transform: translateX(-${step * 2}px); }
|
|
25
|
+
75% { transform: translateX(-${step * 3}px); }
|
|
26
|
+
80% { transform: translateX(-${step * 3}px); }
|
|
27
|
+
100% { transform: translateX(-${step * 4}px); }
|
|
28
|
+
}
|
|
29
|
+
.carousel-strip { animation: carousel 4s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
|
|
30
|
+
`}</style>
|
|
31
|
+
|
|
32
|
+
<g className="carousel-strip">
|
|
33
|
+
{/* Slot 0: Bot 1 — indigo */}
|
|
34
|
+
<g transform="translate(0, 0)" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
35
|
+
<path d="M12 8V4H8"/>
|
|
36
|
+
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
|
37
|
+
<path d="M2 14h2"/>
|
|
38
|
+
<path d="M20 14h2"/>
|
|
39
|
+
<path d="M15 13v2"/>
|
|
40
|
+
<path d="M9 13v2"/>
|
|
41
|
+
</g>
|
|
42
|
+
|
|
43
|
+
{/* Slot 1: Bot 2 — pink */}
|
|
44
|
+
<g transform={`translate(${step}, 0)`} stroke="#f472b6" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
45
|
+
<path d="M12 6V2H8"/>
|
|
46
|
+
<path d="M15 11v2"/>
|
|
47
|
+
<path d="M2 12h2"/>
|
|
48
|
+
<path d="M20 12h2"/>
|
|
49
|
+
<path d="M20 16a2 2 0 0 1-2 2H8.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 4 20.286V8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z"/>
|
|
50
|
+
<path d="M9 11v2"/>
|
|
51
|
+
</g>
|
|
52
|
+
|
|
53
|
+
{/* Slot 2: Bot 3 — emerald */}
|
|
54
|
+
<g transform={`translate(${step * 2}, 0)`} stroke="#34d399" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
55
|
+
<path d="M6 6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2l0-4"/>
|
|
56
|
+
<path d="M12 2v2"/>
|
|
57
|
+
<path d="M9 12v9"/>
|
|
58
|
+
<path d="M15 12v9"/>
|
|
59
|
+
<path d="M5 16l4-2"/>
|
|
60
|
+
<path d="M15 14l4 2"/>
|
|
61
|
+
<path d="M9 18h6"/>
|
|
62
|
+
<path d="M10 8v.01"/>
|
|
63
|
+
<path d="M14 8v.01"/>
|
|
64
|
+
</g>
|
|
65
|
+
|
|
66
|
+
{/* Slot 3: Bot 4 — amber */}
|
|
67
|
+
<g transform={`translate(${step * 3}, 0)`} stroke="#fbbf24" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
68
|
+
<path d="M12 20v2"/>
|
|
69
|
+
<path d="M12 2v2"/>
|
|
70
|
+
<path d="M17 20v2"/>
|
|
71
|
+
<path d="M17 2v2"/>
|
|
72
|
+
<path d="M2 12h2"/>
|
|
73
|
+
<path d="M2 17h2"/>
|
|
74
|
+
<path d="M2 7h2"/>
|
|
75
|
+
<path d="M20 12h2"/>
|
|
76
|
+
<path d="M20 17h2"/>
|
|
77
|
+
<path d="M20 7h2"/>
|
|
78
|
+
<path d="M7 20v2"/>
|
|
79
|
+
<path d="M7 2v2"/>
|
|
80
|
+
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
|
81
|
+
<rect x="8" y="8" width="8" height="8" rx="1"/>
|
|
82
|
+
</g>
|
|
83
|
+
|
|
84
|
+
{/* Slot 4: Repeat Bot 1 for seamless loop — indigo */}
|
|
85
|
+
<g transform={`translate(${step * 4}, 0)`} stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
86
|
+
<path d="M12 8V4H8"/>
|
|
87
|
+
<rect width="16" height="12" x="4" y="8" rx="2"/>
|
|
88
|
+
<path d="M2 14h2"/>
|
|
89
|
+
<path d="M20 14h2"/>
|
|
90
|
+
<path d="M15 13v2"/>
|
|
91
|
+
<path d="M9 13v2"/>
|
|
92
|
+
</g>
|
|
93
|
+
</g>
|
|
94
|
+
</svg>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -77,7 +77,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
77
77
|
<div className="text-sm font-medium truncate" style={{ color: 'var(--c-white)' }}>
|
|
78
78
|
{chat.name || '(untitled)'}
|
|
79
79
|
</div>
|
|
80
|
-
<div className="flex items-center gap-2 text-[
|
|
80
|
+
<div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--c-text2)' }}>
|
|
81
81
|
<span className="inline-flex items-center gap-1">
|
|
82
82
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: editorColor(chat.source) }} />
|
|
83
83
|
{editorLabel(chat.source)}
|
|
@@ -91,7 +91,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
91
91
|
<a
|
|
92
92
|
href={`${BASE}/api/chats/${chat.id}/markdown`}
|
|
93
93
|
download
|
|
94
|
-
className="flex items-center gap-1 px-2 py-1 text-[
|
|
94
|
+
className="flex items-center gap-1 px-2 py-1 text-[11px] transition shrink-0"
|
|
95
95
|
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
96
96
|
>
|
|
97
97
|
<Download size={11} /> .md
|
|
@@ -102,7 +102,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
102
102
|
|
|
103
103
|
{/* Stats row */}
|
|
104
104
|
{chat?.stats && (
|
|
105
|
-
<div className="flex items-center gap-3 px-4 py-2 text-[
|
|
105
|
+
<div className="flex items-center gap-3 px-4 py-2 text-[11px] shrink-0" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text2)' }}>
|
|
106
106
|
<span>{chat.stats.totalMessages} msgs</span>
|
|
107
107
|
{chat.stats.toolCalls?.length > 0 && <span>{chat.stats.toolCalls.length} tools</span>}
|
|
108
108
|
{chat.stats.totalInputTokens > 0 && <span>{formatNumber(chat.stats.totalInputTokens)} in</span>}
|
|
@@ -125,7 +125,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
125
125
|
placeholder="Filter messages..."
|
|
126
126
|
value={msgFilter}
|
|
127
127
|
onChange={e => setMsgFilter(e.target.value)}
|
|
128
|
-
className="w-full pl-7 pr-3 py-1 text-[
|
|
128
|
+
className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
|
|
129
129
|
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
130
130
|
/>
|
|
131
131
|
</div>
|
|
@@ -135,10 +135,10 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
135
135
|
{/* Messages */}
|
|
136
136
|
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-4 py-3 space-y-2">
|
|
137
137
|
{loading && (
|
|
138
|
-
<div className="text-[
|
|
138
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>Loading conversation...</div>
|
|
139
139
|
)}
|
|
140
140
|
{!loading && chat && chat.messages.length === 0 && (
|
|
141
|
-
<div className="text-[
|
|
141
|
+
<div className="text-[12px] py-12 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
142
142
|
{chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
|
|
143
143
|
</div>
|
|
144
144
|
)}
|
|
@@ -149,7 +149,7 @@ export default function ChatSidebar({ chatId, onClose, fetchFn, extraHeader, use
|
|
|
149
149
|
const Icon = cfg.icon
|
|
150
150
|
return (
|
|
151
151
|
<div key={i} className="rounded-r px-3 py-2" style={{ borderLeft: `2px solid ${cfg.borderColor}`, background: cfg.bg }}>
|
|
152
|
-
<div className="flex items-center gap-1.5 text-[
|
|
152
|
+
<div className="flex items-center gap-1.5 text-[11px] mb-1" style={{ color: 'var(--c-text2)' }}>
|
|
153
153
|
<Icon size={11} />
|
|
154
154
|
<span className="font-medium">{msg.role === 'user' && (username || chat?.username) ? (username || chat.username) : cfg.label}</span>
|
|
155
155
|
{msg.model && <span className="font-mono" style={{ color: 'var(--c-accent)', opacity: 0.6 }}>· {msg.model}</span>}
|
|
@@ -46,7 +46,7 @@ export default function DateRangePicker({ value, onChange }) {
|
|
|
46
46
|
<button
|
|
47
47
|
key={p.label}
|
|
48
48
|
onClick={() => applyPreset(p.days)}
|
|
49
|
-
className="px-2 py-0.5 text-[
|
|
49
|
+
className="px-2 py-0.5 text-[11px] transition"
|
|
50
50
|
style={{
|
|
51
51
|
border: isActive ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
|
|
52
52
|
color: isActive ? 'var(--c-accent)' : 'var(--c-text2)',
|
|
@@ -64,7 +64,7 @@ export default function DateRangePicker({ value, onChange }) {
|
|
|
64
64
|
value={value?.from || ''}
|
|
65
65
|
max={value?.to || today}
|
|
66
66
|
onChange={e => setFrom(e.target.value)}
|
|
67
|
-
className="px-1.5 py-0.5 text-[
|
|
67
|
+
className="px-1.5 py-0.5 text-[11px] outline-none cursor-pointer"
|
|
68
68
|
style={{
|
|
69
69
|
background: 'var(--c-bg3)',
|
|
70
70
|
color: 'var(--c-text)',
|
|
@@ -72,14 +72,14 @@ export default function DateRangePicker({ value, onChange }) {
|
|
|
72
72
|
colorScheme: dark ? 'dark' : 'light',
|
|
73
73
|
}}
|
|
74
74
|
/>
|
|
75
|
-
<span className="text-[
|
|
75
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>—</span>
|
|
76
76
|
<input
|
|
77
77
|
type="date"
|
|
78
78
|
value={value?.to || ''}
|
|
79
79
|
min={value?.from || ''}
|
|
80
80
|
max={today}
|
|
81
81
|
onChange={e => setTo(e.target.value)}
|
|
82
|
-
className="px-1.5 py-0.5 text-[
|
|
82
|
+
className="px-1.5 py-0.5 text-[11px] outline-none cursor-pointer"
|
|
83
83
|
style={{
|
|
84
84
|
background: 'var(--c-bg3)',
|
|
85
85
|
color: 'var(--c-text)',
|
|
@@ -92,7 +92,7 @@ export default function DateRangePicker({ value, onChange }) {
|
|
|
92
92
|
{active && (
|
|
93
93
|
<button
|
|
94
94
|
onClick={() => onChange(null)}
|
|
95
|
-
className="flex items-center gap-0.5 px-2 py-0.5 text-[
|
|
95
|
+
className="flex items-center gap-0.5 px-2 py-0.5 text-[11px] transition"
|
|
96
96
|
style={{ border: '1px solid var(--c-accent)', color: 'var(--c-accent)' }}
|
|
97
97
|
>
|
|
98
98
|
<X size={9} /> clear
|
|
@@ -9,11 +9,11 @@ export default function EditorBreakdown({ editors, total }) {
|
|
|
9
9
|
return (
|
|
10
10
|
<div key={src} className="flex items-center gap-2">
|
|
11
11
|
<EditorDot source={src} size={8} />
|
|
12
|
-
<span className="text-[
|
|
12
|
+
<span className="text-[11px] w-24" style={{ color: 'var(--c-text)' }}>{editorLabel(src)}</span>
|
|
13
13
|
<div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
|
|
14
14
|
<div className="h-full" style={{ width: `${pct}%`, background: editorColor(src), opacity: 0.7 }} />
|
|
15
15
|
</div>
|
|
16
|
-
<span className="text-[
|
|
16
|
+
<span className="text-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
|
|
17
17
|
</div>
|
|
18
18
|
)
|
|
19
19
|
})}
|
|
@@ -5,7 +5,7 @@ export default function EditorDot({ source, showLabel = false, size = 8 }) {
|
|
|
5
5
|
return (
|
|
6
6
|
<span className="inline-flex items-center gap-1.5">
|
|
7
7
|
<EditorIcon source={source} size={size} />
|
|
8
|
-
{showLabel && <span className="text-[
|
|
8
|
+
{showLabel && <span className="text-[11px]" style={{ color: 'var(--c-text)' }}>{editorLabel(source)}</span>}
|
|
9
9
|
</span>
|
|
10
10
|
)
|
|
11
11
|
}
|
|
@@ -2,8 +2,8 @@ export default function KpiCard({ label, value, sub, onClick }) {
|
|
|
2
2
|
return (
|
|
3
3
|
<div className={`card px-3 py-2${onClick ? ' cursor-pointer hover:opacity-80 transition' : ''}`} onClick={onClick}>
|
|
4
4
|
<div className="text-base font-bold" style={{ color: onClick ? 'var(--c-accent)' : 'var(--c-white)' }}>{value}</div>
|
|
5
|
-
<div className="text-[
|
|
6
|
-
{sub && <div className="text-[
|
|
5
|
+
<div className="text-[11px]" style={{ color: 'var(--c-text2)' }}>{label}</div>
|
|
6
|
+
{sub && <div className="text-[10px] mt-0.5" style={{ color: 'var(--c-text3)' }}>{sub}</div>}
|
|
7
7
|
</div>
|
|
8
8
|
)
|
|
9
9
|
}
|
|
@@ -62,14 +62,14 @@ export default function LiveFeed({ onSessionClick }) {
|
|
|
62
62
|
{/* Header */}
|
|
63
63
|
<div className="flex items-center gap-2 px-3 py-2.5 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
64
64
|
<Radio size={12} style={{ color: '#22c55e' }} />
|
|
65
|
-
<span className="text-[
|
|
65
|
+
<span className="text-[12px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Live Feed</span>
|
|
66
66
|
<span className="inline-block w-1.5 h-1.5 rounded-full pulse-dot ml-auto" style={{ background: '#22c55e' }} />
|
|
67
67
|
</div>
|
|
68
68
|
|
|
69
69
|
{/* Feed */}
|
|
70
70
|
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin">
|
|
71
71
|
{items.length === 0 && (
|
|
72
|
-
<div className="text-[
|
|
72
|
+
<div className="text-[12px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
73
73
|
No recent activity
|
|
74
74
|
</div>
|
|
75
75
|
)}
|
|
@@ -77,7 +77,7 @@ export default function LiveFeed({ onSessionClick }) {
|
|
|
77
77
|
{buckets.map((bucket, bi) => (
|
|
78
78
|
<div key={bi}>
|
|
79
79
|
{/* Time separator */}
|
|
80
|
-
<div className="sticky top-0 px-3 py-1.5 text-[
|
|
80
|
+
<div className="sticky top-0 px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider" style={{ background: 'var(--c-bg)', color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)' }}>
|
|
81
81
|
{bucket.label}
|
|
82
82
|
</div>
|
|
83
83
|
|
|
@@ -89,26 +89,26 @@ export default function LiveFeed({ onSessionClick }) {
|
|
|
89
89
|
onClick={() => onSessionClick && onSessionClick(item.id, item.username)}
|
|
90
90
|
>
|
|
91
91
|
{/* Session name */}
|
|
92
|
-
<div className="text-[
|
|
92
|
+
<div className="text-[12px] font-medium truncate mb-1" style={{ color: 'var(--c-white)' }}>
|
|
93
93
|
{item.name || 'Untitled'}
|
|
94
94
|
</div>
|
|
95
95
|
|
|
96
96
|
{/* User + editor row */}
|
|
97
97
|
<div className="flex items-center gap-1.5 mb-1">
|
|
98
98
|
<span
|
|
99
|
-
className="text-[
|
|
99
|
+
className="text-[10px] font-medium px-1 py-0.5 shrink-0 truncate max-w-[120px]"
|
|
100
100
|
style={{ background: 'rgba(99,102,241,0.12)', color: '#818cf8' }}
|
|
101
101
|
title={item.username}
|
|
102
102
|
>
|
|
103
103
|
{item.username}
|
|
104
104
|
</span>
|
|
105
105
|
<EditorDot source={item.source} size={6} />
|
|
106
|
-
<span className="text-[
|
|
107
|
-
<span className="text-[
|
|
106
|
+
<span className="text-[10px] truncate" style={{ color: 'var(--c-text2)' }}>{editorLabel(item.source)}</span>
|
|
107
|
+
<span className="text-[10px] ml-auto shrink-0" style={{ color: 'var(--c-text3)' }}>{timeLabel(item.lastUpdatedAt)}</span>
|
|
108
108
|
</div>
|
|
109
109
|
|
|
110
110
|
{/* Meta row */}
|
|
111
|
-
<div className="flex items-center gap-2 text-[
|
|
111
|
+
<div className="flex items-center gap-2 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
112
112
|
{item.totalMessages > 0 && (
|
|
113
113
|
<span className="flex items-center gap-0.5">
|
|
114
114
|
<MessageSquare size={8} /> {item.totalMessages}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Lock } from 'lucide-react'
|
|
3
3
|
import { login } from '../lib/api'
|
|
4
|
+
import AnimatedLogo from './AnimatedLogo'
|
|
4
5
|
|
|
5
6
|
export default function LoginScreen({ onSuccess }) {
|
|
6
7
|
const [password, setPassword] = useState('')
|
|
@@ -31,13 +32,14 @@ export default function LoginScreen({ onSuccess }) {
|
|
|
31
32
|
>
|
|
32
33
|
<Lock size={18} style={{ color: '#818cf8' }} />
|
|
33
34
|
</div>
|
|
34
|
-
<div className="text-sm font-bold" style={{ color: 'var(--c-white)' }}>
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
<div className="flex items-center gap-1.5 text-sm font-bold" style={{ color: 'var(--c-white)' }}>
|
|
36
|
+
<AnimatedLogo size={16} />
|
|
37
|
+
Agentlytics
|
|
38
|
+
<span className="text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
37
39
|
relay
|
|
38
40
|
</span>
|
|
39
41
|
</div>
|
|
40
|
-
<div className="text-[
|
|
42
|
+
<div className="text-[11px] mt-1" style={{ color: 'var(--c-text3)' }}>
|
|
41
43
|
This relay is password-protected
|
|
42
44
|
</div>
|
|
43
45
|
</div>
|
|
@@ -57,12 +59,12 @@ export default function LoginScreen({ onSuccess }) {
|
|
|
57
59
|
}}
|
|
58
60
|
/>
|
|
59
61
|
{error && (
|
|
60
|
-
<div className="text-[
|
|
62
|
+
<div className="text-[11px]" style={{ color: '#f87171' }}>{error}</div>
|
|
61
63
|
)}
|
|
62
64
|
<button
|
|
63
65
|
type="submit"
|
|
64
66
|
disabled={loading || !password}
|
|
65
|
-
className="w-full py-2 text-[
|
|
67
|
+
className="w-full py-2 text-[12px] font-medium rounded transition"
|
|
66
68
|
style={{
|
|
67
69
|
background: '#6366f1',
|
|
68
70
|
color: '#fff',
|
|
@@ -52,7 +52,7 @@ export function ToolArgsDiff({ args }) {
|
|
|
52
52
|
const oldLines = (old || '').split('\n').slice(0, maxLines)
|
|
53
53
|
const newLines = (nw || '').split('\n').slice(0, maxLines)
|
|
54
54
|
return (
|
|
55
|
-
<div className="mt-1.5 text-[
|
|
55
|
+
<div className="mt-1.5 text-[10px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
|
|
56
56
|
{(args.file_path || args.TargetFile) && (
|
|
57
57
|
<div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text)' }}>{args.file_path || args.TargetFile}</div>
|
|
58
58
|
)}
|
|
@@ -85,7 +85,7 @@ export function ToolArgsDetail({ args }) {
|
|
|
85
85
|
const query = args.Query || args.query || args.search_term || null
|
|
86
86
|
const url = args.Url || args.url || null
|
|
87
87
|
return (
|
|
88
|
-
<div className="mt-1.5 text-[
|
|
88
|
+
<div className="mt-1.5 text-[10px] font-mono overflow-x-auto" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
89
89
|
{file && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>file: {file}</div>}
|
|
90
90
|
{cmd && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>cmd: {cmd}</div>}
|
|
91
91
|
{query && <div className="px-2 py-0.5" style={{ color: 'var(--c-text)' }}>query: {query}</div>}
|
|
@@ -101,7 +101,7 @@ export function ToolCallBlock({ name, args, detail }) {
|
|
|
101
101
|
const [open, setOpen] = useState(false)
|
|
102
102
|
const hasDetail = detail && Object.keys(detail).length > 0
|
|
103
103
|
return (
|
|
104
|
-
<div className="my-1 px-2.5 py-1.5 text-[
|
|
104
|
+
<div className="my-1 px-2.5 py-1.5 text-[11px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
105
105
|
<div className="flex items-center gap-2 cursor-pointer" onClick={() => hasDetail && setOpen(!open)}>
|
|
106
106
|
{hasDetail
|
|
107
107
|
? (open ? <ChevronDown size={10} style={{ color: '#a78bfa' }} /> : <ChevronRight size={10} style={{ color: '#a78bfa' }} />)
|
|
@@ -121,13 +121,13 @@ export function ToolResultBlock({ name, preview }) {
|
|
|
121
121
|
const isNoisy = preview.length > 120 || preview.startsWith('{') || preview.includes('contentId')
|
|
122
122
|
const short = isNoisy ? `${name} completed` : preview.substring(0, 120)
|
|
123
123
|
return (
|
|
124
|
-
<div className="my-1 px-2.5 py-1.5 text-[
|
|
124
|
+
<div className="my-1 px-2.5 py-1.5 text-[11px]" style={{ background: 'var(--c-code-bg)', border: '1px solid var(--c-border)' }}>
|
|
125
125
|
<div className="flex items-center gap-2 cursor-pointer" onClick={() => preview && setOpen(!open)}>
|
|
126
126
|
<CheckCircle size={10} style={{ color: '#34d399' }} />
|
|
127
127
|
<span className="truncate" style={{ color: 'var(--c-text)' }}>{short}</span>
|
|
128
128
|
{isNoisy && preview && <span style={{ color: 'var(--c-text3)' }}>{open ? '[-]' : '[+]'}</span>}
|
|
129
129
|
</div>
|
|
130
|
-
{open && <pre className="mt-1 text-[
|
|
130
|
+
{open && <pre className="mt-1 text-[10px] overflow-x-auto whitespace-pre-wrap break-all" style={{ color: 'var(--c-text2)' }}>{preview}</pre>}
|
|
131
131
|
</div>
|
|
132
132
|
)
|
|
133
133
|
}
|
|
@@ -9,15 +9,15 @@ export default function ModelBreakdown({ models }) {
|
|
|
9
9
|
return (
|
|
10
10
|
<div key={name} className="flex items-center gap-2">
|
|
11
11
|
<Cpu size={10} style={{ color: '#818cf8' }} />
|
|
12
|
-
<span className="text-[
|
|
12
|
+
<span className="text-[11px] truncate w-40" style={{ color: 'var(--c-text)' }}>{name}</span>
|
|
13
13
|
<div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
|
|
14
14
|
<div className="h-full" style={{ width: `${pct}%`, background: '#6366f1', opacity: 0.5 }} />
|
|
15
15
|
</div>
|
|
16
|
-
<span className="text-[
|
|
16
|
+
<span className="text-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
|
|
17
17
|
</div>
|
|
18
18
|
)
|
|
19
19
|
})}
|
|
20
|
-
{models.length === 0 && <div className="text-[
|
|
20
|
+
{models.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
|
|
21
21
|
</div>
|
|
22
22
|
)
|
|
23
23
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export default function SectionTitle({ children }) {
|
|
2
|
-
return <h3 className="text-[
|
|
2
|
+
return <h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>{children}</h3>
|
|
3
3
|
}
|
package/ui/src/index.css
CHANGED
package/ui/src/lib/api.js
CHANGED
|
@@ -127,6 +127,26 @@ export async function fetchDashboardStats(params = {}) {
|
|
|
127
127
|
return res.json();
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
export async function fetchCostAnalytics(params = {}) {
|
|
131
|
+
const q = new URLSearchParams();
|
|
132
|
+
if (params.editor) q.set('editor', params.editor);
|
|
133
|
+
appendDateParams(q, params);
|
|
134
|
+
const qs = q.toString();
|
|
135
|
+
const res = await fetch(`${BASE}/api/cost-analytics${qs ? '?' + qs : ''}`);
|
|
136
|
+
return res.json();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function fetchCosts(params = {}) {
|
|
140
|
+
const q = new URLSearchParams();
|
|
141
|
+
if (params.editor) q.set('editor', params.editor);
|
|
142
|
+
if (params.folder) q.set('folder', params.folder);
|
|
143
|
+
if (params.chatId) q.set('chatId', params.chatId);
|
|
144
|
+
appendDateParams(q, params);
|
|
145
|
+
const qs = q.toString();
|
|
146
|
+
const res = await fetch(`${BASE}/api/costs${qs ? '?' + qs : ''}`);
|
|
147
|
+
return res.json();
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
export async function executeQuery(sql) {
|
|
131
151
|
const res = await fetch(`${BASE}/api/query`, {
|
|
132
152
|
method: 'POST',
|