agentlytics 0.1.5 → 0.1.7
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 +440 -9
- package/editors/cursor.js +28 -6
- package/editors/vscode.js +6 -0
- package/package.json +1 -1
- package/server.js +84 -4
- package/ui/package-lock.json +60 -375
- package/ui/package.json +1 -1
- package/ui/src/App.jsx +14 -1
- package/ui/src/lib/api.js +39 -0
- package/ui/src/lib/constants.js +8 -0
- package/ui/src/pages/ChatDetail.jsx +5 -2
- package/ui/src/pages/CostAnalysis.jsx +356 -0
- package/ui/src/pages/Dashboard.jsx +29 -8
- package/ui/src/pages/DeepAnalysis.jsx +11 -4
- package/ui/src/pages/ProjectDetail.jsx +12 -4
- package/ui/src/pages/Projects.jsx +9 -3
- package/ui/src/pages/Sessions.jsx +5 -1
- package/ui/src/pages/Settings.jsx +142 -0
package/ui/package.json
CHANGED
package/ui/src/App.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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, Settings as SettingsIcon } from 'lucide-react'
|
|
4
4
|
import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
6
|
import AnimatedLogo from './components/AnimatedLogo'
|
|
@@ -12,7 +12,9 @@ import Compare from './pages/Compare'
|
|
|
12
12
|
import ChatDetail from './pages/ChatDetail'
|
|
13
13
|
import Projects from './pages/Projects'
|
|
14
14
|
import ProjectDetail from './pages/ProjectDetail'
|
|
15
|
+
import CostAnalysis from './pages/CostAnalysis'
|
|
15
16
|
import SqlViewer from './pages/SqlViewer'
|
|
17
|
+
import Settings from './pages/Settings'
|
|
16
18
|
import RelayDashboard from './pages/RelayDashboard'
|
|
17
19
|
import RelayUserDetail from './pages/RelayUserDetail'
|
|
18
20
|
|
|
@@ -86,6 +88,7 @@ export default function App() {
|
|
|
86
88
|
{ to: '/', icon: Activity, label: 'Dashboard' },
|
|
87
89
|
{ to: '/projects', icon: FolderOpen, label: 'Projects' },
|
|
88
90
|
{ to: '/sessions', icon: MessageSquare, label: 'Sessions' },
|
|
91
|
+
{ to: '/costs', icon: DollarSign, label: 'Costs' },
|
|
89
92
|
{ to: '/analysis', icon: BarChart3, label: 'Analysis' },
|
|
90
93
|
{ to: '/compare', icon: GitCompare, label: 'Compare' },
|
|
91
94
|
{ to: '/sql', icon: Database, label: 'SQL' },
|
|
@@ -166,6 +169,14 @@ export default function App() {
|
|
|
166
169
|
Connect
|
|
167
170
|
</button>
|
|
168
171
|
)}
|
|
172
|
+
<NavLink
|
|
173
|
+
to="/settings"
|
|
174
|
+
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
175
|
+
style={({ isActive }) => ({ color: isActive ? '#6366f1' : 'var(--c-text2)' })}
|
|
176
|
+
title="Settings"
|
|
177
|
+
>
|
|
178
|
+
<SettingsIcon size={13} />
|
|
179
|
+
</NavLink>
|
|
169
180
|
<button
|
|
170
181
|
onClick={toggle}
|
|
171
182
|
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
@@ -200,9 +211,11 @@ export default function App() {
|
|
|
200
211
|
<Route path="/projects/detail" element={<ProjectDetail />} />
|
|
201
212
|
<Route path="/sessions" element={<Sessions overview={overview} />} />
|
|
202
213
|
{/* ChatDetail is now a sidebar in Sessions */}
|
|
214
|
+
<Route path="/costs" element={<CostAnalysis overview={overview} />} />
|
|
203
215
|
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
204
216
|
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
205
217
|
<Route path="/sql" element={<SqlViewer />} />
|
|
218
|
+
<Route path="/settings" element={<Settings />} />
|
|
206
219
|
</Routes>
|
|
207
220
|
)}
|
|
208
221
|
</main>
|
package/ui/src/lib/api.js
CHANGED
|
@@ -127,6 +127,45 @@ 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
|
+
|
|
150
|
+
export async function fetchConfig() {
|
|
151
|
+
const res = await fetch(`${BASE}/api/config`);
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function updateConfig(data) {
|
|
156
|
+
const res = await fetch(`${BASE}/api/config`, {
|
|
157
|
+
method: 'PUT',
|
|
158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
159
|
+
body: JSON.stringify(data),
|
|
160
|
+
});
|
|
161
|
+
return res.json();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function fetchAllProjects() {
|
|
165
|
+
const res = await fetch(`${BASE}/api/all-projects`);
|
|
166
|
+
return res.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
130
169
|
export async function executeQuery(sql) {
|
|
131
170
|
const res = await fetch(`${BASE}/api/query`, {
|
|
132
171
|
method: 'POST',
|
package/ui/src/lib/constants.js
CHANGED
|
@@ -49,6 +49,14 @@ export function formatNumber(n) {
|
|
|
49
49
|
return n.toLocaleString();
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function formatCost(n) {
|
|
53
|
+
if (n == null || n === 0) return '$0';
|
|
54
|
+
if (n < 0.01) return '<$0.01';
|
|
55
|
+
if (n >= 1000) return '$' + (n / 1000).toFixed(1) + 'K';
|
|
56
|
+
if (n >= 100) return '$' + Math.round(n);
|
|
57
|
+
return '$' + n.toFixed(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
export function formatDate(ts) {
|
|
53
61
|
if (!ts) return '';
|
|
54
62
|
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
3
|
import { ArrowLeft, Download } from 'lucide-react'
|
|
4
|
-
import { fetchChat, BASE } from '../lib/api'
|
|
5
|
-
import { editorColor, editorLabel, formatDateTime, formatNumber } from '../lib/constants'
|
|
4
|
+
import { fetchChat, fetchCosts, BASE } from '../lib/api'
|
|
5
|
+
import { editorColor, editorLabel, formatDateTime, formatNumber, formatCost } from '../lib/constants'
|
|
6
6
|
import KpiCard from '../components/KpiCard'
|
|
7
7
|
import { MessageBubble } from '../components/MessageRenderer'
|
|
8
8
|
|
|
@@ -10,12 +10,14 @@ export default function ChatDetail() {
|
|
|
10
10
|
const { id } = useParams()
|
|
11
11
|
const navigate = useNavigate()
|
|
12
12
|
const [chat, setChat] = useState(null)
|
|
13
|
+
const [costs, setCosts] = useState(null)
|
|
13
14
|
const [loading, setLoading] = useState(true)
|
|
14
15
|
|
|
15
16
|
useEffect(() => {
|
|
16
17
|
setLoading(true)
|
|
17
18
|
fetchChat(id).then(data => {
|
|
18
19
|
setChat(data)
|
|
20
|
+
fetchCosts({ chatId: data.id }).then(setCosts)
|
|
19
21
|
setLoading(false)
|
|
20
22
|
})
|
|
21
23
|
}, [id])
|
|
@@ -76,6 +78,7 @@ export default function ChatDetail() {
|
|
|
76
78
|
<KpiCard label="Tool Calls" value={s.toolCalls.length} />
|
|
77
79
|
{s.totalInputTokens > 0 && <KpiCard label="Input Tokens" value={formatNumber(s.totalInputTokens)} />}
|
|
78
80
|
{s.totalOutputTokens > 0 && <KpiCard label="Output Tokens" value={formatNumber(s.totalOutputTokens)} />}
|
|
81
|
+
{costs && costs.totalCost > 0 && <KpiCard label="Est. Cost" value={formatCost(costs.totalCost)} />}
|
|
79
82
|
</div>
|
|
80
83
|
)}
|
|
81
84
|
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { DollarSign, TrendingUp, Cpu, FolderOpen, AlertTriangle } from 'lucide-react'
|
|
4
|
+
import { Chart as ChartJS, ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler } from 'chart.js'
|
|
5
|
+
import { Line, Doughnut, Bar } from 'react-chartjs-2'
|
|
6
|
+
import { fetchCostAnalytics, fetchOverview } from '../lib/api'
|
|
7
|
+
import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRangeToApiParams } from '../lib/constants'
|
|
8
|
+
import { useTheme } from '../lib/theme'
|
|
9
|
+
import KpiCard from '../components/KpiCard'
|
|
10
|
+
import EditorIcon from '../components/EditorIcon'
|
|
11
|
+
import SectionTitle from '../components/SectionTitle'
|
|
12
|
+
import DateRangePicker from '../components/DateRangePicker'
|
|
13
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
14
|
+
|
|
15
|
+
ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler)
|
|
16
|
+
|
|
17
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
18
|
+
const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399', '#2dd4bf', '#38bdf8', '#60a5fa', '#a3e635']
|
|
19
|
+
|
|
20
|
+
export default function CostAnalysis({ overview }) {
|
|
21
|
+
const { dark } = useTheme()
|
|
22
|
+
const navigate = useNavigate()
|
|
23
|
+
const txtDim = dark ? '#555' : '#999'
|
|
24
|
+
const legendColor = dark ? '#777' : '#555'
|
|
25
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
26
|
+
|
|
27
|
+
const [data, setData] = useState(null)
|
|
28
|
+
const [loading, setLoading] = useState(true)
|
|
29
|
+
const [editor, setEditor] = useState('')
|
|
30
|
+
const [apiDateRange, setApiDateRange] = useState(null)
|
|
31
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
32
|
+
|
|
33
|
+
const editors = overview?.editors || []
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setLoading(true)
|
|
37
|
+
fetchCostAnalytics({ editor, ...dateRangeToApiParams(apiDateRange) }).then(d => {
|
|
38
|
+
setData(d)
|
|
39
|
+
setLoading(false)
|
|
40
|
+
}).catch(() => setLoading(false))
|
|
41
|
+
}, [editor, apiDateRange])
|
|
42
|
+
|
|
43
|
+
if (!data) {
|
|
44
|
+
return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading cost data...</div>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { totalCost, byModel, byEditor, byProject, monthly, topSessions, summary, unknownModels } = data
|
|
48
|
+
|
|
49
|
+
// Charts
|
|
50
|
+
const modelChartData = byModel.length > 0 ? {
|
|
51
|
+
labels: byModel.slice(0, 10).map(m => m.model),
|
|
52
|
+
datasets: [{
|
|
53
|
+
data: byModel.slice(0, 10).map(m => Math.round(m.cost * 100) / 100),
|
|
54
|
+
backgroundColor: MODEL_COLORS,
|
|
55
|
+
borderWidth: 0,
|
|
56
|
+
}],
|
|
57
|
+
} : null
|
|
58
|
+
|
|
59
|
+
const editorChartData = byEditor.length > 0 ? {
|
|
60
|
+
labels: byEditor.map(e => editorLabel(e.editor)),
|
|
61
|
+
datasets: [{
|
|
62
|
+
data: byEditor.map(e => Math.round(e.cost * 100) / 100),
|
|
63
|
+
backgroundColor: byEditor.map(e => editorColor(e.editor)),
|
|
64
|
+
borderWidth: 0,
|
|
65
|
+
}],
|
|
66
|
+
} : null
|
|
67
|
+
|
|
68
|
+
const monthlyChartData = monthly.length > 1 ? {
|
|
69
|
+
labels: monthly.map(m => m.month),
|
|
70
|
+
datasets: [{
|
|
71
|
+
label: 'Cost ($)',
|
|
72
|
+
data: monthly.map(m => m.cost),
|
|
73
|
+
borderColor: '#6366f1',
|
|
74
|
+
backgroundColor: 'rgba(99,102,241,0.1)',
|
|
75
|
+
borderWidth: 2,
|
|
76
|
+
tension: 0.3,
|
|
77
|
+
pointRadius: 3,
|
|
78
|
+
pointHoverRadius: 5,
|
|
79
|
+
fill: true,
|
|
80
|
+
}],
|
|
81
|
+
} : null
|
|
82
|
+
|
|
83
|
+
const projectChartData = byProject.length > 0 ? {
|
|
84
|
+
labels: byProject.slice(0, 10).map(p => p.name),
|
|
85
|
+
datasets: [{
|
|
86
|
+
data: byProject.slice(0, 10).map(p => Math.round(p.cost * 100) / 100),
|
|
87
|
+
backgroundColor: '#818cf8',
|
|
88
|
+
borderRadius: 3,
|
|
89
|
+
}],
|
|
90
|
+
} : null
|
|
91
|
+
|
|
92
|
+
const doughnutOpts = {
|
|
93
|
+
responsive: true, maintainAspectRatio: false, cutout: '55%',
|
|
94
|
+
plugins: {
|
|
95
|
+
legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
96
|
+
tooltip: {
|
|
97
|
+
bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
|
|
98
|
+
callbacks: { label: ctx => ` ${ctx.label}: $${ctx.raw.toFixed(2)}` },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="fade-in space-y-4">
|
|
105
|
+
{/* Filters */}
|
|
106
|
+
<div className="flex items-center gap-3">
|
|
107
|
+
<div className="flex items-center gap-1.5 text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
|
|
108
|
+
<DollarSign size={14} style={{ color: '#6366f1' }} />
|
|
109
|
+
Cost Analysis
|
|
110
|
+
</div>
|
|
111
|
+
<select
|
|
112
|
+
value={editor}
|
|
113
|
+
onChange={e => setEditor(e.target.value)}
|
|
114
|
+
className="px-2 py-1 text-[12px] outline-none appearance-none cursor-pointer"
|
|
115
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
116
|
+
>
|
|
117
|
+
<option value="">All Editors</option>
|
|
118
|
+
{editors.map(e => (
|
|
119
|
+
<option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
|
|
120
|
+
))}
|
|
121
|
+
</select>
|
|
122
|
+
<div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Disclaimer */}
|
|
126
|
+
<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' }}>
|
|
127
|
+
<AlertTriangle size={12} className="mt-0.5 shrink-0" />
|
|
128
|
+
<span>These are <strong>estimates</strong> based on public API list prices. Actual costs may be lower if you use discounted plans, commitments, prompt caching, batching, or provider credits. Token counts for some editors are approximated from character counts (~4 chars/token).</span>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* KPIs */}
|
|
132
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))' }}>
|
|
133
|
+
<KpiCard label="total est. cost" value={formatCost(totalCost)} sub="all models" />
|
|
134
|
+
<KpiCard label="avg / session" value={formatCost(summary.avgPerSession)} sub={`${formatNumber(summary.totalSessions)} sessions`} />
|
|
135
|
+
<KpiCard label="avg / day" value={formatCost(summary.avgPerDay)} sub={`${summary.totalDays} days`} />
|
|
136
|
+
<KpiCard label="models" value={byModel.length} sub={unknownModels.length > 0 ? `${unknownModels.length} unknown` : 'all priced'} />
|
|
137
|
+
<KpiCard label="top model" value={byModel[0]?.model || '—'} sub={byModel[0] ? formatCost(byModel[0].cost) : ''} />
|
|
138
|
+
<KpiCard label="top editor" value={byEditor[0] ? editorLabel(byEditor[0].editor) : '—'} sub={byEditor[0] ? formatCost(byEditor[0].cost) : ''} />
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Charts row 1: Model + Editor doughnuts */}
|
|
142
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
143
|
+
<div className="card p-3 lg:col-span-2">
|
|
144
|
+
<SectionTitle>cost by model</SectionTitle>
|
|
145
|
+
{modelChartData ? (
|
|
146
|
+
<div style={{ height: 220 }}>
|
|
147
|
+
<Bar
|
|
148
|
+
data={{
|
|
149
|
+
labels: byModel.slice(0, 12).map(m => m.model),
|
|
150
|
+
datasets: [{
|
|
151
|
+
data: byModel.slice(0, 12).map(m => Math.round(m.cost * 100) / 100),
|
|
152
|
+
backgroundColor: MODEL_COLORS,
|
|
153
|
+
borderRadius: 3,
|
|
154
|
+
}],
|
|
155
|
+
}}
|
|
156
|
+
options={{
|
|
157
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
158
|
+
scales: {
|
|
159
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO }, callback: v => '$' + v } },
|
|
160
|
+
y: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
|
|
161
|
+
},
|
|
162
|
+
plugins: {
|
|
163
|
+
legend: { display: false },
|
|
164
|
+
tooltip: {
|
|
165
|
+
bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
|
|
166
|
+
callbacks: { label: ctx => ` $${ctx.raw.toFixed(2)}` },
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no cost data</div>}
|
|
173
|
+
</div>
|
|
174
|
+
<div className="card p-3">
|
|
175
|
+
<SectionTitle>cost by editor</SectionTitle>
|
|
176
|
+
{editorChartData ? (
|
|
177
|
+
<div style={{ height: 220 }}>
|
|
178
|
+
<Doughnut data={editorChartData} options={doughnutOpts} />
|
|
179
|
+
</div>
|
|
180
|
+
) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no cost data</div>}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Monthly trend */}
|
|
185
|
+
{monthlyChartData && (
|
|
186
|
+
<div className="card p-3">
|
|
187
|
+
<SectionTitle>monthly cost trend</SectionTitle>
|
|
188
|
+
<div style={{ height: 180 }}>
|
|
189
|
+
<Line
|
|
190
|
+
data={monthlyChartData}
|
|
191
|
+
options={{
|
|
192
|
+
responsive: true, maintainAspectRatio: false,
|
|
193
|
+
interaction: { mode: 'index', intersect: false },
|
|
194
|
+
scales: {
|
|
195
|
+
x: { grid: { display: false }, ticks: { color: txtDim, font: { size: 9, family: MONO }, maxRotation: 0, maxTicksLimit: 12 } },
|
|
196
|
+
y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO }, callback: v => '$' + v } },
|
|
197
|
+
},
|
|
198
|
+
plugins: {
|
|
199
|
+
legend: { display: false },
|
|
200
|
+
tooltip: {
|
|
201
|
+
bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
|
|
202
|
+
callbacks: { label: ctx => ` $${ctx.raw.toFixed(2)} (${monthly[ctx.dataIndex]?.sessions || 0} sessions)` },
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
}}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{/* Charts row 2: Projects */}
|
|
212
|
+
{projectChartData && (
|
|
213
|
+
<div className="card p-3">
|
|
214
|
+
<SectionTitle>cost by project (top 10)</SectionTitle>
|
|
215
|
+
<div style={{ height: 220 }}>
|
|
216
|
+
<Bar
|
|
217
|
+
data={projectChartData}
|
|
218
|
+
options={{
|
|
219
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
220
|
+
scales: {
|
|
221
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 9, family: MONO }, callback: v => '$' + v } },
|
|
222
|
+
y: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
|
|
223
|
+
},
|
|
224
|
+
plugins: {
|
|
225
|
+
legend: { display: false },
|
|
226
|
+
tooltip: {
|
|
227
|
+
bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 },
|
|
228
|
+
callbacks: { label: ctx => ` $${ctx.raw.toFixed(2)}` },
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{/* Model breakdown table */}
|
|
238
|
+
<div className="card overflow-hidden">
|
|
239
|
+
<div className="px-3 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
240
|
+
<SectionTitle>model breakdown</SectionTitle>
|
|
241
|
+
</div>
|
|
242
|
+
<table className="w-full text-[12px]">
|
|
243
|
+
<thead>
|
|
244
|
+
<tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
|
|
245
|
+
<th className="text-left py-2 px-3 font-medium">model</th>
|
|
246
|
+
<th className="text-right py-2 px-3 font-medium">input tokens</th>
|
|
247
|
+
<th className="text-right py-2 px-3 font-medium">output tokens</th>
|
|
248
|
+
<th className="text-right py-2 px-3 font-medium">cache read</th>
|
|
249
|
+
<th className="text-right py-2 px-3 font-medium">cache write</th>
|
|
250
|
+
<th className="text-right py-2 px-3 font-medium">est. cost</th>
|
|
251
|
+
<th className="text-right py-2 px-3 font-medium">% of total</th>
|
|
252
|
+
</tr>
|
|
253
|
+
</thead>
|
|
254
|
+
<tbody>
|
|
255
|
+
{byModel.map((m, i) => (
|
|
256
|
+
<tr key={m.model} style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
257
|
+
<td className="py-2 px-3 font-mono font-medium" style={{ color: 'var(--c-white)' }}>
|
|
258
|
+
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: MODEL_COLORS[i % MODEL_COLORS.length] }} />
|
|
259
|
+
{m.model}
|
|
260
|
+
</td>
|
|
261
|
+
<td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text2)' }}>{formatNumber(m.inputTokens)}</td>
|
|
262
|
+
<td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text2)' }}>{formatNumber(m.outputTokens)}</td>
|
|
263
|
+
<td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text3)' }}>{m.cacheRead > 0 ? formatNumber(m.cacheRead) : '—'}</td>
|
|
264
|
+
<td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text3)' }}>{m.cacheWrite > 0 ? formatNumber(m.cacheWrite) : '—'}</td>
|
|
265
|
+
<td className="py-2 px-3 text-right font-mono font-medium" style={{ color: 'var(--c-white)' }}>{formatCost(m.cost)}</td>
|
|
266
|
+
<td className="py-2 px-3 text-right font-mono" style={{ color: 'var(--c-text3)' }}>
|
|
267
|
+
{totalCost > 0 ? ((m.cost / totalCost) * 100).toFixed(1) + '%' : '—'}
|
|
268
|
+
</td>
|
|
269
|
+
</tr>
|
|
270
|
+
))}
|
|
271
|
+
</tbody>
|
|
272
|
+
</table>
|
|
273
|
+
{byModel.length === 0 && (
|
|
274
|
+
<div className="text-center py-8 text-sm" style={{ color: 'var(--c-text3)' }}>no model cost data</div>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Top expensive sessions */}
|
|
279
|
+
{topSessions.length > 0 && (
|
|
280
|
+
<div className="card overflow-hidden">
|
|
281
|
+
<div className="px-3 py-2" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
282
|
+
<SectionTitle>most expensive sessions</SectionTitle>
|
|
283
|
+
</div>
|
|
284
|
+
<table className="w-full text-[12px]">
|
|
285
|
+
<thead>
|
|
286
|
+
<tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
|
|
287
|
+
<th className="text-left py-2 px-3 font-medium">editor</th>
|
|
288
|
+
<th className="text-left py-2 px-3 font-medium">name</th>
|
|
289
|
+
<th className="text-left py-2 px-3 font-medium">project</th>
|
|
290
|
+
<th className="text-left py-2 px-3 font-medium">model</th>
|
|
291
|
+
<th className="text-right py-2 px-3 font-medium">msgs</th>
|
|
292
|
+
<th className="text-right py-2 px-3 font-medium">est. cost</th>
|
|
293
|
+
<th className="text-left py-2 px-3 font-medium">date</th>
|
|
294
|
+
</tr>
|
|
295
|
+
</thead>
|
|
296
|
+
<tbody>
|
|
297
|
+
{topSessions.slice(0, 30).map(s => (
|
|
298
|
+
<tr
|
|
299
|
+
key={s.id}
|
|
300
|
+
className="cursor-pointer transition"
|
|
301
|
+
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
302
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
303
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
304
|
+
onClick={() => setSelectedChatId(s.id)}
|
|
305
|
+
>
|
|
306
|
+
<td className="py-2 px-3">
|
|
307
|
+
<span className="inline-flex items-center gap-1.5">
|
|
308
|
+
<EditorIcon source={s.source} size={12} />
|
|
309
|
+
<span style={{ color: 'var(--c-text2)' }}>{editorLabel(s.source)}</span>
|
|
310
|
+
</span>
|
|
311
|
+
</td>
|
|
312
|
+
<td className="py-2 px-3 font-medium truncate max-w-[200px]" style={{ color: 'var(--c-white)' }}>
|
|
313
|
+
{s.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
|
|
314
|
+
</td>
|
|
315
|
+
<td className="py-2 px-3 truncate max-w-[140px]" style={{ color: 'var(--c-text2)' }} title={s.folder}>
|
|
316
|
+
{s.folder ? (
|
|
317
|
+
<span
|
|
318
|
+
className="cursor-pointer hover:underline"
|
|
319
|
+
style={{ color: 'var(--c-accent)' }}
|
|
320
|
+
onClick={e => { e.stopPropagation(); navigate(`/projects/detail?folder=${encodeURIComponent(s.folder)}`) }}
|
|
321
|
+
>{s.folder.split('/').pop()}</span>
|
|
322
|
+
) : ''}
|
|
323
|
+
</td>
|
|
324
|
+
<td className="py-2 px-3 font-mono truncate max-w-[140px]" style={{ color: 'var(--c-text2)' }} title={s.model}>{s.model}</td>
|
|
325
|
+
<td className="py-2 px-3 text-right" style={{ color: 'var(--c-text3)' }}>{s.messages}</td>
|
|
326
|
+
<td className="py-2 px-3 text-right font-mono font-medium" style={{ color: 'var(--c-white)' }}>{formatCost(s.cost)}</td>
|
|
327
|
+
<td className="py-2 px-3 whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{formatDate(s.lastUpdatedAt)}</td>
|
|
328
|
+
</tr>
|
|
329
|
+
))}
|
|
330
|
+
</tbody>
|
|
331
|
+
</table>
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{/* Unknown models */}
|
|
336
|
+
{unknownModels.length > 0 && (
|
|
337
|
+
<div className="card p-3">
|
|
338
|
+
<SectionTitle>
|
|
339
|
+
<AlertTriangle size={11} className="inline mr-1" style={{ color: '#f59e0b' }} />
|
|
340
|
+
unpriced models ({unknownModels.length})
|
|
341
|
+
</SectionTitle>
|
|
342
|
+
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
343
|
+
{unknownModels.map(m => (
|
|
344
|
+
<span key={m} className="text-[11px] px-2 py-0.5 font-mono" style={{ background: 'rgba(245,158,11,0.08)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.2)' }}>
|
|
345
|
+
{m}
|
|
346
|
+
</span>
|
|
347
|
+
))}
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
)}
|
|
351
|
+
|
|
352
|
+
{/* Chat sidebar */}
|
|
353
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
354
|
+
</div>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
@@ -6,9 +6,10 @@ import { Doughnut, Bar, Line } from 'react-chartjs-2'
|
|
|
6
6
|
import KpiCard from '../components/KpiCard'
|
|
7
7
|
import ActivityHeatmap from '../components/ActivityHeatmap'
|
|
8
8
|
import DateRangePicker from '../components/DateRangePicker'
|
|
9
|
-
import { editorColor, editorLabel, formatNumber, dateRangeToApiParams } from '../lib/constants'
|
|
9
|
+
import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
|
|
10
10
|
import EditorIcon from '../components/EditorIcon'
|
|
11
|
-
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats } from '../lib/api'
|
|
11
|
+
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats, fetchCosts } from '../lib/api'
|
|
12
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
12
13
|
import { useTheme } from '../lib/theme'
|
|
13
14
|
import SectionTitle from '../components/SectionTitle'
|
|
14
15
|
|
|
@@ -30,8 +31,10 @@ export default function Dashboard({ overview }) {
|
|
|
30
31
|
const [selectedEditor, setSelectedEditor] = useState(null)
|
|
31
32
|
const [dateRange, setDateRange] = useState(null)
|
|
32
33
|
const { dark } = useTheme()
|
|
34
|
+
const [costs, setCosts] = useState(null)
|
|
33
35
|
const [sharing, setSharing] = useState(false)
|
|
34
36
|
const [largeContextChats, setLargeContextChats] = useState(null)
|
|
37
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
35
38
|
const txtColor = dark ? '#888' : '#555'
|
|
36
39
|
const txtDim = dark ? '#555' : '#999'
|
|
37
40
|
const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
|
|
@@ -64,16 +67,19 @@ export default function Dashboard({ overview }) {
|
|
|
64
67
|
setFilteredData(null)
|
|
65
68
|
fetchDailyActivity(dateParams).then(setDailyData)
|
|
66
69
|
fetchDashboardStats(dateParams).then(setStats)
|
|
70
|
+
fetchCosts(dateParams).then(setCosts)
|
|
67
71
|
return
|
|
68
72
|
}
|
|
69
73
|
Promise.all([
|
|
70
74
|
fetchOverviewApi({ editor: selectedEditor, ...dateParams }),
|
|
71
75
|
fetchDailyActivity({ editor: selectedEditor, ...dateParams }),
|
|
72
76
|
fetchDashboardStats({ editor: selectedEditor, ...dateParams }),
|
|
73
|
-
|
|
77
|
+
fetchCosts({ editor: selectedEditor, ...dateParams }),
|
|
78
|
+
]).then(([ov, daily, st, c]) => {
|
|
74
79
|
setFilteredData(ov)
|
|
75
80
|
setDailyData(daily)
|
|
76
81
|
setStats(st)
|
|
82
|
+
setCosts(c)
|
|
77
83
|
})
|
|
78
84
|
}, [selectedEditor, dateRange])
|
|
79
85
|
|
|
@@ -282,6 +288,19 @@ export default function Dashboard({ overview }) {
|
|
|
282
288
|
</>}
|
|
283
289
|
</div>
|
|
284
290
|
|
|
291
|
+
{/* Token economy KPIs */}
|
|
292
|
+
{tk && tk.input > 0 && (
|
|
293
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
|
|
294
|
+
<KpiCard label="in tokens" value={formatNumber(tk.input)} sub="total prompt" />
|
|
295
|
+
<KpiCard label="out tokens" value={formatNumber(tk.output)} sub="total completion" />
|
|
296
|
+
<KpiCard label="cache read" value={formatNumber(tk.cacheRead)} sub={`${cacheHitRate}% hit rate`} />
|
|
297
|
+
<KpiCard label="cache write" value={formatNumber(tk.cacheWrite)} />
|
|
298
|
+
<KpiCard label="out/in ratio" value={`${outputInputRatio}×`} sub={<span className="flex items-center gap-0.5"><Zap size={8} /> efficiency</span>} />
|
|
299
|
+
<KpiCard label="you wrote" value={formatNumber(tk.userChars)} sub={`AI wrote ${formatNumber(tk.assistantChars)}`} />
|
|
300
|
+
<KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '—'} sub={costs && costs.byModel.length > 0 ? `${costs.byModel.length} model${costs.byModel.length !== 1 ? 's' : ''}` : undefined} />
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
|
|
285
304
|
{/* Activity Heatmap | Col 1 | Col 2 | Col 3 */}
|
|
286
305
|
<div className="card p-3">
|
|
287
306
|
<SectionTitle>agentic coding activity</SectionTitle>
|
|
@@ -467,18 +486,19 @@ export default function Dashboard({ overview }) {
|
|
|
467
486
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2">
|
|
468
487
|
{stats && stats.topModels.length > 0 && (
|
|
469
488
|
<div className="card p-3">
|
|
470
|
-
<SectionTitle>top models</SectionTitle>
|
|
489
|
+
<SectionTitle>top models {costs && costs.totalCost > 0 && <span style={{ color: 'var(--c-text3)' }}>({formatCost(costs.totalCost)} est.)</span>}</SectionTitle>
|
|
471
490
|
<div className="space-y-1">
|
|
472
491
|
{stats.topModels.map((m, i) => {
|
|
473
492
|
const maxM = stats.topModels[0].count
|
|
493
|
+
const modelCost = costs?.byModel?.find(c => c.model === m.name)
|
|
474
494
|
return (
|
|
475
495
|
<div key={m.name} className="flex items-center gap-2">
|
|
476
496
|
<span className="text-[10px] w-3 text-right" style={{ color: 'var(--c-text3)' }}>{i + 1}</span>
|
|
497
|
+
<span className="text-[9px] truncate w-28" style={{ color: 'var(--c-text2)' }} title={m.name}>{m.name}</span>
|
|
477
498
|
<div className="flex-1 h-4 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
|
|
478
|
-
<div className="h-full rounded-sm
|
|
479
|
-
<span className="text-[8px] truncate" style={{ color: i < 2 ? '#fff' : 'var(--c-text2)' }}>{m.name}</span>
|
|
480
|
-
</div>
|
|
499
|
+
<div className="h-full rounded-sm" style={{ width: `${(m.count / maxM * 100).toFixed(1)}%`, background: i === 0 ? '#6366f1' : i === 1 ? '#818cf8' : '#a5b4fc40' }} />
|
|
481
500
|
</div>
|
|
501
|
+
{modelCost ? <span className="text-[9px] w-12 text-right" style={{ color: '#10b981' }}>{formatCost(modelCost.cost)}</span> : null}
|
|
482
502
|
<span className="text-[10px] w-8 text-right" style={{ color: 'var(--c-text3)' }}>{m.count}</span>
|
|
483
503
|
</div>
|
|
484
504
|
)
|
|
@@ -524,7 +544,7 @@ export default function Dashboard({ overview }) {
|
|
|
524
544
|
key={c.id}
|
|
525
545
|
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer transition hover:opacity-80"
|
|
526
546
|
style={{ background: c.bubbleCount >= 500 ? 'rgba(239,68,68,0.06)' : 'rgba(245,158,11,0.06)' }}
|
|
527
|
-
onClick={() =>
|
|
547
|
+
onClick={() => setSelectedChatId(c.id)}
|
|
528
548
|
>
|
|
529
549
|
<EditorIcon source={c.source} size={10} />
|
|
530
550
|
<span className="text-[10px] truncate flex-1" style={{ color: 'var(--c-text)' }}>{c.name || 'Untitled'}</span>
|
|
@@ -540,6 +560,7 @@ export default function Dashboard({ overview }) {
|
|
|
540
560
|
</div>
|
|
541
561
|
)}
|
|
542
562
|
</div>
|
|
563
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
543
564
|
</div>
|
|
544
565
|
)
|
|
545
566
|
}
|
|
@@ -2,8 +2,8 @@ import { useState, useEffect, useRef, useMemo } from 'react'
|
|
|
2
2
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
3
3
|
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
4
4
|
import { Loader2, X, ArrowRight, Zap, MessageSquare, Wrench, Cpu, TrendingUp, BarChart3 } from 'lucide-react'
|
|
5
|
-
import { fetchDeepAnalytics, fetchToolCalls } from '../lib/api'
|
|
6
|
-
import { editorLabel, editorColor, formatNumber, formatDateTime, dateRangeToApiParams } from '../lib/constants'
|
|
5
|
+
import { fetchDeepAnalytics, fetchToolCalls, fetchCosts } from '../lib/api'
|
|
6
|
+
import { editorLabel, editorColor, formatNumber, formatCost, formatDateTime, dateRangeToApiParams } from '../lib/constants'
|
|
7
7
|
import { useTheme } from '../lib/theme'
|
|
8
8
|
import KpiCard from '../components/KpiCard'
|
|
9
9
|
import EditorIcon from '../components/EditorIcon'
|
|
@@ -195,6 +195,7 @@ export default function DeepAnalysis({ overview }) {
|
|
|
195
195
|
const [data, setData] = useState(null)
|
|
196
196
|
const [loading, setLoading] = useState(false)
|
|
197
197
|
const [selectedTool, setSelectedTool] = useState(null)
|
|
198
|
+
const [costs, setCosts] = useState(null)
|
|
198
199
|
const chartRef = useRef(null)
|
|
199
200
|
const { dark } = useTheme()
|
|
200
201
|
const txtColor = dark ? '#a0a0a0' : '#444'
|
|
@@ -207,8 +208,13 @@ export default function DeepAnalysis({ overview }) {
|
|
|
207
208
|
|
|
208
209
|
async function analyze() {
|
|
209
210
|
setLoading(true)
|
|
210
|
-
const
|
|
211
|
+
const dateParams = dateRangeToApiParams(dateRange)
|
|
212
|
+
const [result, costData] = await Promise.all([
|
|
213
|
+
fetchDeepAnalytics({ editor, folder: folder || undefined, limit: 500, ...dateParams }),
|
|
214
|
+
fetchCosts({ editor, folder: folder || undefined, ...dateParams }),
|
|
215
|
+
])
|
|
211
216
|
setData(result)
|
|
217
|
+
setCosts(costData)
|
|
212
218
|
setLoading(false)
|
|
213
219
|
}
|
|
214
220
|
|
|
@@ -279,7 +285,8 @@ export default function DeepAnalysis({ overview }) {
|
|
|
279
285
|
<KpiCard label="messages" value={formatNumber(data.totalMessages)} sub={`${insights.msgsPerSession}/session`} />
|
|
280
286
|
<KpiCard label="tool calls" value={formatNumber(data.totalToolCalls)} sub={`${insights.toolsPerSession}/session`} />
|
|
281
287
|
<KpiCard label="total tokens" value={formatNumber(insights.totalTok)} sub={`${formatNumber(insights.tokPerMsg)}/msg`} />
|
|
282
|
-
<KpiCard label="you wrote" value={formatNumber(data.totalUserChars)} sub={`AI: ${insights.aiVsHuman}
|
|
288
|
+
<KpiCard label="you wrote" value={formatNumber(data.totalUserChars)} sub={`AI: ${insights.aiVsHuman}\u00d7 more`} />
|
|
289
|
+
<KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} />
|
|
283
290
|
</div>
|
|
284
291
|
|
|
285
292
|
{/* Token flow + Insights row */}
|