agentlytics 0.1.9 → 0.1.11
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 +59 -32
- package/cache.js +76 -14
- package/editors/base.js +0 -116
- package/editors/codex.js +0 -11
- package/editors/index.js +1 -9
- package/editors/opencode.js +1 -1
- package/editors/windsurf.js +3 -3
- package/editors/zed.js +3 -3
- package/index.js +3 -1
- package/package.json +1 -3
- package/pricing.json +805 -70
- package/relay-client.js +0 -4
- package/server.js +10 -1
- package/share-image.js +288 -75
- package/ui/src/App.jsx +1 -2
- package/ui/src/components/LiveFeed.jsx +0 -10
- package/ui/src/components/MessageRenderer.jsx +0 -19
- package/ui/src/components/ShareModal.jsx +273 -0
- package/ui/src/lib/api.js +11 -7
- package/ui/src/pages/Dashboard.jsx +7 -54
- package/ui/src/pages/DeepAnalysis.jsx +2 -2
- package/ui/src/pages/ProjectDetail.jsx +0 -1
- package/ui/src/pages/Projects.jsx +1 -1
- package/ui/src/pages/RelayDashboard.jsx +2 -2
- package/ui/src/pages/RelayUserDetail.jsx +1 -3
- package/ui/src/components/EditorBreakdown.jsx +0 -22
- package/ui/src/components/ModelBreakdown.jsx +0 -23
- package/ui/src/pages/ChatDetail.jsx +0 -107
- package/ui/src/pages/RelaySessionDetail.jsx +0 -32
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { X, Download, Share2, BarChart3, DollarSign, Clock, Cpu, Braces, User, Sun, Moon } from 'lucide-react'
|
|
3
|
+
import { fetchShareImage } from '../lib/api'
|
|
4
|
+
|
|
5
|
+
const TOGGLE_ITEMS = [
|
|
6
|
+
{ key: 'showEditors', label: 'Editors', icon: BarChart3 },
|
|
7
|
+
{ key: 'showCosts', label: 'Est. Costs', icon: DollarSign },
|
|
8
|
+
{ key: 'showHours', label: 'Peak Hours', icon: Clock },
|
|
9
|
+
{ key: 'showModels', label: 'Top Models', icon: Cpu },
|
|
10
|
+
{ key: 'showTokens', label: 'Token Footer', icon: Braces },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export default function ShareModal({ open, onClose }) {
|
|
14
|
+
const [opts, setOpts] = useState({
|
|
15
|
+
showEditors: true,
|
|
16
|
+
showCosts: true,
|
|
17
|
+
showHours: true,
|
|
18
|
+
showModels: true,
|
|
19
|
+
showTokens: true,
|
|
20
|
+
username: '',
|
|
21
|
+
theme: 'dark',
|
|
22
|
+
})
|
|
23
|
+
const [svg, setSvg] = useState('')
|
|
24
|
+
const [loading, setLoading] = useState(false)
|
|
25
|
+
const [downloading, setDownloading] = useState(false)
|
|
26
|
+
const debounceRef = useRef(null)
|
|
27
|
+
const backdropRef = useRef(null)
|
|
28
|
+
|
|
29
|
+
const loadPreview = useCallback(async (currentOpts) => {
|
|
30
|
+
setLoading(true)
|
|
31
|
+
try {
|
|
32
|
+
const result = await fetchShareImage(currentOpts)
|
|
33
|
+
if (result && !result.startsWith('{')) setSvg(result)
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error('Preview failed:', e)
|
|
36
|
+
}
|
|
37
|
+
setLoading(false)
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) return
|
|
42
|
+
loadPreview(opts)
|
|
43
|
+
}, [open])
|
|
44
|
+
|
|
45
|
+
const updateOpt = (key, value) => {
|
|
46
|
+
const next = { ...opts, [key]: value }
|
|
47
|
+
setOpts(next)
|
|
48
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
49
|
+
debounceRef.current = setTimeout(() => loadPreview(next), 300)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleDownloadPng = async () => {
|
|
53
|
+
if (!svg) return
|
|
54
|
+
setDownloading(true)
|
|
55
|
+
try {
|
|
56
|
+
const canvas = document.createElement('canvas')
|
|
57
|
+
canvas.width = 1200
|
|
58
|
+
canvas.height = 675
|
|
59
|
+
const ctx = canvas.getContext('2d')
|
|
60
|
+
const img = new Image()
|
|
61
|
+
const svgB64 = btoa(unescape(encodeURIComponent(svg)))
|
|
62
|
+
const dataUrl = `data:image/svg+xml;base64,${svgB64}`
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
img.onload = resolve
|
|
65
|
+
img.onerror = reject
|
|
66
|
+
img.src = dataUrl
|
|
67
|
+
})
|
|
68
|
+
ctx.drawImage(img, 0, 0, 1200, 675)
|
|
69
|
+
const pngUrl = canvas.toDataURL('image/png')
|
|
70
|
+
const a = document.createElement('a')
|
|
71
|
+
a.href = pngUrl
|
|
72
|
+
a.download = 'agentlytics.png'
|
|
73
|
+
a.click()
|
|
74
|
+
} catch {
|
|
75
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
76
|
+
const a = document.createElement('a')
|
|
77
|
+
a.href = URL.createObjectURL(blob)
|
|
78
|
+
a.download = 'agentlytics.svg'
|
|
79
|
+
a.click()
|
|
80
|
+
URL.revokeObjectURL(a.href)
|
|
81
|
+
}
|
|
82
|
+
setDownloading(false)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const handleDownloadSvg = () => {
|
|
86
|
+
if (!svg) return
|
|
87
|
+
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
88
|
+
const a = document.createElement('a')
|
|
89
|
+
a.href = URL.createObjectURL(blob)
|
|
90
|
+
a.download = 'agentlytics.svg'
|
|
91
|
+
a.click()
|
|
92
|
+
URL.revokeObjectURL(a.href)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const handleShareTwitter = async () => {
|
|
96
|
+
await handleDownloadPng()
|
|
97
|
+
const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
|
|
98
|
+
window.open(`https://x.com/intent/post?text=${text}`, '_blank')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!open) return null
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
ref={backdropRef}
|
|
106
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
107
|
+
style={{ background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(8px)' }}
|
|
108
|
+
onClick={(e) => e.target === backdropRef.current && onClose()}
|
|
109
|
+
>
|
|
110
|
+
<div
|
|
111
|
+
className="w-full relative flex flex-col"
|
|
112
|
+
style={{
|
|
113
|
+
maxWidth: 960,
|
|
114
|
+
maxHeight: '90vh',
|
|
115
|
+
background: 'var(--c-bg)',
|
|
116
|
+
border: '1px solid var(--c-border)',
|
|
117
|
+
borderRadius: 12,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{/* Header */}
|
|
121
|
+
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
122
|
+
<div className="flex items-center gap-2">
|
|
123
|
+
<Share2 size={14} style={{ color: 'var(--c-accent)' }} />
|
|
124
|
+
<span className="text-[13px] font-semibold" style={{ color: 'var(--c-white)' }}>Share Stats</span>
|
|
125
|
+
</div>
|
|
126
|
+
<button onClick={onClose} className="p-1 rounded hover:opacity-70 transition" style={{ color: 'var(--c-text2)' }}>
|
|
127
|
+
<X size={16} />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Body */}
|
|
132
|
+
<div className="flex-1 overflow-auto p-5" style={{ minHeight: 0 }}>
|
|
133
|
+
<div className="flex gap-5" style={{ flexDirection: 'row' }}>
|
|
134
|
+
|
|
135
|
+
{/* Sidebar: toggles */}
|
|
136
|
+
<div className="flex-shrink-0" style={{ width: 200 }}>
|
|
137
|
+
<div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
138
|
+
Customize
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="space-y-1.5">
|
|
142
|
+
{TOGGLE_ITEMS.map(({ key, label, icon: Icon }) => {
|
|
143
|
+
const active = opts[key]
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
key={key}
|
|
147
|
+
onClick={() => updateOpt(key, !active)}
|
|
148
|
+
className="flex items-center gap-2 w-full px-2.5 py-2 rounded-md text-[12px] transition"
|
|
149
|
+
style={{
|
|
150
|
+
background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
|
|
151
|
+
border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
|
|
152
|
+
color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
|
|
153
|
+
opacity: active ? 1 : 0.6,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<Icon size={12} />
|
|
157
|
+
{label}
|
|
158
|
+
</button>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="mt-4">
|
|
164
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
165
|
+
Theme
|
|
166
|
+
</div>
|
|
167
|
+
<div className="flex gap-1.5">
|
|
168
|
+
{[{ key: 'dark', icon: Moon, label: 'Dark' }, { key: 'light', icon: Sun, label: 'Light' }].map(({ key, icon: Icon, label }) => {
|
|
169
|
+
const active = opts.theme === key
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
key={key}
|
|
173
|
+
onClick={() => updateOpt('theme', key)}
|
|
174
|
+
className="flex items-center gap-1.5 flex-1 justify-center px-2 py-1.5 rounded-md text-[11px] transition"
|
|
175
|
+
style={{
|
|
176
|
+
background: active ? 'var(--c-accent-bg, rgba(99,102,241,0.1))' : 'transparent',
|
|
177
|
+
border: `1px solid ${active ? 'var(--c-accent, #6366f1)' : 'var(--c-border)'}`,
|
|
178
|
+
color: active ? 'var(--c-accent, #818cf8)' : 'var(--c-text2)',
|
|
179
|
+
opacity: active ? 1 : 0.6,
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<Icon size={11} />
|
|
183
|
+
{label}
|
|
184
|
+
</button>
|
|
185
|
+
)
|
|
186
|
+
})}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div className="mt-4">
|
|
191
|
+
<div className="text-[11px] font-medium mb-2" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
192
|
+
Username
|
|
193
|
+
</div>
|
|
194
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md" style={{ border: '1px solid var(--c-border)', background: 'var(--c-bg)' }}>
|
|
195
|
+
<User size={11} style={{ color: 'var(--c-text3)' }} />
|
|
196
|
+
<input
|
|
197
|
+
type="text"
|
|
198
|
+
placeholder="optional"
|
|
199
|
+
value={opts.username}
|
|
200
|
+
onChange={(e) => updateOpt('username', e.target.value)}
|
|
201
|
+
className="bg-transparent outline-none text-[12px] w-full"
|
|
202
|
+
style={{ color: 'var(--c-text)' }}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
{/* Preview */}
|
|
209
|
+
<div className="flex-1 min-w-0">
|
|
210
|
+
<div className="text-[11px] font-medium mb-3" style={{ color: 'var(--c-text2)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
211
|
+
Preview
|
|
212
|
+
</div>
|
|
213
|
+
<div
|
|
214
|
+
className="rounded-lg overflow-hidden relative"
|
|
215
|
+
style={{
|
|
216
|
+
border: '1px solid var(--c-border)',
|
|
217
|
+
background: '#09090f',
|
|
218
|
+
minHeight: 200,
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{loading && (
|
|
222
|
+
<div className="absolute inset-0 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.5)', zIndex: 2 }}>
|
|
223
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Generating...</span>
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
{svg && (
|
|
227
|
+
<div
|
|
228
|
+
className="w-full [&>svg]:w-full [&>svg]:h-auto [&>svg]:block"
|
|
229
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Footer: actions */}
|
|
238
|
+
<div className="flex items-center justify-between px-5 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
239
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
|
|
240
|
+
Tip: Toggle sections to customize your share card
|
|
241
|
+
</span>
|
|
242
|
+
<div className="flex items-center gap-2">
|
|
243
|
+
<button
|
|
244
|
+
onClick={handleDownloadSvg}
|
|
245
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
246
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)' }}
|
|
247
|
+
>
|
|
248
|
+
<Download size={12} />
|
|
249
|
+
SVG
|
|
250
|
+
</button>
|
|
251
|
+
<button
|
|
252
|
+
onClick={handleDownloadPng}
|
|
253
|
+
disabled={downloading}
|
|
254
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
255
|
+
style={{ border: '1px solid var(--c-border)', color: 'var(--c-text)', opacity: downloading ? 0.5 : 1 }}
|
|
256
|
+
>
|
|
257
|
+
<Download size={12} />
|
|
258
|
+
{downloading ? 'Converting...' : 'PNG'}
|
|
259
|
+
</button>
|
|
260
|
+
<button
|
|
261
|
+
onClick={handleShareTwitter}
|
|
262
|
+
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md transition hover:opacity-80"
|
|
263
|
+
style={{ background: '#6366f1', color: '#fff' }}
|
|
264
|
+
>
|
|
265
|
+
<Share2 size={12} />
|
|
266
|
+
Share on X
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
)
|
|
273
|
+
}
|
package/ui/src/lib/api.js
CHANGED
|
@@ -180,8 +180,17 @@ export async function fetchSchema() {
|
|
|
180
180
|
return res.json();
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
export async function fetchShareImage() {
|
|
184
|
-
const
|
|
183
|
+
export async function fetchShareImage(opts = {}) {
|
|
184
|
+
const q = new URLSearchParams();
|
|
185
|
+
if (opts.showEditors !== undefined) q.set('showEditors', opts.showEditors);
|
|
186
|
+
if (opts.showModels !== undefined) q.set('showModels', opts.showModels);
|
|
187
|
+
if (opts.showCosts !== undefined) q.set('showCosts', opts.showCosts);
|
|
188
|
+
if (opts.showTokens !== undefined) q.set('showTokens', opts.showTokens);
|
|
189
|
+
if (opts.showHours !== undefined) q.set('showHours', opts.showHours);
|
|
190
|
+
if (opts.username) q.set('username', opts.username);
|
|
191
|
+
if (opts.theme) q.set('theme', opts.theme);
|
|
192
|
+
const qs = q.toString();
|
|
193
|
+
const res = await fetch(`${BASE}/api/share-image${qs ? '?' + qs : ''}`);
|
|
185
194
|
return res.text();
|
|
186
195
|
}
|
|
187
196
|
|
|
@@ -210,11 +219,6 @@ export async function fetchRelayTeamStats() {
|
|
|
210
219
|
return res.json();
|
|
211
220
|
}
|
|
212
221
|
|
|
213
|
-
export async function fetchRelayUsers() {
|
|
214
|
-
const res = await authFetch(`${BASE}/relay/users`);
|
|
215
|
-
return res.json();
|
|
216
|
-
}
|
|
217
|
-
|
|
218
222
|
export async function fetchRelayUserActivity(username, opts = {}) {
|
|
219
223
|
const q = new URLSearchParams();
|
|
220
224
|
if (opts.folder) q.set('folder', opts.folder);
|
|
@@ -8,8 +8,9 @@ import ActivityHeatmap from '../components/ActivityHeatmap'
|
|
|
8
8
|
import DateRangePicker from '../components/DateRangePicker'
|
|
9
9
|
import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
|
|
10
10
|
import EditorIcon from '../components/EditorIcon'
|
|
11
|
-
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats,
|
|
11
|
+
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchChats, fetchCosts } from '../lib/api'
|
|
12
12
|
import ChatSidebar from '../components/ChatSidebar'
|
|
13
|
+
import ShareModal from '../components/ShareModal'
|
|
13
14
|
import { useTheme } from '../lib/theme'
|
|
14
15
|
import SectionTitle from '../components/SectionTitle'
|
|
15
16
|
|
|
@@ -32,7 +33,7 @@ export default function Dashboard({ overview }) {
|
|
|
32
33
|
const [dateRange, setDateRange] = useState(null)
|
|
33
34
|
const { dark } = useTheme()
|
|
34
35
|
const [costs, setCosts] = useState(null)
|
|
35
|
-
const [
|
|
36
|
+
const [shareOpen, setShareOpen] = useState(false)
|
|
36
37
|
const [largeContextChats, setLargeContextChats] = useState(null)
|
|
37
38
|
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
38
39
|
const txtColor = dark ? '#888' : '#555'
|
|
@@ -169,54 +170,6 @@ export default function Dashboard({ overview }) {
|
|
|
169
170
|
return s + v * midpoints[i]
|
|
170
171
|
}, 0) / tk.sessions).toFixed(1) : '—') : '—'
|
|
171
172
|
|
|
172
|
-
const handleShare = async () => {
|
|
173
|
-
setSharing(true)
|
|
174
|
-
try {
|
|
175
|
-
const svg = await fetchShareImage()
|
|
176
|
-
if (!svg || svg.startsWith('{')) throw new Error('Failed to fetch image')
|
|
177
|
-
|
|
178
|
-
// Try PNG conversion via canvas, fallback to SVG download
|
|
179
|
-
let downloaded = false
|
|
180
|
-
try {
|
|
181
|
-
const canvas = document.createElement('canvas')
|
|
182
|
-
canvas.width = 1600
|
|
183
|
-
canvas.height = 880
|
|
184
|
-
const ctx = canvas.getContext('2d')
|
|
185
|
-
const img = new Image()
|
|
186
|
-
const svgB64 = btoa(unescape(encodeURIComponent(svg)))
|
|
187
|
-
const dataUrl = `data:image/svg+xml;base64,${svgB64}`
|
|
188
|
-
await new Promise((resolve, reject) => {
|
|
189
|
-
img.onload = resolve
|
|
190
|
-
img.onerror = reject
|
|
191
|
-
img.src = dataUrl
|
|
192
|
-
})
|
|
193
|
-
ctx.drawImage(img, 0, 0, 1600, 880)
|
|
194
|
-
const pngUrl = canvas.toDataURL('image/png')
|
|
195
|
-
const a = document.createElement('a')
|
|
196
|
-
a.href = pngUrl
|
|
197
|
-
a.download = 'agentlytics.png'
|
|
198
|
-
a.click()
|
|
199
|
-
downloaded = true
|
|
200
|
-
} catch {
|
|
201
|
-
// Fallback: download SVG directly
|
|
202
|
-
const blob = new Blob([svg], { type: 'image/svg+xml' })
|
|
203
|
-
const a = document.createElement('a')
|
|
204
|
-
a.href = URL.createObjectURL(blob)
|
|
205
|
-
a.download = 'agentlytics.svg'
|
|
206
|
-
a.click()
|
|
207
|
-
URL.revokeObjectURL(a.href)
|
|
208
|
-
downloaded = true
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (downloaded) {
|
|
212
|
-
const text = encodeURIComponent("Here's my agentic coding stats using github.com/f/agentlytics")
|
|
213
|
-
window.open(`https://x.com/intent/post?text=${text}`, '_blank')
|
|
214
|
-
}
|
|
215
|
-
} catch (e) {
|
|
216
|
-
console.error('Share failed:', e)
|
|
217
|
-
}
|
|
218
|
-
setSharing(false)
|
|
219
|
-
}
|
|
220
173
|
|
|
221
174
|
return (
|
|
222
175
|
<div className="fade-in space-y-3">
|
|
@@ -224,13 +177,12 @@ export default function Dashboard({ overview }) {
|
|
|
224
177
|
<div className="flex items-center justify-end gap-3">
|
|
225
178
|
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
|
226
179
|
<button
|
|
227
|
-
onClick={
|
|
228
|
-
disabled={sharing}
|
|
180
|
+
onClick={() => setShareOpen(true)}
|
|
229
181
|
className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
|
|
230
|
-
style={{ background: '#6366f1', color: '#fff'
|
|
182
|
+
style={{ background: '#6366f1', color: '#fff' }}
|
|
231
183
|
>
|
|
232
184
|
<Share2 size={12} />
|
|
233
|
-
|
|
185
|
+
Share Stats
|
|
234
186
|
</button>
|
|
235
187
|
</div>
|
|
236
188
|
|
|
@@ -561,6 +513,7 @@ export default function Dashboard({ overview }) {
|
|
|
561
513
|
)}
|
|
562
514
|
</div>
|
|
563
515
|
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
516
|
+
<ShareModal open={shareOpen} onClose={() => setShareOpen(false)} />
|
|
564
517
|
</div>
|
|
565
518
|
)
|
|
566
519
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
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
|
-
import { Loader2, X,
|
|
4
|
+
import { Loader2, X, Zap, MessageSquare, Wrench, Cpu, TrendingUp } from 'lucide-react'
|
|
5
5
|
import { fetchDeepAnalytics, fetchToolCalls, fetchCosts } from '../lib/api'
|
|
6
|
-
import { editorLabel, editorColor, formatNumber, formatCost,
|
|
6
|
+
import { editorLabel, editorColor, formatNumber, formatCost, 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'
|
|
@@ -80,7 +80,6 @@ export default function ProjectDetail() {
|
|
|
80
80
|
if (!project) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text3)' }}>project not found</div>
|
|
81
81
|
|
|
82
82
|
const editorEntries = Object.entries(project.editors).sort((a, b) => b[1] - a[1])
|
|
83
|
-
const maxEditorCount = editorEntries.length > 0 ? editorEntries[0][1] : 1
|
|
84
83
|
const allEnabled = !enabledEditors || enabledEditors.size === editorEntries.length
|
|
85
84
|
|
|
86
85
|
// Derive stats from editor-filtered chats
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect } 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
|
-
import { Search, MessageSquare, Wrench, Cpu, FolderOpen, Calendar
|
|
4
|
+
import { Search, MessageSquare, Wrench, Cpu, FolderOpen, Calendar } from 'lucide-react'
|
|
5
5
|
import { useNavigate } from 'react-router-dom'
|
|
6
6
|
import { fetchProjects, fetchCosts } from '../lib/api'
|
|
7
7
|
import { editorColor, editorLabel, formatNumber, formatCost, formatDate, dateRangeToApiParams } from '../lib/constants'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import { useNavigate } from 'react-router-dom'
|
|
3
|
-
import { Users,
|
|
3
|
+
import { Users, Search, Merge, MessageSquare, FolderOpen, ChevronDown, ChevronRight } from 'lucide-react'
|
|
4
4
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
5
5
|
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
6
6
|
import KpiCard from '../components/KpiCard'
|
|
@@ -36,7 +36,7 @@ function ProportionBar({ segments, height = 6 }) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Left sidebar: team members grouped by project (folder-tree view)
|
|
39
|
-
function TeamSidebar({ userList, userColorMap,
|
|
39
|
+
function TeamSidebar({ userList, userColorMap, selectedUser }) {
|
|
40
40
|
const [collapsed, setCollapsed] = useState(new Set())
|
|
41
41
|
const navigate = useNavigate()
|
|
42
42
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
2
2
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { ArrowLeft, MessageSquare, FolderOpen, ChevronDown, ChevronRight,
|
|
3
|
+
import { ArrowLeft, MessageSquare, FolderOpen, ChevronDown, ChevronRight, Hash } from 'lucide-react'
|
|
4
4
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
5
5
|
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
6
6
|
import KpiCard from '../components/KpiCard'
|
|
@@ -37,7 +37,6 @@ function ProportionBar({ segments, height = 6 }) {
|
|
|
37
37
|
// Left sidebar: sessions grouped by project (folder-tree)
|
|
38
38
|
function SessionSidebar({ sessions, projects, selectedChat, onSelectChat }) {
|
|
39
39
|
const [collapsed, setCollapsed] = useState(new Set())
|
|
40
|
-
const [filter, setFilter] = useState(null) // null = all, or project path
|
|
41
40
|
|
|
42
41
|
const toggle = (key) => {
|
|
43
42
|
setCollapsed(prev => {
|
|
@@ -142,7 +141,6 @@ export default function RelayUserDetail() {
|
|
|
142
141
|
const legendColor = dark ? '#888' : '#555'
|
|
143
142
|
const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
|
|
144
143
|
const txtDim = dark ? '#555' : '#999'
|
|
145
|
-
const txtColor = dark ? '#888' : '#555'
|
|
146
144
|
|
|
147
145
|
useEffect(() => {
|
|
148
146
|
if (username) {
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import EditorDot from './EditorDot'
|
|
2
|
-
import { editorColor, editorLabel } from '../lib/constants'
|
|
3
|
-
|
|
4
|
-
export default function EditorBreakdown({ editors, total }) {
|
|
5
|
-
return (
|
|
6
|
-
<div className="space-y-1.5">
|
|
7
|
-
{editors.map(([src, count]) => {
|
|
8
|
-
const pct = total > 0 ? (count / total * 100) : 0
|
|
9
|
-
return (
|
|
10
|
-
<div key={src} className="flex items-center gap-2">
|
|
11
|
-
<EditorDot source={src} size={8} />
|
|
12
|
-
<span className="text-[11px] w-24" style={{ color: 'var(--c-text)' }}>{editorLabel(src)}</span>
|
|
13
|
-
<div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
|
|
14
|
-
<div className="h-full" style={{ width: `${pct}%`, background: editorColor(src), opacity: 0.7 }} />
|
|
15
|
-
</div>
|
|
16
|
-
<span className="text-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
|
|
17
|
-
</div>
|
|
18
|
-
)
|
|
19
|
-
})}
|
|
20
|
-
</div>
|
|
21
|
-
)
|
|
22
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { Cpu } from 'lucide-react'
|
|
2
|
-
|
|
3
|
-
export default function ModelBreakdown({ models }) {
|
|
4
|
-
const max = models[0]?.[1] || 1
|
|
5
|
-
return (
|
|
6
|
-
<div className="space-y-1.5">
|
|
7
|
-
{models.map(([name, count]) => {
|
|
8
|
-
const pct = (count / max * 100)
|
|
9
|
-
return (
|
|
10
|
-
<div key={name} className="flex items-center gap-2">
|
|
11
|
-
<Cpu size={10} style={{ color: '#818cf8' }} />
|
|
12
|
-
<span className="text-[11px] truncate w-40" style={{ color: 'var(--c-text)' }}>{name}</span>
|
|
13
|
-
<div className="flex-1 h-2 relative" style={{ background: 'var(--c-card)' }}>
|
|
14
|
-
<div className="h-full" style={{ width: `${pct}%`, background: '#6366f1', opacity: 0.5 }} />
|
|
15
|
-
</div>
|
|
16
|
-
<span className="text-[11px] w-10 text-right" style={{ color: 'var(--c-text2)' }}>{count}</span>
|
|
17
|
-
</div>
|
|
18
|
-
)
|
|
19
|
-
})}
|
|
20
|
-
{models.length === 0 && <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>No model data</div>}
|
|
21
|
-
</div>
|
|
22
|
-
)
|
|
23
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
2
|
-
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { ArrowLeft, Download } from 'lucide-react'
|
|
4
|
-
import { fetchChat, fetchCosts, BASE } from '../lib/api'
|
|
5
|
-
import { editorColor, editorLabel, formatDateTime, formatNumber, formatCost } from '../lib/constants'
|
|
6
|
-
import KpiCard from '../components/KpiCard'
|
|
7
|
-
import { MessageBubble } from '../components/MessageRenderer'
|
|
8
|
-
|
|
9
|
-
export default function ChatDetail() {
|
|
10
|
-
const { id } = useParams()
|
|
11
|
-
const navigate = useNavigate()
|
|
12
|
-
const [chat, setChat] = useState(null)
|
|
13
|
-
const [costs, setCosts] = useState(null)
|
|
14
|
-
const [loading, setLoading] = useState(true)
|
|
15
|
-
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
setLoading(true)
|
|
18
|
-
fetchChat(id).then(data => {
|
|
19
|
-
setChat(data)
|
|
20
|
-
fetchCosts({ chatId: data.id }).then(setCosts)
|
|
21
|
-
setLoading(false)
|
|
22
|
-
})
|
|
23
|
-
}, [id])
|
|
24
|
-
|
|
25
|
-
if (loading) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>Loading conversation...</div>
|
|
26
|
-
if (!chat) return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>Chat not found.</div>
|
|
27
|
-
|
|
28
|
-
const s = chat.stats
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<div className="fade-in max-w-4xl mx-auto">
|
|
32
|
-
<button
|
|
33
|
-
onClick={() => navigate(-1)}
|
|
34
|
-
className="flex items-center gap-2 text-sm mb-4 transition"
|
|
35
|
-
style={{ color: 'var(--c-text2)' }}
|
|
36
|
-
>
|
|
37
|
-
<ArrowLeft size={16} /> Back
|
|
38
|
-
</button>
|
|
39
|
-
|
|
40
|
-
{/* Header */}
|
|
41
|
-
<div className="card p-5 mb-4">
|
|
42
|
-
<div className="flex items-start justify-between">
|
|
43
|
-
<div className="flex-1 min-w-0">
|
|
44
|
-
<h2 className="text-xl font-semibold mb-1" style={{ color: 'var(--c-white)' }}>{chat.name || '(untitled)'}</h2>
|
|
45
|
-
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--c-text2)' }}>
|
|
46
|
-
<span className="inline-flex items-center gap-1.5">
|
|
47
|
-
<span className="w-2 h-2 rounded-full" style={{ background: editorColor(chat.source) }} />
|
|
48
|
-
{editorLabel(chat.source)}
|
|
49
|
-
</span>
|
|
50
|
-
{chat.mode && <span>· {chat.mode}</span>}
|
|
51
|
-
{chat.folder && <span className="font-mono">· {chat.folder}</span>}
|
|
52
|
-
</div>
|
|
53
|
-
<div className="text-xs mt-1" style={{ color: 'var(--c-text3)' }}>
|
|
54
|
-
{formatDateTime(chat.createdAt)}
|
|
55
|
-
{chat.lastUpdatedAt && chat.lastUpdatedAt !== chat.createdAt && ` — ${formatDateTime(chat.lastUpdatedAt)}`}
|
|
56
|
-
<span className="ml-3 font-mono" style={{ color: 'var(--c-text3)' }}>{chat.id}</span>
|
|
57
|
-
</div>
|
|
58
|
-
</div>
|
|
59
|
-
<a
|
|
60
|
-
href={`${BASE}/api/chats/${chat.id}/markdown`}
|
|
61
|
-
download
|
|
62
|
-
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition shrink-0"
|
|
63
|
-
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
64
|
-
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg2)'}
|
|
65
|
-
onMouseLeave={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
66
|
-
>
|
|
67
|
-
<Download size={13} /> Export .md
|
|
68
|
-
</a>
|
|
69
|
-
</div>
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
{/* Stats */}
|
|
73
|
-
{s && (
|
|
74
|
-
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3 mb-4">
|
|
75
|
-
<KpiCard label="Messages" value={s.totalMessages} />
|
|
76
|
-
<KpiCard label="User" value={s.userMessages} />
|
|
77
|
-
<KpiCard label="Assistant" value={s.assistantMessages} />
|
|
78
|
-
<KpiCard label="Tool Calls" value={s.toolCalls.length} />
|
|
79
|
-
{s.totalInputTokens > 0 && <KpiCard label="Input Tokens" value={formatNumber(s.totalInputTokens)} />}
|
|
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)} />}
|
|
82
|
-
</div>
|
|
83
|
-
)}
|
|
84
|
-
|
|
85
|
-
{/* Model badges */}
|
|
86
|
-
{s && s.models.length > 0 && (
|
|
87
|
-
<div className="flex flex-wrap gap-2 mb-4">
|
|
88
|
-
{[...new Set(s.models)].map(m => (
|
|
89
|
-
<span key={m} className="text-xs bg-accent/10 text-accent-light px-2.5 py-1 rounded-full font-mono">{m}</span>
|
|
90
|
-
))}
|
|
91
|
-
</div>
|
|
92
|
-
)}
|
|
93
|
-
|
|
94
|
-
{/* Messages */}
|
|
95
|
-
<div className="space-y-3">
|
|
96
|
-
{chat.messages.length === 0 && (
|
|
97
|
-
<div className="text-center py-12 text-sm" style={{ color: 'var(--c-text2)' }}>
|
|
98
|
-
{chat.encrypted ? '🔒 This conversation is encrypted.' : 'No messages found.'}
|
|
99
|
-
</div>
|
|
100
|
-
)}
|
|
101
|
-
{chat.messages.map((msg, i) => (
|
|
102
|
-
<MessageBubble key={i} msg={msg} toolCallDetails={chat.toolCallDetails} />
|
|
103
|
-
))}
|
|
104
|
-
</div>
|
|
105
|
-
</div>
|
|
106
|
-
)
|
|
107
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { useCallback } from 'react'
|
|
2
|
-
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { fetchRelaySession } from '../lib/api'
|
|
4
|
-
import ChatSidebar from '../components/ChatSidebar'
|
|
5
|
-
|
|
6
|
-
export default function RelaySessionDetail() {
|
|
7
|
-
const { chatId } = useParams()
|
|
8
|
-
const [searchParams] = useSearchParams()
|
|
9
|
-
const username = searchParams.get('username')
|
|
10
|
-
const navigate = useNavigate()
|
|
11
|
-
|
|
12
|
-
const fetchFn = useCallback(
|
|
13
|
-
(id) => fetchRelaySession(id, username),
|
|
14
|
-
[username]
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
const extraHeader = username ? (
|
|
18
|
-
<span className="text-[11px] font-medium px-1.5 py-0.5 shrink-0" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
19
|
-
{username}
|
|
20
|
-
</span>
|
|
21
|
-
) : null
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<ChatSidebar
|
|
25
|
-
chatId={chatId}
|
|
26
|
-
onClose={() => navigate(-1)}
|
|
27
|
-
fetchFn={fetchFn}
|
|
28
|
-
username={username}
|
|
29
|
-
extraHeader={extraHeader}
|
|
30
|
-
/>
|
|
31
|
-
)
|
|
32
|
-
}
|