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
package/ui/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ui",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "node ../index.js --ui-dev & vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"lint": "eslint .",
|
|
10
|
+
"preview": "vite preview"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
14
|
+
"chart.js": "^4.5.1",
|
|
15
|
+
"lucide-react": "^0.577.0",
|
|
16
|
+
"react": "^19.2.0",
|
|
17
|
+
"react-chartjs-2": "^5.3.1",
|
|
18
|
+
"react-dom": "^19.2.0",
|
|
19
|
+
"react-markdown": "^10.1.0",
|
|
20
|
+
"react-router-dom": "^7.13.1",
|
|
21
|
+
"remark-gfm": "^4.0.1",
|
|
22
|
+
"tailwindcss": "^4.2.1"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@eslint/js": "^9.39.1",
|
|
26
|
+
"@types/react": "^19.2.7",
|
|
27
|
+
"@types/react-dom": "^19.2.3",
|
|
28
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
29
|
+
"eslint": "^9.39.1",
|
|
30
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
31
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
32
|
+
"globals": "^16.5.0",
|
|
33
|
+
"vite": "^8.0.0-beta.13"
|
|
34
|
+
},
|
|
35
|
+
"overrides": {
|
|
36
|
+
"vite": "^8.0.0-beta.13"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/ui/src/App.jsx
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
|
|
3
|
+
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, CreditCard, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Plug, Copy, Check, Settings as SettingsIcon, Package, ChevronDown, Target } from 'lucide-react'
|
|
4
|
+
import { fetchOverview, refetchAgents, fetchMode, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
|
+
import { useTheme } from './lib/theme'
|
|
6
|
+
import { useLive } from './hooks/useLive'
|
|
7
|
+
import AnimatedLogo from './components/AnimatedLogo'
|
|
8
|
+
import AnimatedLoader from './components/AnimatedLoader'
|
|
9
|
+
import LoginScreen from './components/LoginScreen'
|
|
10
|
+
import Dashboard from './pages/Dashboard'
|
|
11
|
+
import Sessions from './pages/Sessions'
|
|
12
|
+
import DeepAnalysis from './pages/DeepAnalysis'
|
|
13
|
+
import Compare from './pages/Compare'
|
|
14
|
+
import Projects from './pages/Projects'
|
|
15
|
+
import ProjectDetail from './pages/ProjectDetail'
|
|
16
|
+
import CostAnalysis from './pages/CostAnalysis'
|
|
17
|
+
import SqlViewer from './pages/SqlViewer'
|
|
18
|
+
import Artifacts from './pages/Artifacts'
|
|
19
|
+
import Settings from './pages/Settings'
|
|
20
|
+
import Subscriptions from './pages/Subscriptions'
|
|
21
|
+
import MCPs from './pages/MCPs'
|
|
22
|
+
import RelayDashboard from './pages/RelayDashboard'
|
|
23
|
+
import RelayUserDetail from './pages/RelayUserDetail'
|
|
24
|
+
import GSD from './pages/GSD'
|
|
25
|
+
|
|
26
|
+
function NavDropdown({ icon: Icon, label, items }) {
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
const location = useLocation()
|
|
29
|
+
const isActive = items.some(i => i.to === location.pathname)
|
|
30
|
+
const timeout = useRef(null)
|
|
31
|
+
|
|
32
|
+
const enter = () => { clearTimeout(timeout.current); setOpen(true) }
|
|
33
|
+
const leave = () => { timeout.current = setTimeout(() => setOpen(false), 150) }
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="relative" onMouseEnter={enter} onMouseLeave={leave}>
|
|
37
|
+
<button
|
|
38
|
+
className={`flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
|
|
39
|
+
isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
|
|
40
|
+
}`}
|
|
41
|
+
>
|
|
42
|
+
<Icon size={12} />
|
|
43
|
+
{label}
|
|
44
|
+
<ChevronDown size={10} style={{ opacity: 0.5 }} />
|
|
45
|
+
</button>
|
|
46
|
+
{open && (
|
|
47
|
+
<div
|
|
48
|
+
className="absolute top-full left-0 mt-1 py-1 rounded shadow-lg min-w-[160px] z-[100]"
|
|
49
|
+
style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
|
|
50
|
+
>
|
|
51
|
+
{items.map(({ to, icon: SubIcon, label: subLabel }) => (
|
|
52
|
+
<NavLink
|
|
53
|
+
key={to}
|
|
54
|
+
to={to}
|
|
55
|
+
onClick={() => setOpen(false)}
|
|
56
|
+
className={({ isActive: a }) =>
|
|
57
|
+
`flex items-center gap-2 px-3 py-1.5 text-[12px] transition ${
|
|
58
|
+
a ? 'bg-[var(--c-bg3)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)] hover:bg-[var(--c-bg3)]'
|
|
59
|
+
}`
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
<SubIcon size={12} />
|
|
63
|
+
{subLabel}
|
|
64
|
+
</NavLink>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function App() {
|
|
73
|
+
const [overview, setOverview] = useState(null)
|
|
74
|
+
const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
|
|
75
|
+
const { live, toggle: toggleLive } = useLive()
|
|
76
|
+
const [mode, setMode] = useState(null) // 'local' | 'relay'
|
|
77
|
+
const [needsAuth, setNeedsAuth] = useState(false)
|
|
78
|
+
const [authed, setAuthed] = useState(!!getAuthToken())
|
|
79
|
+
const liveRef = useRef(null)
|
|
80
|
+
const { dark, toggle } = useTheme()
|
|
81
|
+
const [mcpOpen, setMcpOpen] = useState(false)
|
|
82
|
+
const [mcpCopied, setMcpCopied] = useState(false)
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setOnAuthFailure(() => setAuthed(false))
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
fetchMode().then(data => {
|
|
90
|
+
setMode(data.mode || 'local')
|
|
91
|
+
setNeedsAuth(!!data.auth)
|
|
92
|
+
})
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const refreshOverview = useCallback(() => {
|
|
96
|
+
fetchOverview().then(setOverview).catch(() => {})
|
|
97
|
+
}, [])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (mode === 'local') refreshOverview()
|
|
101
|
+
}, [mode])
|
|
102
|
+
|
|
103
|
+
const rescanAndRefresh = useCallback(async (onProgress) => {
|
|
104
|
+
await refetchAgents(onProgress)
|
|
105
|
+
const data = await fetchOverview()
|
|
106
|
+
setOverview(data)
|
|
107
|
+
}, [])
|
|
108
|
+
|
|
109
|
+
// Live mode: rescan & refresh every 60s. `mode` must be a dependency: it
|
|
110
|
+
// starts as null (detection is async), so without it a persisted live=true
|
|
111
|
+
// never starts the interval once mode resolves to 'local'.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (live && mode === 'local') {
|
|
114
|
+
liveRef.current = setInterval(() => {
|
|
115
|
+
rescanAndRefresh().catch(() => {})
|
|
116
|
+
}, 60000)
|
|
117
|
+
} else {
|
|
118
|
+
if (liveRef.current) clearInterval(liveRef.current)
|
|
119
|
+
liveRef.current = null
|
|
120
|
+
}
|
|
121
|
+
return () => { if (liveRef.current) clearInterval(liveRef.current) }
|
|
122
|
+
}, [live, mode, rescanAndRefresh])
|
|
123
|
+
|
|
124
|
+
const handleRefetch = async () => {
|
|
125
|
+
setRefetchState({ scanned: 0, total: 0 })
|
|
126
|
+
try {
|
|
127
|
+
await rescanAndRefresh((p) => setRefetchState({ scanned: p.scanned, total: p.total }))
|
|
128
|
+
} catch (e) { console.error(e) }
|
|
129
|
+
setRefetchState(null)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isRelay = mode === 'relay'
|
|
133
|
+
const showLogin = isRelay && needsAuth && !authed
|
|
134
|
+
|
|
135
|
+
const location = useLocation()
|
|
136
|
+
const isFullWidth = location.pathname === '/artifacts'
|
|
137
|
+
|
|
138
|
+
const nav = isRelay ? [
|
|
139
|
+
{ to: '/', icon: Users, label: 'Team' },
|
|
140
|
+
] : [
|
|
141
|
+
{ to: '/', icon: Activity, label: 'Dashboard' },
|
|
142
|
+
{ to: '/sessions', icon: MessageSquare, label: 'Sessions' },
|
|
143
|
+
{ to: '/projects', icon: FolderOpen, label: 'Projects' },
|
|
144
|
+
{ icon: DollarSign, label: 'Costs', children: [
|
|
145
|
+
{ to: '/costs', icon: DollarSign, label: 'Cost Analysis' },
|
|
146
|
+
{ to: '/subscriptions', icon: CreditCard, label: 'Subscriptions' },
|
|
147
|
+
]},
|
|
148
|
+
{ icon: BarChart3, label: 'Insights', children: [
|
|
149
|
+
{ to: '/analysis', icon: BarChart3, label: 'Deep Analysis' },
|
|
150
|
+
{ to: '/compare', icon: GitCompare, label: 'Compare' },
|
|
151
|
+
]},
|
|
152
|
+
{ to: '/artifacts', icon: Package, label: 'Artifacts' },
|
|
153
|
+
{ to: '/gsd', icon: Target, label: 'GSD' },
|
|
154
|
+
{ to: '/mcps', icon: Plug, label: 'MCPs' },
|
|
155
|
+
{ to: '/sql', icon: Database, label: 'SQL' },
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
if (showLogin) {
|
|
159
|
+
return <LoginScreen onSuccess={() => setAuthed(true)} />
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="min-h-screen">
|
|
164
|
+
<header className="border-b px-4 py-1.5 flex items-center gap-3 sticky top-0 z-50 backdrop-blur-xl" style={{ borderColor: 'var(--c-border)', background: 'var(--c-header)' }}>
|
|
165
|
+
<span className="flex items-center gap-1.5 text-xs font-bold tracking-tight" style={{ color: 'var(--c-white)' }}>
|
|
166
|
+
<AnimatedLogo size={18} />
|
|
167
|
+
Agentlytics{isRelay && <span className="ml-1.5 text-[10px] font-medium px-1.5 py-0.5" style={{ background: 'rgba(99,102,241,0.15)', color: '#818cf8' }}>relay</span>}
|
|
168
|
+
</span>
|
|
169
|
+
<nav className="flex gap-0.5 ml-2">
|
|
170
|
+
{nav.map((item) => item.children ? (
|
|
171
|
+
<NavDropdown key={item.label} icon={item.icon} label={item.label} items={item.children} />
|
|
172
|
+
) : (
|
|
173
|
+
<NavLink
|
|
174
|
+
key={item.to}
|
|
175
|
+
to={item.to}
|
|
176
|
+
end={item.to === '/'}
|
|
177
|
+
className={({ isActive }) =>
|
|
178
|
+
`flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded transition ${
|
|
179
|
+
isActive ? 'bg-[var(--c-card)] text-[var(--c-white)]' : 'text-[var(--c-text2)] hover:text-[var(--c-white)]'
|
|
180
|
+
}`
|
|
181
|
+
}
|
|
182
|
+
>
|
|
183
|
+
<item.icon size={12} />
|
|
184
|
+
{item.label}
|
|
185
|
+
</NavLink>
|
|
186
|
+
))}
|
|
187
|
+
</nav>
|
|
188
|
+
<div className="ml-auto flex items-center gap-3">
|
|
189
|
+
{!isRelay && (
|
|
190
|
+
<>
|
|
191
|
+
<button
|
|
192
|
+
onClick={toggleLive}
|
|
193
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition"
|
|
194
|
+
style={{
|
|
195
|
+
color: live ? '#22c55e' : 'var(--c-text3)',
|
|
196
|
+
border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
|
|
197
|
+
background: live ? 'rgba(34,197,94,0.08)' : 'transparent',
|
|
198
|
+
}}
|
|
199
|
+
title={live ? 'Disable live refresh' : 'Enable live refresh (every 60s)'}
|
|
200
|
+
>
|
|
201
|
+
<span
|
|
202
|
+
className={`inline-block w-1.5 h-1.5 rounded-full ${live ? 'pulse-dot' : ''}`}
|
|
203
|
+
style={{ background: live ? '#22c55e' : 'var(--c-text3)' }}
|
|
204
|
+
/>
|
|
205
|
+
Live
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
onClick={handleRefetch}
|
|
209
|
+
disabled={!!refetchState}
|
|
210
|
+
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded transition hover:bg-[var(--c-card)]"
|
|
211
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
212
|
+
title="Clear cache and rescan all editors"
|
|
213
|
+
>
|
|
214
|
+
<RefreshCw size={10} className={refetchState ? 'animate-spin' : ''} />
|
|
215
|
+
{refetchState
|
|
216
|
+
? `Refetching (${refetchState.scanned}/${refetchState.total})...`
|
|
217
|
+
: 'Refetch'}
|
|
218
|
+
</button>
|
|
219
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
|
|
220
|
+
{overview ? `${overview.totalChats} sessions` : '...'}
|
|
221
|
+
</span>
|
|
222
|
+
</>
|
|
223
|
+
)}
|
|
224
|
+
{isRelay && (
|
|
225
|
+
<button
|
|
226
|
+
onClick={() => { setMcpOpen(true); setMcpCopied(false) }}
|
|
227
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] transition hover:bg-[var(--c-card)]"
|
|
228
|
+
style={{ color: '#818cf8', border: '1px solid var(--c-border)' }}
|
|
229
|
+
title="MCP Connection"
|
|
230
|
+
>
|
|
231
|
+
<Plug size={10} />
|
|
232
|
+
Connect
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
235
|
+
<NavLink
|
|
236
|
+
to="/settings"
|
|
237
|
+
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
238
|
+
style={({ isActive }) => ({ color: isActive ? '#6366f1' : 'var(--c-text2)' })}
|
|
239
|
+
title="Settings"
|
|
240
|
+
>
|
|
241
|
+
<SettingsIcon size={13} />
|
|
242
|
+
</NavLink>
|
|
243
|
+
<button
|
|
244
|
+
onClick={toggle}
|
|
245
|
+
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
246
|
+
style={{ color: 'var(--c-text2)' }}
|
|
247
|
+
title={dark ? 'Light mode' : 'Dark mode'}
|
|
248
|
+
>
|
|
249
|
+
{dark ? <Sun size={13} /> : <Moon size={13} />}
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</header>
|
|
253
|
+
|
|
254
|
+
{refetchState && (
|
|
255
|
+
<div className="flex items-center gap-2 px-4 py-1.5 text-[12px]" style={{ background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.15)', color: '#ca8a04' }}>
|
|
256
|
+
<AlertTriangle size={12} />
|
|
257
|
+
<span>Devin, Devin Next, and Antigravity require their app to be running during refetch — otherwise their sessions won't be detected.</span>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<main className={isRelay ? 'px-0' : isFullWidth ? 'p-0 overflow-hidden' : 'p-4 max-w-[1400px] mx-auto'}>
|
|
262
|
+
{mode === null ? (
|
|
263
|
+
<AnimatedLoader label="Loading..." />
|
|
264
|
+
) : isRelay ? (
|
|
265
|
+
<Routes>
|
|
266
|
+
<Route path="/" element={<RelayDashboard />} />
|
|
267
|
+
<Route path="/relay" element={<RelayDashboard />} />
|
|
268
|
+
<Route path="/relay/user/:username" element={<RelayUserDetail />} />
|
|
269
|
+
</Routes>
|
|
270
|
+
) : (
|
|
271
|
+
<Routes>
|
|
272
|
+
<Route path="/" element={<Dashboard overview={overview} />} />
|
|
273
|
+
<Route path="/projects" element={<Projects overview={overview} />} />
|
|
274
|
+
<Route path="/projects/detail" element={<ProjectDetail />} />
|
|
275
|
+
<Route path="/sessions" element={<Sessions overview={overview} />} />
|
|
276
|
+
{/* ChatDetail is now a sidebar in Sessions */}
|
|
277
|
+
<Route path="/costs" element={<CostAnalysis overview={overview} />} />
|
|
278
|
+
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
279
|
+
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
280
|
+
<Route path="/subscriptions" element={<Subscriptions />} />
|
|
281
|
+
<Route path="/artifacts" element={<Artifacts />} />
|
|
282
|
+
<Route path="/mcps" element={<MCPs />} />
|
|
283
|
+
<Route path="/sql" element={<SqlViewer />} />
|
|
284
|
+
<Route path="/gsd" element={<GSD />} />
|
|
285
|
+
<Route path="/settings" element={<Settings />} />
|
|
286
|
+
</Routes>
|
|
287
|
+
)}
|
|
288
|
+
</main>
|
|
289
|
+
|
|
290
|
+
<footer className={`border-t mt-8 px-4 py-3 flex items-center justify-between text-[11px]${isFullWidth ? ' hidden' : ''}`} style={{ borderColor: 'var(--c-border)', color: 'var(--c-text3)' }}>
|
|
291
|
+
<div className="flex items-center gap-3">
|
|
292
|
+
<a href="https://github.com/f/agentlytics" target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-[var(--c-text)] transition">
|
|
293
|
+
<Github size={11} />
|
|
294
|
+
<span>GitHub</span>
|
|
295
|
+
</a>
|
|
296
|
+
<span className="flex items-center gap-1">
|
|
297
|
+
<Terminal size={11} />
|
|
298
|
+
<code style={{ fontFamily: 'JetBrains Mono, monospace' }}>npx agentlytics</code>
|
|
299
|
+
</span>
|
|
300
|
+
</div>
|
|
301
|
+
<span>
|
|
302
|
+
built by <a href="https://github.com/f" target="_blank" rel="noopener noreferrer" className="hover:text-[var(--c-text)] transition" style={{ color: 'var(--c-text2)' }}>fkadev</a>
|
|
303
|
+
</span>
|
|
304
|
+
</footer>
|
|
305
|
+
|
|
306
|
+
{/* MCP Config Modal */}
|
|
307
|
+
{mcpOpen && (
|
|
308
|
+
<>
|
|
309
|
+
<div className="fixed inset-0 z-[60]" style={{ background: 'rgba(0,0,0,0.5)' }} onClick={() => setMcpOpen(false)} />
|
|
310
|
+
<div
|
|
311
|
+
className="fixed z-[70] w-[440px] max-w-[90vw] p-5 rounded shadow-2xl"
|
|
312
|
+
style={{ top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'var(--c-bg)', border: '1px solid var(--c-border)' }}
|
|
313
|
+
>
|
|
314
|
+
<div className="flex items-center justify-between mb-4">
|
|
315
|
+
<div className="text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
|
|
316
|
+
<Plug size={13} className="inline mr-1.5" style={{ color: '#818cf8' }} />
|
|
317
|
+
Connection Config
|
|
318
|
+
</div>
|
|
319
|
+
<button onClick={() => setMcpOpen(false)} className="text-[18px] leading-none px-1 hover:opacity-70 transition" style={{ color: 'var(--c-text3)' }}>×</button>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>MCP Config</div>
|
|
323
|
+
<div className="flex items-center justify-between mb-1">
|
|
324
|
+
<div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>Add to your AI client's MCP settings</div>
|
|
325
|
+
<button
|
|
326
|
+
onClick={() => {
|
|
327
|
+
// Password-protected relays gate /mcp with the same bearer token the
|
|
328
|
+
// dashboard got at login — include it or the copied config 401s.
|
|
329
|
+
const relayToken = localStorage.getItem('agentlytics_relay_token')
|
|
330
|
+
const server = { url: `${window.location.origin}/mcp` }
|
|
331
|
+
if (relayToken) server.headers = { Authorization: `Bearer ${relayToken}` }
|
|
332
|
+
const json = JSON.stringify({ "mcpServers": { "agentlytics": server } }, null, 2)
|
|
333
|
+
navigator.clipboard.writeText(json)
|
|
334
|
+
setMcpCopied(true)
|
|
335
|
+
setTimeout(() => setMcpCopied(false), 2000)
|
|
336
|
+
}}
|
|
337
|
+
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] transition hover:bg-[var(--c-bg3)]"
|
|
338
|
+
style={{ border: '1px solid var(--c-border)', color: mcpCopied ? '#22c55e' : 'var(--c-text2)' }}
|
|
339
|
+
>
|
|
340
|
+
{mcpCopied ? <><Check size={9} /> Copied</> : <><Copy size={9} /> Copy</>}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
<pre
|
|
344
|
+
className="text-[11px] px-3 py-2 overflow-x-auto mb-4"
|
|
345
|
+
style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
|
|
346
|
+
>{localStorage.getItem('agentlytics_relay_token')
|
|
347
|
+
? `{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp",\n "headers": { "Authorization": "Bearer ${localStorage.getItem('agentlytics_relay_token')}" }\n }\n }\n}`
|
|
348
|
+
: `{\n "mcpServers": {\n "agentlytics": {\n "url": "${window.location.origin}/mcp"\n }\n }\n}`}</pre>
|
|
349
|
+
|
|
350
|
+
<div className="text-[12px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>Team Sharing</div>
|
|
351
|
+
<div className="text-[10px] mb-1" style={{ color: 'var(--c-text3)' }}>
|
|
352
|
+
Relay join is not supported in memhouse — team sharing is kernel-based
|
|
353
|
+
(members + grants + row-level security). See mem-house/delivery/kernel-install.md.
|
|
354
|
+
</div>
|
|
355
|
+
<pre
|
|
356
|
+
className="text-[11px] px-3 py-2 overflow-x-auto"
|
|
357
|
+
style={{ background: 'var(--c-bg3)', border: '1px solid var(--c-border)', color: 'var(--c-text)', fontFamily: 'JetBrains Mono, monospace', lineHeight: 1.6 }}
|
|
358
|
+
>{`# each member ships with their own credential:\nMEMHOUSE_URL=<realm-url> MEMHOUSE_USER=<member> MEMHOUSE_PASSWORD=<credential> \\\n memhouse ship --loop`}</pre>
|
|
359
|
+
</div>
|
|
360
|
+
</>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useState, useMemo, useRef } from 'react'
|
|
2
|
+
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from 'chart.js'
|
|
3
|
+
import { Line } from 'react-chartjs-2'
|
|
4
|
+
import { editorColor, editorLabel } from '../lib/constants'
|
|
5
|
+
import { useTheme } from '../lib/theme'
|
|
6
|
+
|
|
7
|
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
|
8
|
+
|
|
9
|
+
const CELL_SIZE = 11
|
|
10
|
+
const CELL_GAP = 2
|
|
11
|
+
const WEEK_COLS = 53
|
|
12
|
+
const DAY_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
|
|
13
|
+
|
|
14
|
+
function getIntensity(count, maxCount) {
|
|
15
|
+
if (count === 0) return 0
|
|
16
|
+
if (maxCount <= 0) return 1
|
|
17
|
+
const ratio = count / maxCount
|
|
18
|
+
if (ratio > 0.75) return 4
|
|
19
|
+
if (ratio > 0.5) return 3
|
|
20
|
+
if (ratio > 0.25) return 2
|
|
21
|
+
return 1
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const INTENSITY_COLORS_DARK = ['rgba(255,255,255,0.03)', '#0e4429', '#006d32', '#26a641', '#39d353']
|
|
25
|
+
const INTENSITY_COLORS_LIGHT = ['rgba(0,0,0,0.04)', '#9be9a8', '#40c463', '#30a14e', '#216e39']
|
|
26
|
+
|
|
27
|
+
export default function ActivityHeatmap({ dailyData }) {
|
|
28
|
+
const { dark } = useTheme()
|
|
29
|
+
const [selectedDay, setSelectedDay] = useState(null)
|
|
30
|
+
const containerRef = useRef(null)
|
|
31
|
+
|
|
32
|
+
// Build a full year grid (53 weeks × 7 days)
|
|
33
|
+
const grid = useMemo(() => {
|
|
34
|
+
if (!dailyData || dailyData.length === 0) return { weeks: [], months: [], maxCount: 0 }
|
|
35
|
+
|
|
36
|
+
const dayMap = {}
|
|
37
|
+
let maxCount = 0
|
|
38
|
+
for (const d of dailyData) {
|
|
39
|
+
dayMap[d.day] = d
|
|
40
|
+
if (d.total > maxCount) maxCount = d.total
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// End on Saturday of the current week, start WEEK_COLS-1 weeks before
|
|
44
|
+
const today = new Date()
|
|
45
|
+
today.setHours(0, 0, 0, 0)
|
|
46
|
+
const start = new Date(today)
|
|
47
|
+
start.setDate(start.getDate() - start.getDay() - (WEEK_COLS - 1) * 7)
|
|
48
|
+
|
|
49
|
+
const weeks = []
|
|
50
|
+
const months = []
|
|
51
|
+
let lastMonth = -1
|
|
52
|
+
const cursor = new Date(start)
|
|
53
|
+
|
|
54
|
+
for (let w = 0; w < WEEK_COLS; w++) {
|
|
55
|
+
const week = []
|
|
56
|
+
for (let d = 0; d < 7; d++) {
|
|
57
|
+
const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
|
|
58
|
+
const data = dayMap[key] || null
|
|
59
|
+
const count = data ? data.total : 0
|
|
60
|
+
const isFuture = cursor > today
|
|
61
|
+
|
|
62
|
+
if (cursor.getMonth() !== lastMonth) {
|
|
63
|
+
lastMonth = cursor.getMonth()
|
|
64
|
+
months.push({ week: w, label: cursor.toLocaleString('default', { month: 'short' }) })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
week.push({ key, count, data, isFuture, day: d })
|
|
68
|
+
cursor.setDate(cursor.getDate() + 1)
|
|
69
|
+
}
|
|
70
|
+
weeks.push(week)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { weeks, months, maxCount }
|
|
74
|
+
}, [dailyData])
|
|
75
|
+
|
|
76
|
+
// Hourly drill-down for selected day
|
|
77
|
+
const hourlyChart = useMemo(() => {
|
|
78
|
+
if (!selectedDay?.data) return null
|
|
79
|
+
const hours = selectedDay.data.hours || {}
|
|
80
|
+
const editors = Object.keys(hours)
|
|
81
|
+
const labels = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`)
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
labels,
|
|
85
|
+
datasets: editors.map(eid => ({
|
|
86
|
+
label: editorLabel(eid),
|
|
87
|
+
data: hours[eid],
|
|
88
|
+
borderColor: editorColor(eid),
|
|
89
|
+
backgroundColor: editorColor(eid) + '20',
|
|
90
|
+
borderWidth: 1.5,
|
|
91
|
+
tension: 0.3,
|
|
92
|
+
pointRadius: 1,
|
|
93
|
+
pointHoverRadius: 3,
|
|
94
|
+
fill: true,
|
|
95
|
+
})),
|
|
96
|
+
}
|
|
97
|
+
}, [selectedDay])
|
|
98
|
+
|
|
99
|
+
if (!grid.weeks.length) return null
|
|
100
|
+
|
|
101
|
+
const COLORS = dark ? INTENSITY_COLORS_DARK : INTENSITY_COLORS_LIGHT
|
|
102
|
+
const txtDim = dark ? '#555' : '#999'
|
|
103
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
104
|
+
const legendColor = dark ? '#888' : '#555'
|
|
105
|
+
const svgWidth = WEEK_COLS * (CELL_SIZE + CELL_GAP) + 28
|
|
106
|
+
const svgHeight = 7 * (CELL_SIZE + CELL_GAP) + 20
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div>
|
|
110
|
+
<div className="overflow-x-auto scrollbar-thin" ref={containerRef}>
|
|
111
|
+
<svg width={svgWidth} height={svgHeight} className="block">
|
|
112
|
+
{/* Month labels */}
|
|
113
|
+
{grid.months.map((m, i) => (
|
|
114
|
+
<text key={i} x={28 + m.week * (CELL_SIZE + CELL_GAP)} y={8} fill="var(--c-text3)" fontSize={8}>{m.label}</text>
|
|
115
|
+
))}
|
|
116
|
+
{DAY_LABELS.map((label, i) => (
|
|
117
|
+
<text key={i} x={0} y={14 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 2} fill="var(--c-text3)" fontSize={8}>{label}</text>
|
|
118
|
+
))}
|
|
119
|
+
{/* Cells */}
|
|
120
|
+
{grid.weeks.map((week, w) =>
|
|
121
|
+
week.map((cell, d) => {
|
|
122
|
+
if (cell.isFuture) return null
|
|
123
|
+
const intensity = getIntensity(cell.count, grid.maxCount)
|
|
124
|
+
const isSelected = selectedDay?.key === cell.key
|
|
125
|
+
return (
|
|
126
|
+
<rect
|
|
127
|
+
key={cell.key}
|
|
128
|
+
x={28 + w * (CELL_SIZE + CELL_GAP)}
|
|
129
|
+
y={12 + d * (CELL_SIZE + CELL_GAP)}
|
|
130
|
+
width={CELL_SIZE}
|
|
131
|
+
height={CELL_SIZE}
|
|
132
|
+
rx={0}
|
|
133
|
+
fill={COLORS[intensity]}
|
|
134
|
+
stroke={isSelected ? '#818cf8' : 'transparent'}
|
|
135
|
+
strokeWidth={isSelected ? 2 : 0}
|
|
136
|
+
className="cursor-pointer transition-all"
|
|
137
|
+
onClick={() => setSelectedDay(cell.count > 0 ? cell : null)}
|
|
138
|
+
>
|
|
139
|
+
<title>{`${cell.key}: ${cell.count} session${cell.count !== 1 ? 's' : ''}`}</title>
|
|
140
|
+
</rect>
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
)}
|
|
144
|
+
</svg>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="flex items-center gap-1.5 mt-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
148
|
+
<span>less</span>
|
|
149
|
+
{COLORS.map((color, i) => (
|
|
150
|
+
<span key={i} className="inline-block w-[9px] h-[9px] rounded-sm" style={{ background: color }} />
|
|
151
|
+
))}
|
|
152
|
+
<span>more</span>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{/* Drill-down: hourly activity for selected day */}
|
|
156
|
+
{selectedDay && selectedDay.data && (
|
|
157
|
+
<div className="mt-2 card p-3 fade-in">
|
|
158
|
+
<div className="flex items-center justify-between mb-2">
|
|
159
|
+
<div>
|
|
160
|
+
<span className="text-xs font-medium" style={{ color: 'var(--c-white)' }}>{selectedDay.key}</span>
|
|
161
|
+
<span className="text-[11px] ml-2" style={{ color: 'var(--c-text2)' }}>
|
|
162
|
+
{selectedDay.count} session{selectedDay.count !== 1 ? 's' : ''}
|
|
163
|
+
{' · '}
|
|
164
|
+
{Object.entries(selectedDay.data.editors || {}).map(([e, c]) => `${editorLabel(e)}: ${c}`).join(', ')}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
<button onClick={() => setSelectedDay(null)} className="text-[11px] transition" style={{ color: 'var(--c-text2)' }}>close</button>
|
|
168
|
+
</div>
|
|
169
|
+
{hourlyChart && (
|
|
170
|
+
<div style={{ height: 140 }}>
|
|
171
|
+
<Line
|
|
172
|
+
data={hourlyChart}
|
|
173
|
+
options={{
|
|
174
|
+
responsive: true,
|
|
175
|
+
maintainAspectRatio: false,
|
|
176
|
+
interaction: { mode: 'index', intersect: false },
|
|
177
|
+
scales: {
|
|
178
|
+
x: {
|
|
179
|
+
grid: { color: gridColor },
|
|
180
|
+
ticks: { color: txtDim, font: { size: 9, family: 'JetBrains Mono, monospace' }, maxRotation: 0 },
|
|
181
|
+
},
|
|
182
|
+
y: {
|
|
183
|
+
beginAtZero: true,
|
|
184
|
+
grid: { color: gridColor },
|
|
185
|
+
ticks: { color: txtDim, stepSize: 1, font: { size: 9, family: 'JetBrains Mono, monospace' } },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
plugins: {
|
|
189
|
+
legend: {
|
|
190
|
+
position: 'top',
|
|
191
|
+
labels: { color: legendColor, font: { size: 9, family: 'JetBrains Mono, monospace' }, usePointStyle: true, pointStyle: 'circle', padding: 8 },
|
|
192
|
+
},
|
|
193
|
+
tooltip: {
|
|
194
|
+
bodyFont: { family: 'JetBrains Mono, monospace', size: 10 },
|
|
195
|
+
titleFont: { family: 'JetBrains Mono, monospace', size: 10 },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|