memhouse 0.0.1 → 0.3.1
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/LICENSE +22 -0
- package/README.md +72 -3
- package/bin/memhouse.js +559 -0
- package/cache.js +850 -0
- package/ch-ingest.js +329 -0
- package/ch-schema.js +152 -0
- package/clickhouse.js +87 -0
- package/editors/adapter-errors.js +52 -0
- package/editors/antigravity.js +1048 -0
- package/editors/base.js +321 -0
- package/editors/claude.js +386 -0
- package/editors/codebuff.js +338 -0
- package/editors/codex.js +537 -0
- package/editors/commandcode.js +166 -0
- package/editors/copilot-jetbrains.js +207 -0
- package/editors/copilot.js +260 -0
- package/editors/cursor-agent.js +202 -0
- package/editors/cursor.js +498 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +386 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +274 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +432 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +229 -0
- package/index.js +424 -2
- package/mcp-server.js +294 -0
- package/mem-house/DESIGN.md +119 -0
- package/mem-house/delivery/AGENT-INSTALL.md +87 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +83 -0
- package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
- package/mem-house/rls.sql +17 -0
- package/mem-house/schema.sql +145 -0
- package/mem-house/server/queries.js +878 -0
- package/mem-house/server/server.js +228 -0
- package/mem-house/shipper/ship.js +461 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +59 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-CCPh7a2Z.js +75 -0
- package/public/assets/index-DV6ONi_F.css +2 -0
- package/public/index.html +16 -0
- package/relay-client.js +306 -0
- package/relay-server.js +577 -0
- package/server.js +860 -0
- package/share-image.js +367 -0
- package/ui/eslint.config.js +29 -0
- package/ui/index.html +15 -0
- package/ui/package-lock.json +4229 -0
- package/ui/package.json +38 -0
- package/ui/src/App.jsx +364 -0
- package/ui/src/components/ActivityHeatmap.jsx +206 -0
- package/ui/src/components/AiAuditCard.jsx +254 -0
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +226 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/components/EditorDot.jsx +11 -0
- package/ui/src/components/EditorIcon.jsx +117 -0
- package/ui/src/components/KpiCard.jsx +9 -0
- package/ui/src/components/LiveFeed.jsx +128 -0
- package/ui/src/components/LoginScreen.jsx +81 -0
- package/ui/src/components/MessageRenderer.jsx +148 -0
- package/ui/src/components/PageHeader.jsx +11 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/index.css +83 -0
- package/ui/src/lib/api.js +343 -0
- package/ui/src/lib/constants.js +88 -0
- package/ui/src/lib/theme.jsx +23 -0
- package/ui/src/main.jsx +16 -0
- package/ui/src/pages/Artifacts.jsx +575 -0
- package/ui/src/pages/Compare.jsx +232 -0
- package/ui/src/pages/CostAnalysis.jsx +354 -0
- package/ui/src/pages/Dashboard.jsx +529 -0
- package/ui/src/pages/DeepAnalysis.jsx +495 -0
- package/ui/src/pages/GSD.jsx +726 -0
- package/ui/src/pages/MCPs.jsx +744 -0
- package/ui/src/pages/ProjectDetail.jsx +325 -0
- package/ui/src/pages/Projects.jsx +249 -0
- package/ui/src/pages/RelayDashboard.jsx +590 -0
- package/ui/src/pages/RelayUserDetail.jsx +466 -0
- package/ui/src/pages/Sessions.jsx +535 -0
- package/ui/src/pages/Settings.jsx +241 -0
- package/ui/src/pages/SqlViewer.jsx +370 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- package/ui/vite.config.js +18 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
|
2
|
+
import { useSearchParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { Search, Filter, List, FolderOpen, ChevronDown, ChevronRight, X, 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 { fetchChats } 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
|
+
import PageHeader from '../components/PageHeader'
|
|
15
|
+
|
|
16
|
+
ChartJS.register(ArcElement, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Tooltip, Legend, Filler)
|
|
17
|
+
|
|
18
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
19
|
+
|
|
20
|
+
// Custom Chart.js plugin: drag-to-select range on x-axis
|
|
21
|
+
const dragSelectPlugin = {
|
|
22
|
+
id: 'dragSelect',
|
|
23
|
+
beforeEvent(chart, args) {
|
|
24
|
+
const evt = args.event
|
|
25
|
+
const state = chart._dragSelect || (chart._dragSelect = { dragging: false, startX: 0, endX: 0 })
|
|
26
|
+
const area = chart.chartArea
|
|
27
|
+
if (!area) return
|
|
28
|
+
|
|
29
|
+
if (evt.type === 'mousedown' && evt.x >= area.left && evt.x <= area.right && evt.y >= area.top && evt.y <= area.bottom) {
|
|
30
|
+
state.dragging = true
|
|
31
|
+
state.startX = evt.x
|
|
32
|
+
state.endX = evt.x
|
|
33
|
+
}
|
|
34
|
+
if (evt.type === 'mousemove' && state.dragging) {
|
|
35
|
+
state.endX = Math.max(area.left, Math.min(evt.x, area.right))
|
|
36
|
+
args.changed = false
|
|
37
|
+
chart.draw()
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
if (evt.type === 'mouseup' && state.dragging) {
|
|
41
|
+
state.dragging = false
|
|
42
|
+
state.endX = Math.max(area.left, Math.min(evt.x, area.right))
|
|
43
|
+
const x1 = Math.min(state.startX, state.endX)
|
|
44
|
+
const x2 = Math.max(state.startX, state.endX)
|
|
45
|
+
if (x2 - x1 > 5) {
|
|
46
|
+
const scale = chart.scales.x
|
|
47
|
+
const startIdx = scale.getValueForPixel(x1)
|
|
48
|
+
const endIdx = scale.getValueForPixel(x2)
|
|
49
|
+
const labels = chart.data.labels
|
|
50
|
+
const startLabel = labels[Math.max(0, Math.round(startIdx))]
|
|
51
|
+
const endLabel = labels[Math.min(labels.length - 1, Math.round(endIdx))]
|
|
52
|
+
if (chart.config.options._onRangeSelect) {
|
|
53
|
+
chart.config.options._onRangeSelect(startLabel, endLabel)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
chart.draw()
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
afterDraw(chart) {
|
|
60
|
+
const state = chart._dragSelect
|
|
61
|
+
if (!state) return
|
|
62
|
+
const area = chart.chartArea
|
|
63
|
+
const ctx = chart.ctx
|
|
64
|
+
// Draw persisted selection highlight
|
|
65
|
+
const opts = chart.config.options
|
|
66
|
+
if (opts._selectionRange && chart.data.labels) {
|
|
67
|
+
const scale = chart.scales.x
|
|
68
|
+
const labels = chart.data.labels
|
|
69
|
+
const si = labels.indexOf(opts._selectionRange[0])
|
|
70
|
+
const ei = labels.indexOf(opts._selectionRange[1])
|
|
71
|
+
if (si >= 0 && ei >= 0) {
|
|
72
|
+
const px1 = scale.getPixelForValue(si)
|
|
73
|
+
const px2 = scale.getPixelForValue(ei)
|
|
74
|
+
ctx.save()
|
|
75
|
+
ctx.fillStyle = 'rgba(99,102,241,0.10)'
|
|
76
|
+
ctx.fillRect(Math.min(px1, px2), area.top, Math.abs(px2 - px1), area.bottom - area.top)
|
|
77
|
+
ctx.strokeStyle = 'rgba(99,102,241,0.4)'
|
|
78
|
+
ctx.lineWidth = 1
|
|
79
|
+
ctx.setLineDash([3, 3])
|
|
80
|
+
ctx.strokeRect(Math.min(px1, px2), area.top, Math.abs(px2 - px1), area.bottom - area.top)
|
|
81
|
+
ctx.restore()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Draw active drag overlay
|
|
85
|
+
if (state.dragging) {
|
|
86
|
+
const x1 = Math.min(state.startX, state.endX)
|
|
87
|
+
const x2 = Math.max(state.startX, state.endX)
|
|
88
|
+
ctx.save()
|
|
89
|
+
ctx.fillStyle = 'rgba(99,102,241,0.15)'
|
|
90
|
+
ctx.fillRect(x1, area.top, x2 - x1, area.bottom - area.top)
|
|
91
|
+
ctx.restore()
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
ChartJS.register(dragSelectPlugin)
|
|
96
|
+
|
|
97
|
+
// Custom tooltip positioner: below cursor
|
|
98
|
+
Tooltip.positioners.belowCursor = function(elements, eventPosition) {
|
|
99
|
+
return { x: eventPosition.x, y: eventPosition.y + 20 }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default function Sessions({ overview }) {
|
|
103
|
+
const { dark } = useTheme()
|
|
104
|
+
const txtDim = dark ? '#555' : '#999'
|
|
105
|
+
const legendColor = dark ? '#777' : '#555'
|
|
106
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
107
|
+
const [chats, setChats] = useState([])
|
|
108
|
+
const [total, setTotal] = useState(0)
|
|
109
|
+
const [search, setSearch] = useState('')
|
|
110
|
+
const [searchParams] = useSearchParams()
|
|
111
|
+
const navigate = useNavigate()
|
|
112
|
+
const [editor, setEditor] = useState(searchParams.get('editor') || '')
|
|
113
|
+
const [loading, setLoading] = useState(true)
|
|
114
|
+
const [groupByProject, setGroupByProject] = useState(false)
|
|
115
|
+
const [collapsedProjects, setCollapsedProjects] = useState(new Set())
|
|
116
|
+
const [dateRange, setDateRange] = useState(null) // [startWeek, endWeek] — chart drag-select
|
|
117
|
+
const [apiDateRange, setApiDateRange] = useState(null) // { from, to } — server-side date filter
|
|
118
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
119
|
+
const chartRef = useRef(null)
|
|
120
|
+
|
|
121
|
+
const onRangeSelect = useCallback((start, end) => {
|
|
122
|
+
if (start && end) setDateRange([start, end].sort())
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
setLoading(true)
|
|
127
|
+
fetchChats({ editor, limit: 1000, ...dateRangeToApiParams(apiDateRange) }).then(data => {
|
|
128
|
+
setChats(data.chats)
|
|
129
|
+
setTotal(data.total)
|
|
130
|
+
setLoading(false)
|
|
131
|
+
})
|
|
132
|
+
}, [editor, apiDateRange])
|
|
133
|
+
|
|
134
|
+
const searchFiltered = search
|
|
135
|
+
? chats.filter(c =>
|
|
136
|
+
(c.name || '').toLowerCase().includes(search.toLowerCase()) ||
|
|
137
|
+
(c.folder || '').toLowerCase().includes(search.toLowerCase()) ||
|
|
138
|
+
c.id.toLowerCase().includes(search.toLowerCase())
|
|
139
|
+
)
|
|
140
|
+
: chats
|
|
141
|
+
|
|
142
|
+
// Apply date range filter
|
|
143
|
+
const filtered = useMemo(() => {
|
|
144
|
+
if (!dateRange) return searchFiltered
|
|
145
|
+
const [start, end] = dateRange
|
|
146
|
+
// Add 6 days to end week to cover full week
|
|
147
|
+
const startMs = new Date(start).getTime()
|
|
148
|
+
const endMs = new Date(end).getTime() + 6 * 86400000
|
|
149
|
+
return searchFiltered.filter(c => {
|
|
150
|
+
const ts = c.lastUpdatedAt || c.createdAt
|
|
151
|
+
return ts && ts >= startMs && ts <= endMs
|
|
152
|
+
})
|
|
153
|
+
}, [searchFiltered, dateRange])
|
|
154
|
+
|
|
155
|
+
const editors = overview?.editors || []
|
|
156
|
+
const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
|
|
157
|
+
|
|
158
|
+
// Summary stats from filtered chats
|
|
159
|
+
const stats = useMemo(() => {
|
|
160
|
+
const editorCounts = {}
|
|
161
|
+
const modelCounts = {}
|
|
162
|
+
const modeCounts = {}
|
|
163
|
+
let bloatCount = 0
|
|
164
|
+
let largeCount = 0
|
|
165
|
+
const projectSet = new Set()
|
|
166
|
+
for (const c of filtered) {
|
|
167
|
+
if (c.source) editorCounts[c.source] = (editorCounts[c.source] || 0) + 1
|
|
168
|
+
if (c.topModel) modelCounts[c.topModel] = (modelCounts[c.topModel] || 0) + 1
|
|
169
|
+
if (c.mode) modeCounts[c.mode] = (modeCounts[c.mode] || 0) + 1
|
|
170
|
+
if (c.folder) projectSet.add(c.folder)
|
|
171
|
+
if (c.bubbleCount >= 500) bloatCount++
|
|
172
|
+
else if (c.bubbleCount >= 200) largeCount++
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
editorEntries: Object.entries(editorCounts).sort((a, b) => b[1] - a[1]),
|
|
176
|
+
modelEntries: Object.entries(modelCounts).sort((a, b) => b[1] - a[1]).slice(0, 10),
|
|
177
|
+
modeEntries: Object.entries(modeCounts).sort((a, b) => b[1] - a[1]),
|
|
178
|
+
bloatCount,
|
|
179
|
+
largeCount,
|
|
180
|
+
projectCount: projectSet.size,
|
|
181
|
+
}
|
|
182
|
+
}, [filtered])
|
|
183
|
+
|
|
184
|
+
// Timeline chart data: sessions per week by editor (always use full list, not date-filtered)
|
|
185
|
+
const timelineChart = useMemo(() => {
|
|
186
|
+
if (searchFiltered.length === 0) return null
|
|
187
|
+
const weekMap = {}
|
|
188
|
+
for (const c of searchFiltered) {
|
|
189
|
+
const ts = c.lastUpdatedAt || c.createdAt
|
|
190
|
+
if (!ts) continue
|
|
191
|
+
const d = new Date(ts)
|
|
192
|
+
const day = d.getDay()
|
|
193
|
+
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
|
|
194
|
+
const monday = new Date(d)
|
|
195
|
+
monday.setDate(diff)
|
|
196
|
+
const wk = `${monday.getFullYear()}-${String(monday.getMonth() + 1).padStart(2, '0')}-${String(monday.getDate()).padStart(2, '0')}`
|
|
197
|
+
if (!weekMap[wk]) weekMap[wk] = {}
|
|
198
|
+
const src = c.source || 'unknown'
|
|
199
|
+
weekMap[wk][src] = (weekMap[wk][src] || 0) + 1
|
|
200
|
+
}
|
|
201
|
+
const weeks = Object.keys(weekMap).sort()
|
|
202
|
+
const editorIds = [...new Set(searchFiltered.map(c => c.source).filter(Boolean))]
|
|
203
|
+
return {
|
|
204
|
+
labels: weeks,
|
|
205
|
+
datasets: editorIds.map(eid => ({
|
|
206
|
+
label: editorLabel(eid),
|
|
207
|
+
data: weeks.map(w => weekMap[w][eid] || 0),
|
|
208
|
+
borderColor: editorColor(eid),
|
|
209
|
+
backgroundColor: editorColor(eid) + '15',
|
|
210
|
+
borderWidth: 1.5,
|
|
211
|
+
tension: 0.3,
|
|
212
|
+
pointRadius: 0,
|
|
213
|
+
pointHoverRadius: 4,
|
|
214
|
+
fill: true,
|
|
215
|
+
})),
|
|
216
|
+
}
|
|
217
|
+
}, [searchFiltered])
|
|
218
|
+
|
|
219
|
+
// Group by project
|
|
220
|
+
const grouped = useMemo(() => {
|
|
221
|
+
if (!groupByProject) return null
|
|
222
|
+
const map = {}
|
|
223
|
+
for (const c of filtered) {
|
|
224
|
+
const key = c.folder || '(no project)'
|
|
225
|
+
if (!map[key]) map[key] = []
|
|
226
|
+
map[key].push(c)
|
|
227
|
+
}
|
|
228
|
+
return Object.entries(map)
|
|
229
|
+
.map(([folder, items]) => ({ folder, name: folder === '(no project)' ? '(no project)' : folder.split('/').pop(), items }))
|
|
230
|
+
.sort((a, b) => b.items.length - a.items.length)
|
|
231
|
+
}, [filtered, groupByProject])
|
|
232
|
+
|
|
233
|
+
function toggleProject(folder) {
|
|
234
|
+
setCollapsedProjects(prev => {
|
|
235
|
+
const next = new Set(prev)
|
|
236
|
+
if (next.has(folder)) next.delete(folder)
|
|
237
|
+
else next.add(folder)
|
|
238
|
+
return next
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const SessionRow = ({ c }) => (
|
|
243
|
+
<tr
|
|
244
|
+
key={c.id}
|
|
245
|
+
className="cursor-pointer transition"
|
|
246
|
+
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
247
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
248
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
249
|
+
onClick={() => setSelectedChatId(c.id)}
|
|
250
|
+
>
|
|
251
|
+
<td className="py-2 px-3">
|
|
252
|
+
<span className="inline-flex items-center gap-1.5">
|
|
253
|
+
<EditorIcon source={c.source} size={12} />
|
|
254
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>{editorLabel(c.source)}</span>
|
|
255
|
+
</span>
|
|
256
|
+
</td>
|
|
257
|
+
<td className="py-2 px-3 font-medium truncate max-w-[280px] text-[12px]" style={{ color: 'var(--c-white)' }}>
|
|
258
|
+
{c.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
|
|
259
|
+
{c.encrypted && <span className="ml-1.5 text-[10px] text-yellow-500/60">locked</span>}
|
|
260
|
+
</td>
|
|
261
|
+
{!groupByProject && (
|
|
262
|
+
<td className="py-2 px-3 truncate max-w-[160px] text-[12px]" style={{ color: 'var(--c-text2)' }} title={c.folder}>
|
|
263
|
+
{c.folder ? (
|
|
264
|
+
<span
|
|
265
|
+
className="cursor-pointer hover:underline"
|
|
266
|
+
style={{ color: 'var(--c-accent)' }}
|
|
267
|
+
onClick={e => { e.stopPropagation(); navigate(`/projects/detail?folder=${encodeURIComponent(c.folder)}`) }}
|
|
268
|
+
>{c.folder.split('/').pop()}</span>
|
|
269
|
+
) : ''}
|
|
270
|
+
</td>
|
|
271
|
+
)}
|
|
272
|
+
<td className="py-2 px-3 text-[12px]" style={{ color: 'var(--c-text2)' }}>{c.mode || ''}</td>
|
|
273
|
+
<td className="py-2 px-3 text-[12px] font-mono truncate max-w-[150px]" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>
|
|
274
|
+
{c.topModel || ''}
|
|
275
|
+
</td>
|
|
276
|
+
<td className="py-2 px-3 text-[12px]">
|
|
277
|
+
{c.bubbleCount >= 500 ? (
|
|
278
|
+
<span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#ef4444' }}>
|
|
279
|
+
<AlertTriangle size={9} />{c.bubbleCount}
|
|
280
|
+
</span>
|
|
281
|
+
) : c.bubbleCount >= 200 ? (
|
|
282
|
+
<span className="inline-flex items-center gap-0.5 font-bold" style={{ color: '#f59e0b' }}>
|
|
283
|
+
<AlertTriangle size={9} />{c.bubbleCount}
|
|
284
|
+
</span>
|
|
285
|
+
) : (
|
|
286
|
+
<span style={{ color: 'var(--c-text3)' }}>{c.bubbleCount || 0}</span>
|
|
287
|
+
)}
|
|
288
|
+
</td>
|
|
289
|
+
<td className="py-2 px-3 text-[12px] font-mono text-right" style={{ color: c.cost > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
|
|
290
|
+
{c.cost > 0 ? formatCost(c.cost) : ''}
|
|
291
|
+
</td>
|
|
292
|
+
<td className="py-2 px-3 text-[12px] whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>
|
|
293
|
+
{formatDate(c.lastUpdatedAt || c.createdAt)}
|
|
294
|
+
</td>
|
|
295
|
+
</tr>
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<div className="fade-in space-y-3">
|
|
300
|
+
<PageHeader icon={List} title="Sessions" />
|
|
301
|
+
|
|
302
|
+
{/* KPIs */}
|
|
303
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
|
|
304
|
+
<KpiCard label="sessions" value={formatNumber(filtered.length)} sub={filtered.length !== total ? `of ${formatNumber(total)}` : ''} />
|
|
305
|
+
<KpiCard label="projects" value={stats.projectCount} />
|
|
306
|
+
<KpiCard label="editors" value={stats.editorEntries.length} />
|
|
307
|
+
<KpiCard label="models" value={stats.modelEntries.length} />
|
|
308
|
+
{(stats.bloatCount + stats.largeCount) > 0 && (
|
|
309
|
+
<KpiCard label="large context" value={stats.bloatCount + stats.largeCount} sub={stats.bloatCount > 0 ? `${stats.bloatCount} bloated` : ''} />
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{/* Summary charts */}
|
|
314
|
+
{filtered.length > 0 && (
|
|
315
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
316
|
+
<div className="card p-3">
|
|
317
|
+
<SectionTitle>editors</SectionTitle>
|
|
318
|
+
<div style={{ height: 140 }}>
|
|
319
|
+
<Doughnut
|
|
320
|
+
data={{
|
|
321
|
+
labels: stats.editorEntries.map(e => editorLabel(e[0])),
|
|
322
|
+
datasets: [{ data: stats.editorEntries.map(e => e[1]), backgroundColor: stats.editorEntries.map(e => editorColor(e[0])), borderWidth: 0 }],
|
|
323
|
+
}}
|
|
324
|
+
options={{
|
|
325
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
326
|
+
plugins: {
|
|
327
|
+
legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
328
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
329
|
+
},
|
|
330
|
+
}}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
<div className="card p-3">
|
|
335
|
+
<SectionTitle>models</SectionTitle>
|
|
336
|
+
<div style={{ height: 140 }}>
|
|
337
|
+
{stats.modelEntries.length > 0 ? (
|
|
338
|
+
<Doughnut
|
|
339
|
+
data={{
|
|
340
|
+
labels: stats.modelEntries.map(m => m[0]),
|
|
341
|
+
datasets: [{ data: stats.modelEntries.map(m => m[1]), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
|
|
342
|
+
}}
|
|
343
|
+
options={{
|
|
344
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
345
|
+
plugins: {
|
|
346
|
+
legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
347
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
348
|
+
},
|
|
349
|
+
}}
|
|
350
|
+
/>
|
|
351
|
+
) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div className="card p-3">
|
|
355
|
+
<SectionTitle>modes</SectionTitle>
|
|
356
|
+
<div style={{ height: 140 }}>
|
|
357
|
+
{stats.modeEntries.length > 0 ? (
|
|
358
|
+
<Bar
|
|
359
|
+
data={{
|
|
360
|
+
labels: stats.modeEntries.map(m => m[0] || 'unknown'),
|
|
361
|
+
datasets: [{
|
|
362
|
+
data: stats.modeEntries.map(m => m[1]),
|
|
363
|
+
backgroundColor: '#6366f1',
|
|
364
|
+
borderRadius: 3,
|
|
365
|
+
}],
|
|
366
|
+
}}
|
|
367
|
+
options={{
|
|
368
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
369
|
+
scales: {
|
|
370
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } } },
|
|
371
|
+
y: { grid: { display: false }, ticks: { color: legendColor, font: { size: 9, family: MONO } } },
|
|
372
|
+
},
|
|
373
|
+
plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
|
|
374
|
+
}}
|
|
375
|
+
/>
|
|
376
|
+
) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no mode data</div>}
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
|
|
382
|
+
{/* Timeline chart */}
|
|
383
|
+
{timelineChart && timelineChart.labels.length > 1 && (
|
|
384
|
+
<div className="card p-3">
|
|
385
|
+
<div className="flex items-center justify-between mb-2">
|
|
386
|
+
<SectionTitle>
|
|
387
|
+
session timeline
|
|
388
|
+
<span className="ml-2 font-normal text-[10px]" style={{ color: 'var(--c-text3)' }}>(drag to select range)</span>
|
|
389
|
+
</SectionTitle>
|
|
390
|
+
{dateRange && (
|
|
391
|
+
<button
|
|
392
|
+
onClick={() => setDateRange(null)}
|
|
393
|
+
className="flex items-center gap-1 text-[11px] px-2 py-0.5 transition"
|
|
394
|
+
style={{ color: 'var(--c-accent)', border: '1px solid var(--c-border)' }}
|
|
395
|
+
>
|
|
396
|
+
<X size={10} />
|
|
397
|
+
{dateRange[0]} — {dateRange[1]}
|
|
398
|
+
</button>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
<div style={{ height: 160 }}>
|
|
402
|
+
<Line
|
|
403
|
+
ref={chartRef}
|
|
404
|
+
data={timelineChart}
|
|
405
|
+
options={{
|
|
406
|
+
responsive: true,
|
|
407
|
+
maintainAspectRatio: false,
|
|
408
|
+
interaction: { mode: 'index', intersect: false },
|
|
409
|
+
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'mousedown', 'mouseup'],
|
|
410
|
+
_onRangeSelect: onRangeSelect,
|
|
411
|
+
_selectionRange: dateRange,
|
|
412
|
+
scales: {
|
|
413
|
+
x: { grid: { display: false }, ticks: { color: txtDim, font: { size: 9, family: MONO }, maxTicksLimit: 20, maxRotation: 0 } },
|
|
414
|
+
y: { beginAtZero: true, grid: { color: gridColor }, ticks: { color: txtDim, stepSize: 1, font: { size: 9, family: MONO } } },
|
|
415
|
+
},
|
|
416
|
+
plugins: {
|
|
417
|
+
legend: { position: 'top', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 10 } },
|
|
418
|
+
tooltip: { position: 'belowCursor', bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
419
|
+
},
|
|
420
|
+
}}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
|
|
426
|
+
{/* Filters */}
|
|
427
|
+
<div className="flex items-center gap-2">
|
|
428
|
+
<select
|
|
429
|
+
value={editor}
|
|
430
|
+
onChange={e => setEditor(e.target.value)}
|
|
431
|
+
className="px-2 py-1 text-[12px] outline-none"
|
|
432
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
433
|
+
>
|
|
434
|
+
<option value="">All Editors</option>
|
|
435
|
+
{editors.map(e => (
|
|
436
|
+
<option key={e.id} value={e.id}>{editorLabel(e.id)} ({e.count})</option>
|
|
437
|
+
))}
|
|
438
|
+
</select>
|
|
439
|
+
<div className="relative flex-1 max-w-sm">
|
|
440
|
+
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
441
|
+
<input
|
|
442
|
+
type="text"
|
|
443
|
+
placeholder="Search sessions..."
|
|
444
|
+
value={search}
|
|
445
|
+
onChange={e => setSearch(e.target.value)}
|
|
446
|
+
className="w-full pl-7 pr-3 py-1 text-[12px] outline-none"
|
|
447
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
448
|
+
/>
|
|
449
|
+
</div>
|
|
450
|
+
<button
|
|
451
|
+
onClick={() => setGroupByProject(!groupByProject)}
|
|
452
|
+
className="flex items-center gap-1.5 px-3 py-1 text-[12px] transition"
|
|
453
|
+
style={{
|
|
454
|
+
border: groupByProject ? '1px solid var(--c-accent)' : '1px solid var(--c-border)',
|
|
455
|
+
color: groupByProject ? 'var(--c-accent)' : 'var(--c-text2)',
|
|
456
|
+
background: groupByProject ? 'rgba(99,102,241,0.1)' : 'transparent',
|
|
457
|
+
}}
|
|
458
|
+
>
|
|
459
|
+
{groupByProject ? <FolderOpen size={13} /> : <List size={13} />}
|
|
460
|
+
{groupByProject ? 'grouped' : 'flat'}
|
|
461
|
+
</button>
|
|
462
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
|
|
463
|
+
{loading ? 'loading...' : `${filtered.length} of ${total}`}
|
|
464
|
+
</span>
|
|
465
|
+
{/* Server-side date range filter */}
|
|
466
|
+
<div className="ml-auto"><DateRangePicker value={apiDateRange} onChange={setApiDateRange} /></div>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
{/* Session table */}
|
|
470
|
+
{!groupByProject ? (
|
|
471
|
+
<div className="card overflow-hidden">
|
|
472
|
+
<table className="w-full text-sm">
|
|
473
|
+
<thead>
|
|
474
|
+
<tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
|
|
475
|
+
<th className="text-left py-2 px-3 font-medium">editor</th>
|
|
476
|
+
<th className="text-left py-2 px-3 font-medium">name</th>
|
|
477
|
+
<th className="text-left py-2 px-3 font-medium">project</th>
|
|
478
|
+
<th className="text-left py-2 px-3 font-medium">mode</th>
|
|
479
|
+
<th className="text-left py-2 px-3 font-medium">model</th>
|
|
480
|
+
<th className="text-left py-2 px-3 font-medium">context</th>
|
|
481
|
+
<th className="text-right py-2 px-3 font-medium">est. cost</th>
|
|
482
|
+
<th className="text-left py-2 px-3 font-medium">updated</th>
|
|
483
|
+
</tr>
|
|
484
|
+
</thead>
|
|
485
|
+
<tbody>
|
|
486
|
+
{filtered.map(c => <SessionRow key={c.id} c={c} />)}
|
|
487
|
+
</tbody>
|
|
488
|
+
</table>
|
|
489
|
+
{filtered.length === 0 && !loading && (
|
|
490
|
+
<div className="text-center py-12 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
) : (
|
|
494
|
+
<div className="space-y-2">
|
|
495
|
+
{grouped.map(g => {
|
|
496
|
+
const isCollapsed = collapsedProjects.has(g.folder)
|
|
497
|
+
const editorSet = [...new Set(g.items.map(c => c.source).filter(Boolean))]
|
|
498
|
+
return (
|
|
499
|
+
<div key={g.folder} className="card overflow-hidden">
|
|
500
|
+
<div
|
|
501
|
+
className="flex items-center gap-2 px-4 py-2.5 cursor-pointer transition"
|
|
502
|
+
onClick={() => toggleProject(g.folder)}
|
|
503
|
+
>
|
|
504
|
+
{isCollapsed ? <ChevronRight size={13} style={{ color: 'var(--c-text3)' }} /> : <ChevronDown size={13} style={{ color: 'var(--c-text3)' }} />}
|
|
505
|
+
<FolderOpen size={13} style={{ color: 'var(--c-text3)' }} />
|
|
506
|
+
<span className="text-xs font-medium" style={{ color: 'var(--c-white)' }}>{g.name}</span>
|
|
507
|
+
<span className="text-[11px] truncate max-w-[300px]" style={{ color: 'var(--c-text3)' }}>{g.folder !== g.name && g.folder !== '(no project)' ? g.folder : ''}</span>
|
|
508
|
+
<span className="ml-auto flex items-center gap-2">
|
|
509
|
+
{editorSet.slice(0, 5).map(e => (
|
|
510
|
+
<span key={e} className="w-2 h-2 rounded-full" style={{ background: editorColor(e) }} title={editorLabel(e)} />
|
|
511
|
+
))}
|
|
512
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>{g.items.length}</span>
|
|
513
|
+
</span>
|
|
514
|
+
</div>
|
|
515
|
+
{!isCollapsed && (
|
|
516
|
+
<table className="w-full text-sm">
|
|
517
|
+
<tbody>
|
|
518
|
+
{g.items.map(c => <SessionRow key={c.id} c={c} />)}
|
|
519
|
+
</tbody>
|
|
520
|
+
</table>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
)
|
|
524
|
+
})}
|
|
525
|
+
{grouped.length === 0 && !loading && (
|
|
526
|
+
<div className="text-center py-12 text-sm" style={{ color: 'var(--c-text3)' }}>no sessions found</div>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
)}
|
|
530
|
+
|
|
531
|
+
{/* Chat sidebar */}
|
|
532
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
533
|
+
</div>
|
|
534
|
+
)
|
|
535
|
+
}
|