memhouse 0.0.1 → 0.3.0
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 +59 -3
- package/bin/memhouse.js +487 -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/antigravity.js +1019 -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 +438 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +358 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +179 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +374 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +213 -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 +75 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +76 -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 +402 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +56 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-BsgjOSTv.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 +344 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- package/ui/vite.config.js +18 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { Users, Search, Merge, MessageSquare, FolderOpen, ChevronDown, ChevronRight } from 'lucide-react'
|
|
4
|
+
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
5
|
+
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
6
|
+
import KpiCard from '../components/KpiCard'
|
|
7
|
+
import EditorIcon from '../components/EditorIcon'
|
|
8
|
+
import SectionTitle from '../components/SectionTitle'
|
|
9
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
10
|
+
import LiveFeed from '../components/LiveFeed'
|
|
11
|
+
import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
|
|
12
|
+
import { fetchRelayTeamStats, fetchRelaySearch, fetchRelaySession, mergeRelayUsers } from '../lib/api'
|
|
13
|
+
import AnimatedLoader from '../components/AnimatedLoader'
|
|
14
|
+
import PageHeader from '../components/PageHeader'
|
|
15
|
+
import { useTheme } from '../lib/theme'
|
|
16
|
+
|
|
17
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
18
|
+
|
|
19
|
+
const MONO = 'JetBrains Mono, monospace'
|
|
20
|
+
const MODEL_COLORS = ['#6366f1', '#a78bfa', '#818cf8', '#c084fc', '#e879f9', '#f472b6', '#fb7185', '#f87171', '#fbbf24', '#34d399']
|
|
21
|
+
const USER_COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#c084fc', '#d8b4fe', '#e879f9', '#f472b6', '#fb7185']
|
|
22
|
+
|
|
23
|
+
function ProportionBar({ segments, height = 6 }) {
|
|
24
|
+
const total = segments.reduce((s, seg) => s + seg.value, 0)
|
|
25
|
+
if (total === 0) return null
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex w-full rounded-full overflow-hidden" style={{ height }}>
|
|
28
|
+
{segments.filter(s => s.value > 0).map((seg, i) => (
|
|
29
|
+
<div
|
|
30
|
+
key={i}
|
|
31
|
+
title={`${seg.label}: ${formatNumber(seg.value)}`}
|
|
32
|
+
className="h-full transition-all"
|
|
33
|
+
style={{ width: `${(seg.value / total * 100).toFixed(1)}%`, background: seg.color }}
|
|
34
|
+
/>
|
|
35
|
+
))}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Left sidebar: team members grouped by project (folder-tree view)
|
|
41
|
+
function TeamSidebar({ userList, userColorMap, selectedUser }) {
|
|
42
|
+
const [collapsed, setCollapsed] = useState(new Set())
|
|
43
|
+
const navigate = useNavigate()
|
|
44
|
+
|
|
45
|
+
// Build project → users mapping
|
|
46
|
+
const { projectGroups, ungrouped } = useMemo(() => {
|
|
47
|
+
const projMap = {}
|
|
48
|
+
const seen = new Set()
|
|
49
|
+
for (const u of userList) {
|
|
50
|
+
const projects = u.sharedProjects || []
|
|
51
|
+
if (projects.length === 0) {
|
|
52
|
+
seen.add(u.username)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
for (const p of projects) {
|
|
56
|
+
const name = typeof p === 'string' ? p : (p.name || p)
|
|
57
|
+
if (!projMap[name]) projMap[name] = []
|
|
58
|
+
projMap[name].push(u)
|
|
59
|
+
seen.add(u.username)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const ungrouped = userList.filter(u => !(u.sharedProjects?.length > 0))
|
|
63
|
+
const projectGroups = Object.entries(projMap).sort((a, b) => b[1].length - a[1].length)
|
|
64
|
+
return { projectGroups, ungrouped }
|
|
65
|
+
}, [userList])
|
|
66
|
+
|
|
67
|
+
const toggle = (key) => {
|
|
68
|
+
setCollapsed(prev => {
|
|
69
|
+
const next = new Set(prev)
|
|
70
|
+
next.has(key) ? next.delete(key) : next.add(key)
|
|
71
|
+
return next
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const UserItem = ({ u }) => {
|
|
76
|
+
const idx = userList.indexOf(u)
|
|
77
|
+
const color = userColorMap[u.username] || '#6366f1'
|
|
78
|
+
const editorEntries = Object.entries(u.editors || {}).sort((a, b) => b[1] - a[1])
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
className="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition rounded-sm"
|
|
82
|
+
style={{
|
|
83
|
+
background: selectedUser === u.username ? 'rgba(99,102,241,0.12)' : 'transparent',
|
|
84
|
+
}}
|
|
85
|
+
onMouseEnter={e => { if (selectedUser !== u.username) e.currentTarget.style.background = 'var(--c-bg3)' }}
|
|
86
|
+
onMouseLeave={e => { if (selectedUser !== u.username) e.currentTarget.style.background = 'transparent' }}
|
|
87
|
+
onClick={() => navigate(`/relay/user/${u.username}`)}
|
|
88
|
+
>
|
|
89
|
+
<div className="w-5 h-5 flex items-center justify-center text-[8px] font-bold rounded-sm flex-shrink-0" style={{ background: `${color}20`, color }}>
|
|
90
|
+
{u.username.charAt(0).toUpperCase()}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="flex-1 min-w-0">
|
|
93
|
+
<div className="text-[11px] font-medium truncate" style={{ color: 'var(--c-white)' }}>{u.username}</div>
|
|
94
|
+
<div className="flex items-center gap-1 text-[8px]" style={{ color: 'var(--c-text3)' }}>
|
|
95
|
+
<span>{u.sessions}s</span>
|
|
96
|
+
<span>·</span>
|
|
97
|
+
<span>{formatNumber(u.totalMessages)}m</span>
|
|
98
|
+
</div>
|
|
99
|
+
{editorEntries.length > 0 && (
|
|
100
|
+
<div className="flex items-center gap-1 mt-0.5">
|
|
101
|
+
{editorEntries.slice(0, 4).map(([src]) => (
|
|
102
|
+
<EditorIcon key={src} source={src} size={9} />
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex flex-col h-full">
|
|
113
|
+
<div className="flex items-center gap-2 px-3 py-2.5 shrink-0" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
114
|
+
<Users size={12} style={{ color: 'var(--c-accent)' }} />
|
|
115
|
+
<span className="text-[12px] font-medium uppercase tracking-wider" style={{ color: 'var(--c-text2)' }}>Team</span>
|
|
116
|
+
<span className="text-[10px] ml-auto" style={{ color: 'var(--c-text3)' }}>{userList.length}</span>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="flex-1 overflow-y-auto scrollbar-thin py-1">
|
|
120
|
+
{/* Project groups */}
|
|
121
|
+
{projectGroups.map(([proj, users]) => {
|
|
122
|
+
const isCollapsed = collapsed.has(proj)
|
|
123
|
+
const projName = proj.split('/').pop()
|
|
124
|
+
return (
|
|
125
|
+
<div key={proj}>
|
|
126
|
+
<div
|
|
127
|
+
className="flex items-center gap-1.5 px-2 py-1.5 cursor-pointer transition"
|
|
128
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
129
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
130
|
+
onClick={() => toggle(proj)}
|
|
131
|
+
>
|
|
132
|
+
{isCollapsed ? <ChevronRight size={10} style={{ color: 'var(--c-text3)' }} /> : <ChevronDown size={10} style={{ color: 'var(--c-text3)' }} />}
|
|
133
|
+
<FolderOpen size={10} style={{ color: '#818cf8' }} />
|
|
134
|
+
<span className="text-[11px] font-medium truncate flex-1" style={{ color: 'var(--c-text2)' }} title={proj}>{projName}</span>
|
|
135
|
+
<span className="text-[8px]" style={{ color: 'var(--c-text3)' }}>{users.length}</span>
|
|
136
|
+
</div>
|
|
137
|
+
{!isCollapsed && (
|
|
138
|
+
<div className="pl-3">
|
|
139
|
+
{users.map(u => <UserItem key={`${proj}-${u.username}`} u={u} />)}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
})}
|
|
145
|
+
|
|
146
|
+
{/* Ungrouped users */}
|
|
147
|
+
{ungrouped.length > 0 && (
|
|
148
|
+
<div>
|
|
149
|
+
{projectGroups.length > 0 && (
|
|
150
|
+
<div className="px-2 py-1.5 text-[10px] uppercase tracking-wider" style={{ color: 'var(--c-text3)' }}>
|
|
151
|
+
unassigned
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
<div className={projectGroups.length > 0 ? 'pl-1' : ''}>
|
|
155
|
+
{ungrouped.map(u => <UserItem key={u.username} u={u} />)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{userList.length === 0 && (
|
|
161
|
+
<div className="text-[11px] py-6 text-center" style={{ color: 'var(--c-text3)' }}>
|
|
162
|
+
No team members yet
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Merge section at bottom */}
|
|
168
|
+
{userList.length >= 2 && (
|
|
169
|
+
<MergeSection userList={userList} />
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function MergeSection({ userList }) {
|
|
176
|
+
const [mergeFrom, setMergeFrom] = useState('')
|
|
177
|
+
const [mergeTo, setMergeTo] = useState('')
|
|
178
|
+
const [merging, setMerging] = useState(false)
|
|
179
|
+
const [mergeResult, setMergeResult] = useState(null)
|
|
180
|
+
const [open, setOpen] = useState(false)
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div className="shrink-0 px-2 py-2" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
184
|
+
<div
|
|
185
|
+
className="flex items-center gap-1.5 cursor-pointer text-[10px] uppercase tracking-wider"
|
|
186
|
+
style={{ color: 'var(--c-text3)' }}
|
|
187
|
+
onClick={() => setOpen(!open)}
|
|
188
|
+
>
|
|
189
|
+
{open ? <ChevronDown size={9} /> : <ChevronRight size={9} />}
|
|
190
|
+
<Merge size={9} />
|
|
191
|
+
<span>merge users</span>
|
|
192
|
+
</div>
|
|
193
|
+
{open && (
|
|
194
|
+
<div className="mt-2 space-y-1.5">
|
|
195
|
+
<select
|
|
196
|
+
value={mergeFrom}
|
|
197
|
+
onChange={e => setMergeFrom(e.target.value)}
|
|
198
|
+
className="w-full text-[11px] px-1.5 py-1 outline-none rounded-sm"
|
|
199
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
200
|
+
>
|
|
201
|
+
<option value="">from...</option>
|
|
202
|
+
{userList.filter(u => u.username !== mergeTo).map(u => (
|
|
203
|
+
<option key={u.username} value={u.username}>{u.username}</option>
|
|
204
|
+
))}
|
|
205
|
+
</select>
|
|
206
|
+
<select
|
|
207
|
+
value={mergeTo}
|
|
208
|
+
onChange={e => setMergeTo(e.target.value)}
|
|
209
|
+
className="w-full text-[11px] px-1.5 py-1 outline-none rounded-sm"
|
|
210
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
211
|
+
>
|
|
212
|
+
<option value="">into...</option>
|
|
213
|
+
{userList.filter(u => u.username !== mergeFrom).map(u => (
|
|
214
|
+
<option key={u.username} value={u.username}>{u.username}</option>
|
|
215
|
+
))}
|
|
216
|
+
</select>
|
|
217
|
+
<button
|
|
218
|
+
disabled={!mergeFrom || !mergeTo || merging}
|
|
219
|
+
onClick={async () => {
|
|
220
|
+
if (!confirm(`Merge "${mergeFrom}" → "${mergeTo}"? Cannot undo.`)) return
|
|
221
|
+
setMerging(true)
|
|
222
|
+
setMergeResult(null)
|
|
223
|
+
try {
|
|
224
|
+
const r = await mergeRelayUsers(mergeFrom, mergeTo)
|
|
225
|
+
setMergeResult(r)
|
|
226
|
+
setMergeFrom('')
|
|
227
|
+
setMergeTo('')
|
|
228
|
+
} catch (err) {
|
|
229
|
+
setMergeResult({ error: err.message })
|
|
230
|
+
}
|
|
231
|
+
setMerging(false)
|
|
232
|
+
}}
|
|
233
|
+
className="w-full text-[11px] px-2 py-1 font-medium transition rounded-sm"
|
|
234
|
+
style={{
|
|
235
|
+
background: mergeFrom && mergeTo ? 'rgba(239,68,68,0.15)' : 'var(--c-bg3)',
|
|
236
|
+
color: mergeFrom && mergeTo ? '#ef4444' : 'var(--c-text3)',
|
|
237
|
+
border: '1px solid var(--c-border)',
|
|
238
|
+
cursor: !mergeFrom || !mergeTo || merging ? 'not-allowed' : 'pointer',
|
|
239
|
+
opacity: !mergeFrom || !mergeTo || merging ? 0.5 : 1,
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
{merging ? 'Merging...' : 'Merge'}
|
|
243
|
+
</button>
|
|
244
|
+
{mergeResult && (
|
|
245
|
+
<div className="text-[10px]" style={{ color: mergeResult.error ? '#ef4444' : '#22c55e' }}>
|
|
246
|
+
{mergeResult.error ? `Error: ${mergeResult.error}` : 'Merged!'}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export default function RelayDashboard() {
|
|
256
|
+
const navigate = useNavigate()
|
|
257
|
+
const [stats, setStats] = useState(null)
|
|
258
|
+
const [search, setSearch] = useState('')
|
|
259
|
+
const [searchResults, setSearchResults] = useState(null)
|
|
260
|
+
const [searching, setSearching] = useState(false)
|
|
261
|
+
const [selectedChat, setSelectedChat] = useState(null)
|
|
262
|
+
const [selectedUsername, setSelectedUsername] = useState(null)
|
|
263
|
+
const { dark } = useTheme()
|
|
264
|
+
|
|
265
|
+
const legendColor = dark ? '#888' : '#555'
|
|
266
|
+
const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
|
|
267
|
+
const txtDim = dark ? '#555' : '#999'
|
|
268
|
+
const txtColor = dark ? '#888' : '#555'
|
|
269
|
+
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
fetchRelayTeamStats().then(setStats)
|
|
272
|
+
const iv = setInterval(() => fetchRelayTeamStats().then(setStats), 15000)
|
|
273
|
+
return () => clearInterval(iv)
|
|
274
|
+
}, [])
|
|
275
|
+
|
|
276
|
+
const handleSearch = async (e) => {
|
|
277
|
+
e.preventDefault()
|
|
278
|
+
if (!search.trim()) return
|
|
279
|
+
setSearching(true)
|
|
280
|
+
try {
|
|
281
|
+
const results = await fetchRelaySearch(search.trim(), { limit: 30 })
|
|
282
|
+
setSearchResults(results)
|
|
283
|
+
} catch { setSearchResults([]) }
|
|
284
|
+
setSearching(false)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const userList = stats?.users || []
|
|
288
|
+
|
|
289
|
+
// Stable color map for users (must be before early return to keep hooks order)
|
|
290
|
+
const userColorMap = useMemo(() => {
|
|
291
|
+
const map = {}
|
|
292
|
+
userList.forEach((u, i) => { map[u.username] = USER_COLORS[i % USER_COLORS.length] })
|
|
293
|
+
return map
|
|
294
|
+
}, [userList])
|
|
295
|
+
|
|
296
|
+
if (!stats) return <AnimatedLoader label="Loading relay data..." />
|
|
297
|
+
|
|
298
|
+
const editorData = stats.editors || []
|
|
299
|
+
const models = stats.topModels || []
|
|
300
|
+
const totalTok = (stats.totalInputTokens || 0) + (stats.totalOutputTokens || 0)
|
|
301
|
+
const msgsPerSession = stats.totalSessions > 0 ? (stats.totalMessages / stats.totalSessions).toFixed(1) : 0
|
|
302
|
+
const tokPerSession = stats.totalSessions > 0 ? Math.round(totalTok / stats.totalSessions) : 0
|
|
303
|
+
const maxUserSessions = userList.length > 0 ? Math.max(...userList.map(u => u.sessions)) : 1
|
|
304
|
+
|
|
305
|
+
const handleFeedClick = (chatId, username) => {
|
|
306
|
+
setSelectedChat(chatId)
|
|
307
|
+
setSelectedUsername(username)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const sidebarH = 'calc(100vh - 42px)'
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div className="fade-in flex" style={{ height: sidebarH }}>
|
|
314
|
+
{/* ── Left sidebar: Team tree ── */}
|
|
315
|
+
<div
|
|
316
|
+
className="hidden lg:flex flex-col w-[250px] shrink-0 sticky top-[42px] self-start"
|
|
317
|
+
style={{ height: sidebarH, borderRight: '1px solid var(--c-border)', background: 'var(--c-bg)' }}
|
|
318
|
+
>
|
|
319
|
+
<TeamSidebar userList={userList} userColorMap={userColorMap} selectedUser={selectedUsername} onUserClick={u => navigate(`/relay/user/${u}`)} />
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{/* ── Center: scrollable content ── */}
|
|
323
|
+
<div className="flex-1 min-w-0 overflow-y-auto p-4 space-y-3">
|
|
324
|
+
<PageHeader icon={Users} title="Team Relay" />
|
|
325
|
+
|
|
326
|
+
{/* KPIs */}
|
|
327
|
+
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
|
|
328
|
+
<KpiCard label="team members" value={stats.totalUsers} />
|
|
329
|
+
<KpiCard label="sessions" value={formatNumber(stats.totalSessions)} sub={`${msgsPerSession} msgs/session`} />
|
|
330
|
+
<KpiCard label="active users" value={stats.activeUsers} />
|
|
331
|
+
<KpiCard label="projects" value={stats.totalProjects} />
|
|
332
|
+
<KpiCard label="messages" value={formatNumber(stats.totalMessages)} />
|
|
333
|
+
<KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${formatNumber(tokPerSession)}/session`} />
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{/* Token overview */}
|
|
337
|
+
{totalTok > 0 && (
|
|
338
|
+
<div className="card p-3">
|
|
339
|
+
<div className="flex items-center justify-between mb-2">
|
|
340
|
+
<SectionTitle>team token usage</SectionTitle>
|
|
341
|
+
<span className="text-[11px] font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(totalTok)} total</span>
|
|
342
|
+
</div>
|
|
343
|
+
<ProportionBar height={10} segments={[
|
|
344
|
+
{ label: 'Input', value: stats.totalInputTokens, color: '#6366f1' },
|
|
345
|
+
{ label: 'Output', value: stats.totalOutputTokens, color: '#a78bfa' },
|
|
346
|
+
]} />
|
|
347
|
+
<div className="flex items-center gap-4 mt-1.5 text-[10px]">
|
|
348
|
+
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#6366f1' }} /> input {formatNumber(stats.totalInputTokens)}</span>
|
|
349
|
+
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#a78bfa' }} /> output {formatNumber(stats.totalOutputTokens)}</span>
|
|
350
|
+
</div>
|
|
351
|
+
{userList.length > 1 && (
|
|
352
|
+
<div className="mt-3 pt-2" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
353
|
+
<div className="text-[10px] mb-1.5" style={{ color: 'var(--c-text3)' }}>per-user contribution</div>
|
|
354
|
+
<ProportionBar height={8} segments={userList.map(u => ({
|
|
355
|
+
label: u.username,
|
|
356
|
+
value: u.totalInputTokens + u.totalOutputTokens,
|
|
357
|
+
color: userColorMap[u.username],
|
|
358
|
+
}))} />
|
|
359
|
+
<div className="flex flex-wrap gap-3 mt-1 text-[10px]">
|
|
360
|
+
{userList.map(u => (
|
|
361
|
+
<span key={u.username} className="flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
|
|
362
|
+
<span className="w-2 h-2 rounded-sm" style={{ background: userColorMap[u.username] }} />
|
|
363
|
+
{u.username}
|
|
364
|
+
</span>
|
|
365
|
+
))}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
|
|
372
|
+
{/* Charts: editors + models + sessions per user */}
|
|
373
|
+
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3">
|
|
374
|
+
{editorData.length > 0 && (
|
|
375
|
+
<div className="card p-3">
|
|
376
|
+
<SectionTitle>editors</SectionTitle>
|
|
377
|
+
<div style={{ height: 160 }}>
|
|
378
|
+
<Doughnut
|
|
379
|
+
data={{
|
|
380
|
+
labels: editorData.map(e => editorLabel(e.source)),
|
|
381
|
+
datasets: [{ data: editorData.map(e => e.count), backgroundColor: editorData.map(e => editorColor(e.source)), borderWidth: 0 }],
|
|
382
|
+
}}
|
|
383
|
+
options={{
|
|
384
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
385
|
+
plugins: {
|
|
386
|
+
legend: { position: 'right', labels: { color: legendColor, font: { size: 9, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
387
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
388
|
+
},
|
|
389
|
+
}}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
{models.length > 0 && (
|
|
395
|
+
<div className="card p-3">
|
|
396
|
+
<SectionTitle>models</SectionTitle>
|
|
397
|
+
<div style={{ height: 160 }}>
|
|
398
|
+
<Doughnut
|
|
399
|
+
data={{
|
|
400
|
+
labels: models.slice(0, 10).map(m => m.name),
|
|
401
|
+
datasets: [{ data: models.slice(0, 10).map(m => m.count), backgroundColor: MODEL_COLORS, borderWidth: 0 }],
|
|
402
|
+
}}
|
|
403
|
+
options={{
|
|
404
|
+
responsive: true, maintainAspectRatio: false, cutout: '60%',
|
|
405
|
+
plugins: {
|
|
406
|
+
legend: { position: 'right', labels: { color: legendColor, font: { size: 8, family: MONO }, usePointStyle: true, pointStyle: 'circle', padding: 6 } },
|
|
407
|
+
tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } },
|
|
408
|
+
},
|
|
409
|
+
}}
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
)}
|
|
414
|
+
{userList.length > 0 && (
|
|
415
|
+
<div className="card p-3">
|
|
416
|
+
<SectionTitle>sessions per user</SectionTitle>
|
|
417
|
+
<div style={{ height: Math.max(120, userList.length * 28) }}>
|
|
418
|
+
<Bar
|
|
419
|
+
data={{
|
|
420
|
+
labels: userList.map(u => u.username),
|
|
421
|
+
datasets: [{
|
|
422
|
+
data: userList.map(u => u.sessions),
|
|
423
|
+
backgroundColor: userList.map(u => userColorMap[u.username]),
|
|
424
|
+
borderRadius: 2,
|
|
425
|
+
}],
|
|
426
|
+
}}
|
|
427
|
+
options={{
|
|
428
|
+
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
|
|
429
|
+
plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
|
|
430
|
+
scales: {
|
|
431
|
+
x: { grid: { color: gridColor }, ticks: { color: txtDim, font: { size: 8, family: MONO } }, beginAtZero: true },
|
|
432
|
+
y: { grid: { display: false }, ticks: { color: txtColor, font: { size: 9, family: MONO } } },
|
|
433
|
+
},
|
|
434
|
+
}}
|
|
435
|
+
/>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
|
|
441
|
+
{/* Search */}
|
|
442
|
+
<div className="card p-3">
|
|
443
|
+
<SectionTitle>search across team</SectionTitle>
|
|
444
|
+
<form onSubmit={handleSearch} className="flex gap-2 mt-1">
|
|
445
|
+
<div className="relative flex-1">
|
|
446
|
+
<Search size={11} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
447
|
+
<input
|
|
448
|
+
type="text"
|
|
449
|
+
value={search}
|
|
450
|
+
onChange={e => setSearch(e.target.value)}
|
|
451
|
+
placeholder="Search messages, files, topics across all users..."
|
|
452
|
+
className="w-full pl-7 pr-3 py-1.5 text-[12px] outline-none rounded-sm"
|
|
453
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-white)', border: '1px solid var(--c-border)' }}
|
|
454
|
+
/>
|
|
455
|
+
</div>
|
|
456
|
+
<button
|
|
457
|
+
type="submit"
|
|
458
|
+
disabled={searching}
|
|
459
|
+
className="px-3 py-1.5 text-[11px] font-medium transition rounded-sm"
|
|
460
|
+
style={{ background: 'var(--c-card)', border: '1px solid var(--c-border)', color: 'var(--c-white)' }}
|
|
461
|
+
>
|
|
462
|
+
{searching ? 'Searching...' : 'Search'}
|
|
463
|
+
</button>
|
|
464
|
+
</form>
|
|
465
|
+
{searchResults && (
|
|
466
|
+
<div className="mt-3 max-h-[300px] overflow-y-auto scrollbar-thin">
|
|
467
|
+
{searchResults.length === 0 ? (
|
|
468
|
+
<div className="text-[12px] py-2" style={{ color: 'var(--c-text3)' }}>No results found</div>
|
|
469
|
+
) : (
|
|
470
|
+
<table className="w-full text-[12px]">
|
|
471
|
+
<tbody>
|
|
472
|
+
{searchResults.map((r, i) => (
|
|
473
|
+
<tr
|
|
474
|
+
key={i}
|
|
475
|
+
className="cursor-pointer transition"
|
|
476
|
+
style={{ borderBottom: '1px solid var(--c-border)' }}
|
|
477
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
478
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
479
|
+
onClick={() => { setSelectedChat(r.chatId); setSelectedUsername(r.username) }}
|
|
480
|
+
>
|
|
481
|
+
<td className="py-2 px-2 w-[80px]">
|
|
482
|
+
<span className="text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>{r.username}</span>
|
|
483
|
+
</td>
|
|
484
|
+
<td className="py-2 px-2 w-[24px]"><EditorIcon source={r.source} size={11} /></td>
|
|
485
|
+
<td className="py-2 px-2">
|
|
486
|
+
<div className="text-[11px] truncate" style={{ color: 'var(--c-text3)' }}>{r.chatName}</div>
|
|
487
|
+
<div className="text-[11px] line-clamp-1 mt-0.5" style={{ color: 'var(--c-text)' }}>{r.content}</div>
|
|
488
|
+
</td>
|
|
489
|
+
<td className="py-2 px-2 text-[10px] whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{r.role}</td>
|
|
490
|
+
</tr>
|
|
491
|
+
))}
|
|
492
|
+
</tbody>
|
|
493
|
+
</table>
|
|
494
|
+
)}
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
{/* Team member cards (for detail view) */}
|
|
500
|
+
<SectionTitle>team overview</SectionTitle>
|
|
501
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
|
502
|
+
{userList.map((u) => {
|
|
503
|
+
const uTok = u.totalInputTokens + u.totalOutputTokens
|
|
504
|
+
const color = userColorMap[u.username]
|
|
505
|
+
const editorEntries = Object.entries(u.editors || {}).sort((a, b) => b[1] - a[1])
|
|
506
|
+
return (
|
|
507
|
+
<div
|
|
508
|
+
key={u.username}
|
|
509
|
+
className="card p-3 cursor-pointer transition hover:opacity-90"
|
|
510
|
+
onClick={() => navigate(`/relay/user/${u.username}`)}
|
|
511
|
+
>
|
|
512
|
+
<div className="flex items-center gap-2 mb-2">
|
|
513
|
+
<div className="w-7 h-7 flex items-center justify-center text-[12px] font-bold rounded-sm flex-shrink-0" style={{ background: `${color}20`, color }}>
|
|
514
|
+
{u.username.charAt(0).toUpperCase()}
|
|
515
|
+
</div>
|
|
516
|
+
<div className="flex-1 min-w-0">
|
|
517
|
+
<div className="text-[12px] font-bold truncate" style={{ color: 'var(--c-white)' }}>{u.username}</div>
|
|
518
|
+
<div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
519
|
+
{u.lastActive ? formatDate(u.lastActive) : 'No activity'}
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
<div className="flex items-center gap-0.5 flex-shrink-0">
|
|
523
|
+
{editorEntries.slice(0, 3).map(([src]) => (
|
|
524
|
+
<EditorIcon key={src} source={src} size={11} />
|
|
525
|
+
))}
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
<div className="grid grid-cols-4 gap-1 text-center mb-2">
|
|
529
|
+
{[
|
|
530
|
+
[u.sessions, 'sessions'],
|
|
531
|
+
[formatNumber(u.totalMessages), 'messages'],
|
|
532
|
+
[u.projects, 'projects'],
|
|
533
|
+
[formatNumber(uTok), 'tokens'],
|
|
534
|
+
].map(([v, l]) => (
|
|
535
|
+
<div key={l} className="p-1 rounded-sm" style={{ background: 'var(--c-code-bg)' }}>
|
|
536
|
+
<div className="text-[11px] font-bold" style={{ color: 'var(--c-white)' }}>{v}</div>
|
|
537
|
+
<div className="text-[7px]" style={{ color: 'var(--c-text3)' }}>{l}</div>
|
|
538
|
+
</div>
|
|
539
|
+
))}
|
|
540
|
+
</div>
|
|
541
|
+
{/* Activity bar */}
|
|
542
|
+
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
|
|
543
|
+
<div className="h-full rounded-full" style={{ width: `${(u.sessions / maxUserSessions * 100).toFixed(0)}%`, background: color }} />
|
|
544
|
+
</div>
|
|
545
|
+
{u.topModels && u.topModels.length > 0 && (
|
|
546
|
+
<div className="flex flex-wrap gap-1 mt-1.5">
|
|
547
|
+
{u.topModels.slice(0, 2).map(m => (
|
|
548
|
+
<span key={m.name} className="text-[8px] px-1.5 py-0.5 rounded-sm" style={{ background: 'rgba(99,102,241,0.08)', color: '#818cf8' }}>{m.name}</span>
|
|
549
|
+
))}
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
)
|
|
554
|
+
})}
|
|
555
|
+
</div>
|
|
556
|
+
|
|
557
|
+
{userList.length === 0 && (
|
|
558
|
+
<div className="card p-8 text-center">
|
|
559
|
+
<Users size={32} className="mx-auto mb-3" style={{ color: 'var(--c-text3)' }} />
|
|
560
|
+
<div className="text-[12px] font-medium mb-1" style={{ color: 'var(--c-white)' }}>No team members yet</div>
|
|
561
|
+
<div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>Share the join command with your team to start collecting data</div>
|
|
562
|
+
</div>
|
|
563
|
+
)}
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
{/* ── Right sidebar: Live Feed ── */}
|
|
567
|
+
<div
|
|
568
|
+
className="hidden xl:flex flex-col w-[300px] shrink-0 sticky top-[42px] self-start"
|
|
569
|
+
style={{ height: sidebarH, borderLeft: '1px solid var(--c-border)', background: 'var(--c-bg)' }}
|
|
570
|
+
>
|
|
571
|
+
<LiveFeed onSessionClick={handleFeedClick} />
|
|
572
|
+
</div>
|
|
573
|
+
|
|
574
|
+
{/* Session sidebar */}
|
|
575
|
+
<ChatSidebar
|
|
576
|
+
chatId={selectedChat}
|
|
577
|
+
onClose={() => { setSelectedChat(null); setSelectedUsername(null) }}
|
|
578
|
+
fetchFn={selectedUsername ? (id) => fetchRelaySession(id, selectedUsername) : undefined}
|
|
579
|
+
username={selectedUsername}
|
|
580
|
+
extraHeader={
|
|
581
|
+
selectedUsername ? (
|
|
582
|
+
<span className="text-[11px] font-medium px-1.5 py-0.5 shrink-0" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>
|
|
583
|
+
{selectedUsername}
|
|
584
|
+
</span>
|
|
585
|
+
) : null
|
|
586
|
+
}
|
|
587
|
+
/>
|
|
588
|
+
</div>
|
|
589
|
+
)
|
|
590
|
+
}
|