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/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
|
|
package/ui/src/pages/Compare.jsx
CHANGED
|
@@ -16,15 +16,15 @@ function MetricRow({ label, a, b, colorA, colorB }) {
|
|
|
16
16
|
const max = Math.max(numA, numB, 1)
|
|
17
17
|
return (
|
|
18
18
|
<div className="grid grid-cols-[1fr_100px_100px] gap-x-3 items-center py-0.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
19
|
-
<div className="text-[
|
|
19
|
+
<div className="text-[11px]" style={{ color: 'var(--c-text2)' }}>{label}</div>
|
|
20
20
|
<div className="text-right">
|
|
21
|
-
<span className="text-[
|
|
21
|
+
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--c-white)' }}>
|
|
22
22
|
{typeof a === 'number' ? formatNumber(a) : a}
|
|
23
23
|
</span>
|
|
24
24
|
<div className="h-1 rounded-full mt-0.5 ml-auto" style={{ background: colorA, width: `${(numA / max * 100).toFixed(0)}%` }} />
|
|
25
25
|
</div>
|
|
26
26
|
<div className="text-right">
|
|
27
|
-
<span className="text-[
|
|
27
|
+
<span className="text-[12px] font-mono font-medium" style={{ color: 'var(--c-white)' }}>
|
|
28
28
|
{typeof b === 'number' ? formatNumber(b) : b}
|
|
29
29
|
</span>
|
|
30
30
|
<div className="h-1 rounded-full mt-0.5 ml-auto" style={{ background: colorB, width: `${(numB / max * 100).toFixed(0)}%` }} />
|
|
@@ -37,27 +37,27 @@ function ListCompare({ titleA, titleB, colorA, colorB, itemsA, itemsB, limit = 8
|
|
|
37
37
|
return (
|
|
38
38
|
<div className="grid grid-cols-2 gap-2">
|
|
39
39
|
<div>
|
|
40
|
-
<h4 className="text-[
|
|
40
|
+
<h4 className="text-[11px] font-medium uppercase tracking-wider mb-1" style={{ color: colorA }}>{titleA}</h4>
|
|
41
41
|
<div className="space-y-0.5">
|
|
42
42
|
{itemsA.slice(0, limit).map(t => (
|
|
43
|
-
<div key={t.name} className="flex justify-between text-[
|
|
43
|
+
<div key={t.name} className="flex justify-between text-[11px] py-0.5">
|
|
44
44
|
<span className="truncate" style={{ color: 'var(--c-text)' }}>{t.name}</span>
|
|
45
45
|
<span className="font-mono ml-2" style={{ color: 'var(--c-text3)' }}>{t.count}</span>
|
|
46
46
|
</div>
|
|
47
47
|
))}
|
|
48
|
-
{itemsA.length === 0 && <div className="text-[
|
|
48
|
+
{itemsA.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>none</div>}
|
|
49
49
|
</div>
|
|
50
50
|
</div>
|
|
51
51
|
<div>
|
|
52
|
-
<h4 className="text-[
|
|
52
|
+
<h4 className="text-[11px] font-medium uppercase tracking-wider mb-1" style={{ color: colorB }}>{titleB}</h4>
|
|
53
53
|
<div className="space-y-0.5">
|
|
54
54
|
{itemsB.slice(0, limit).map(t => (
|
|
55
|
-
<div key={t.name} className="flex justify-between text-[
|
|
55
|
+
<div key={t.name} className="flex justify-between text-[11px] py-0.5">
|
|
56
56
|
<span className="truncate" style={{ color: 'var(--c-text)' }}>{t.name}</span>
|
|
57
57
|
<span className="font-mono ml-2" style={{ color: 'var(--c-text3)' }}>{t.count}</span>
|
|
58
58
|
</div>
|
|
59
59
|
))}
|
|
60
|
-
{itemsB.length === 0 && <div className="text-[
|
|
60
|
+
{itemsB.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>none</div>}
|
|
61
61
|
</div>
|
|
62
62
|
</div>
|
|
63
63
|
</div>
|
|
@@ -156,7 +156,7 @@ export default function Compare({ overview }) {
|
|
|
156
156
|
<select
|
|
157
157
|
value={editorA}
|
|
158
158
|
onChange={e => setEditorA(e.target.value)}
|
|
159
|
-
className="px-2 py-1 text-[
|
|
159
|
+
className="px-2 py-1 text-[12px] outline-none"
|
|
160
160
|
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
161
161
|
>
|
|
162
162
|
{editors.map(e => <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>)}
|
|
@@ -165,7 +165,7 @@ export default function Compare({ overview }) {
|
|
|
165
165
|
<select
|
|
166
166
|
value={editorB}
|
|
167
167
|
onChange={e => setEditorB(e.target.value)}
|
|
168
|
-
className="px-2 py-1 text-[
|
|
168
|
+
className="px-2 py-1 text-[12px] outline-none"
|
|
169
169
|
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
170
170
|
>
|
|
171
171
|
{editors.map(e => <option key={e.id} value={e.id}>{editorLabel(e.id)}</option>)}
|
|
@@ -179,8 +179,8 @@ export default function Compare({ overview }) {
|
|
|
179
179
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
180
180
|
<div className="card p-3">
|
|
181
181
|
<div className="flex items-center justify-between mb-1.5">
|
|
182
|
-
<h3 className="text-[
|
|
183
|
-
<div className="flex items-center gap-3 text-[
|
|
182
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>totals</h3>
|
|
183
|
+
<div className="flex items-center gap-3 text-[10px]">
|
|
184
184
|
<span style={{ color: colorA }}>● {nameA}</span>
|
|
185
185
|
<span style={{ color: colorB }}>● {nameB}</span>
|
|
186
186
|
</div>
|
|
@@ -188,7 +188,7 @@ export default function Compare({ overview }) {
|
|
|
188
188
|
{metrics.map(m => <MetricRow key={m.label} label={m.label} a={m.a} b={m.b} colorA={colorA} colorB={colorB} />)}
|
|
189
189
|
</div>
|
|
190
190
|
<div className="card p-3">
|
|
191
|
-
<h3 className="text-[
|
|
191
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider mb-1.5" style={{ color: 'var(--c-text2)' }}>efficiency</h3>
|
|
192
192
|
{ratios.map(m => <MetricRow key={m.label} label={m.label} a={m.a} b={m.b} colorA={colorA} colorB={colorB} />)}
|
|
193
193
|
</div>
|
|
194
194
|
</div>
|
|
@@ -196,14 +196,14 @@ export default function Compare({ overview }) {
|
|
|
196
196
|
{/* Charts row */}
|
|
197
197
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
198
198
|
<div className="card p-3">
|
|
199
|
-
<h3 className="text-[
|
|
199
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>usage</h3>
|
|
200
200
|
<div style={{ height: 160 }}>
|
|
201
201
|
<Bar data={barChart} options={barOpts} />
|
|
202
202
|
</div>
|
|
203
203
|
</div>
|
|
204
204
|
{tokenChart && (
|
|
205
205
|
<div className="card p-3">
|
|
206
|
-
<h3 className="text-[
|
|
206
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>tokens</h3>
|
|
207
207
|
<div style={{ height: 160 }}>
|
|
208
208
|
<Bar data={tokenChart} options={barOpts} />
|
|
209
209
|
</div>
|
|
@@ -214,12 +214,12 @@ export default function Compare({ overview }) {
|
|
|
214
214
|
{/* Tools + Models side-by-side */}
|
|
215
215
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
216
216
|
<div className="card p-3">
|
|
217
|
-
<h3 className="text-[
|
|
217
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>top tools</h3>
|
|
218
218
|
<ListCompare titleA={nameA} titleB={nameB} colorA={colorA} colorB={colorB}
|
|
219
219
|
itemsA={result.deepA.topTools} itemsB={result.deepB.topTools} limit={10} />
|
|
220
220
|
</div>
|
|
221
221
|
<div className="card p-3">
|
|
222
|
-
<h3 className="text-[
|
|
222
|
+
<h3 className="text-[11px] font-medium uppercase tracking-wider mb-2" style={{ color: 'var(--c-text2)' }}>models</h3>
|
|
223
223
|
<ListCompare titleA={nameA} titleB={nameB} colorA={colorA} colorB={colorB}
|
|
224
224
|
itemsA={result.deepA.topModels} itemsB={result.deepB.topModels} limit={8} />
|
|
225
225
|
</div>
|
|
@@ -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
|
+
}
|