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,241 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search, ShieldCheck, ShieldOff, AlertTriangle, X } from 'lucide-react'
|
|
3
|
+
import { fetchConfig, updateConfig, fetchAllProjects } from '../lib/api'
|
|
4
|
+
import { editorLabel, formatNumber, formatDate } from '../lib/constants'
|
|
5
|
+
import EditorIcon from '../components/EditorIcon'
|
|
6
|
+
import SectionTitle from '../components/SectionTitle'
|
|
7
|
+
import AnimatedLoader from '../components/AnimatedLoader'
|
|
8
|
+
import PageHeader from '../components/PageHeader'
|
|
9
|
+
|
|
10
|
+
export default function Settings() {
|
|
11
|
+
const [config, setConfig] = useState(null)
|
|
12
|
+
const [projects, setProjects] = useState([])
|
|
13
|
+
const [loading, setLoading] = useState(true)
|
|
14
|
+
const [saving, setSaving] = useState(false)
|
|
15
|
+
const [search, setSearch] = useState('')
|
|
16
|
+
const [showConfirm, setShowConfirm] = useState(false)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
Promise.all([fetchConfig(), fetchAllProjects()]).then(([cfg, projs]) => {
|
|
20
|
+
setConfig(cfg)
|
|
21
|
+
setProjects(projs)
|
|
22
|
+
setLoading(false)
|
|
23
|
+
}).catch(() => setLoading(false))
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
if (loading || !config) {
|
|
27
|
+
return <AnimatedLoader label="Loading settings..." />
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hiddenProjects = config.hiddenProjects || []
|
|
31
|
+
|
|
32
|
+
const toggleProject = async (folder) => {
|
|
33
|
+
setSaving(true)
|
|
34
|
+
const isHidden = hiddenProjects.includes(folder)
|
|
35
|
+
const updated = isHidden
|
|
36
|
+
? hiddenProjects.filter(f => f !== folder)
|
|
37
|
+
: [...hiddenProjects, folder]
|
|
38
|
+
const newConfig = await updateConfig({ hiddenProjects: updated })
|
|
39
|
+
setConfig(newConfig)
|
|
40
|
+
setSaving(false)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const filtered = projects.filter(p => {
|
|
44
|
+
if (!search) return true
|
|
45
|
+
const q = search.toLowerCase()
|
|
46
|
+
return p.folder.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const sorted = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
|
50
|
+
|
|
51
|
+
const subscriptionAccess = !!config.allowSubscriptionAccess
|
|
52
|
+
|
|
53
|
+
const toggleSubscriptionAccess = async () => {
|
|
54
|
+
setSaving(true)
|
|
55
|
+
const newConfig = await updateConfig({ allowSubscriptionAccess: !subscriptionAccess })
|
|
56
|
+
setConfig(newConfig)
|
|
57
|
+
setSaving(false)
|
|
58
|
+
setShowConfirm(false)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="fade-in space-y-3">
|
|
63
|
+
<PageHeader icon={SettingsIcon} title="Settings" />
|
|
64
|
+
|
|
65
|
+
<div className="card overflow-hidden">
|
|
66
|
+
<div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
67
|
+
<SectionTitle>
|
|
68
|
+
{subscriptionAccess ? <ShieldCheck size={11} className="inline mr-1" /> : <ShieldOff size={11} className="inline mr-1" />}
|
|
69
|
+
subscription access
|
|
70
|
+
</SectionTitle>
|
|
71
|
+
<button
|
|
72
|
+
onClick={() => setShowConfirm(true)}
|
|
73
|
+
disabled={saving}
|
|
74
|
+
className="text-[11px] px-2 py-0.5 rounded transition"
|
|
75
|
+
style={{
|
|
76
|
+
background: subscriptionAccess ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)',
|
|
77
|
+
color: subscriptionAccess ? '#22c55e' : '#ef4444',
|
|
78
|
+
border: `1px solid ${subscriptionAccess ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)'}`,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{subscriptionAccess ? 'Enabled' : 'Disabled'}
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="text-[11px] px-3 py-2" style={{ color: 'var(--c-text3)' }}>
|
|
85
|
+
When enabled, Agentlytics reads locally stored auth tokens (Keychain, SQLite, config files) to show your plan and usage info for each editor.
|
|
86
|
+
Tokens are kept in-memory only and <span style={{ color: 'var(--c-text2)' }}>never sent to any third-party service</span>.
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="card overflow-hidden">
|
|
91
|
+
<div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
92
|
+
<SectionTitle>
|
|
93
|
+
<FolderOpen size={11} className="inline mr-1" />
|
|
94
|
+
projects ({projects.length})
|
|
95
|
+
</SectionTitle>
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
{hiddenProjects.length > 0 && (
|
|
98
|
+
<span className="text-[10px] px-1.5 py-0.5" style={{ background: 'rgba(239,68,68,0.08)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.15)' }}>
|
|
99
|
+
{hiddenProjects.length} hidden
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
<div className="relative">
|
|
103
|
+
<Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
104
|
+
<input
|
|
105
|
+
type="text"
|
|
106
|
+
value={search}
|
|
107
|
+
onChange={e => setSearch(e.target.value)}
|
|
108
|
+
placeholder="Filter projects..."
|
|
109
|
+
className="pl-6 pr-2 py-1 text-[11px] outline-none w-[180px]"
|
|
110
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="text-[11px] px-3 py-1.5" style={{ color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)', background: 'var(--c-bg3)' }}>
|
|
116
|
+
Hidden projects are excluded from all dashboard stats, sessions, costs, and analytics.
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{sorted.map(p => (
|
|
120
|
+
<ProjectRow key={p.folder} project={p} hidden={hiddenProjects.includes(p.folder)} onToggle={toggleProject} saving={saving} />
|
|
121
|
+
))}
|
|
122
|
+
|
|
123
|
+
{sorted.length === 0 && (
|
|
124
|
+
<div className="text-center py-6 text-[12px]" style={{ color: 'var(--c-text3)' }}>no projects match filter</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
{showConfirm && (
|
|
128
|
+
<ConfirmModal
|
|
129
|
+
enabling={!subscriptionAccess}
|
|
130
|
+
saving={saving}
|
|
131
|
+
onConfirm={toggleSubscriptionAccess}
|
|
132
|
+
onCancel={() => setShowConfirm(false)}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ConfirmModal({ enabling, saving, onConfirm, onCancel }) {
|
|
140
|
+
return (
|
|
141
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }} onClick={onCancel}>
|
|
142
|
+
<div className="card w-[420px] mx-4" onClick={e => e.stopPropagation()} style={{ background: 'var(--c-bg2)', border: '1px solid var(--c-border)' }}>
|
|
143
|
+
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
144
|
+
<div className="flex items-center gap-2 text-[13px] font-medium" style={{ color: 'var(--c-white)' }}>
|
|
145
|
+
<AlertTriangle size={14} style={{ color: enabling ? '#fbbf24' : '#ef4444' }} />
|
|
146
|
+
{enabling ? 'Enable' : 'Disable'} Subscription Access
|
|
147
|
+
</div>
|
|
148
|
+
<button onClick={onCancel} className="p-1 rounded hover:bg-[var(--c-bg3)]" style={{ color: 'var(--c-text3)' }}>
|
|
149
|
+
<X size={14} />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
<div className="px-4 py-3 text-[11px] space-y-2" style={{ color: 'var(--c-text2)' }}>
|
|
153
|
+
{enabling ? (
|
|
154
|
+
<>
|
|
155
|
+
<p>This will allow Agentlytics to read locally stored auth tokens from:</p>
|
|
156
|
+
<ul className="space-y-1 pl-3" style={{ color: 'var(--c-text3)' }}>
|
|
157
|
+
<li>Claude Code – macOS Keychain / Linux secret-tool</li>
|
|
158
|
+
<li>Cursor – local SQLite (state.vscdb)</li>
|
|
159
|
+
<li>Copilot / VS Code – ~/.config/github-copilot/apps.json</li>
|
|
160
|
+
<li>Codex – local auth.json (JWT decode only)</li>
|
|
161
|
+
<li>Devin – local SQLite (state.vscdb)</li>
|
|
162
|
+
</ul>
|
|
163
|
+
<p style={{ color: 'var(--c-text2)' }}>Tokens are kept <strong>in-memory only</strong> and never sent to any third-party service.</p>
|
|
164
|
+
</>
|
|
165
|
+
) : (
|
|
166
|
+
<p>This will stop Agentlytics from reading any local auth tokens. Subscription and plan details will no longer be collected.</p>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
<div className="flex justify-end gap-2 px-4 py-3" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
170
|
+
<button
|
|
171
|
+
onClick={onCancel}
|
|
172
|
+
className="text-[11px] px-3 py-1 rounded transition"
|
|
173
|
+
style={{ color: 'var(--c-text3)', border: '1px solid var(--c-border)' }}
|
|
174
|
+
>
|
|
175
|
+
Cancel
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
onClick={onConfirm}
|
|
179
|
+
disabled={saving}
|
|
180
|
+
className="text-[11px] px-3 py-1 rounded transition font-medium"
|
|
181
|
+
style={{
|
|
182
|
+
background: enabling ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)',
|
|
183
|
+
color: enabling ? '#22c55e' : '#ef4444',
|
|
184
|
+
border: `1px solid ${enabling ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{saving ? 'Saving...' : enabling ? 'Yes, enable' : 'Yes, disable'}
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ProjectRow({ project: p, hidden, onToggle, saving }) {
|
|
196
|
+
const editors = Object.entries(p.editors || {}).sort((a, b) => b[1] - a[1])
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div
|
|
200
|
+
className="flex items-center gap-3 px-3 py-2 transition"
|
|
201
|
+
style={{
|
|
202
|
+
borderBottom: '1px solid var(--c-border)',
|
|
203
|
+
opacity: hidden ? 0.5 : 1,
|
|
204
|
+
}}
|
|
205
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
206
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
207
|
+
>
|
|
208
|
+
<button
|
|
209
|
+
onClick={() => onToggle(p.folder)}
|
|
210
|
+
disabled={saving}
|
|
211
|
+
className="shrink-0 p-1 rounded transition hover:bg-[var(--c-bg)]"
|
|
212
|
+
style={{ color: hidden ? '#ef4444' : 'var(--c-text3)', border: '1px solid var(--c-border)' }}
|
|
213
|
+
title={hidden ? 'Show this project' : 'Hide this project'}
|
|
214
|
+
>
|
|
215
|
+
{hidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
216
|
+
</button>
|
|
217
|
+
<div className="min-w-0 flex-1">
|
|
218
|
+
<div className="text-[12px] font-medium truncate" style={{ color: hidden ? 'var(--c-text3)' : 'var(--c-white)' }}>
|
|
219
|
+
{p.name}
|
|
220
|
+
</div>
|
|
221
|
+
<div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }} title={p.folder}>
|
|
222
|
+
{p.folder}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
226
|
+
{editors.slice(0, 3).map(([src, count]) => (
|
|
227
|
+
<span key={src} className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
228
|
+
<EditorIcon source={src} size={10} />
|
|
229
|
+
{count}
|
|
230
|
+
</span>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
<div className="text-[11px] font-mono shrink-0" style={{ color: 'var(--c-text2)' }}>
|
|
234
|
+
{formatNumber(p.totalSessions)}
|
|
235
|
+
</div>
|
|
236
|
+
<div className="text-[10px] shrink-0 w-[80px] text-right" style={{ color: 'var(--c-text3)' }}>
|
|
237
|
+
{formatDate(p.lastSeen)}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo } from 'react'
|
|
2
|
+
import { Play, Database, Table2, ChevronDown, Copy, Download, BarChart3, LineChart, PieChart } from 'lucide-react'
|
|
3
|
+
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler } from 'chart.js'
|
|
4
|
+
import { Bar, Line, Doughnut } from 'react-chartjs-2'
|
|
5
|
+
import { executeQuery, fetchSchema } from '../lib/api'
|
|
6
|
+
import { useTheme } from '../lib/theme'
|
|
7
|
+
import PageHeader from '../components/PageHeader'
|
|
8
|
+
|
|
9
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
|
|
10
|
+
|
|
11
|
+
// Two backends share this SPA: the typed mem-house house (sessions / messages /
|
|
12
|
+
// tool_calls + the sessions_v rollup) and the legacy agentlytics cache (chats /
|
|
13
|
+
// chat_stats). Both are ClickHouse — the flavor is detected from /api/schema
|
|
14
|
+
// (sessions_v present → typed) and the matching example set is offered.
|
|
15
|
+
const TYPED_EXAMPLES = [
|
|
16
|
+
{ label: 'Sessions per editor', sql: `SELECT source, count() AS count FROM sessions_v GROUP BY source ORDER BY count DESC` },
|
|
17
|
+
{ label: 'Top 10 projects', sql: `SELECT project, count() AS sessions, sum(total_msgs) AS messages FROM sessions_v WHERE project != '' GROUP BY project ORDER BY sessions DESC LIMIT 10` },
|
|
18
|
+
{ label: 'Messages per day', sql: `SELECT toDate(ts) AS day, count() AS count FROM messages GROUP BY day ORDER BY day` },
|
|
19
|
+
{ label: 'Top models', sql: `SELECT model, count() AS count FROM messages WHERE model NOT IN ('', '<synthetic>') GROUP BY model ORDER BY count DESC LIMIT 10` },
|
|
20
|
+
{ label: 'Top tools', sql: `SELECT tool_name, count() AS count FROM tool_calls GROUP BY tool_name ORDER BY count DESC LIMIT 15` },
|
|
21
|
+
{ label: 'Token usage by editor', sql: `SELECT source, sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens FROM sessions_v GROUP BY source ORDER BY input_tokens DESC` },
|
|
22
|
+
{ label: 'Sessions by mode', sql: `SELECT mode, count() AS count FROM sessions_v WHERE mode != '' GROUP BY mode ORDER BY count DESC` },
|
|
23
|
+
{ label: 'Hourly distribution', sql: `SELECT toHour(ts) AS hour, count() AS count FROM messages GROUP BY hour ORDER BY hour` },
|
|
24
|
+
// Grouped by (session_id, user_id), matching the table's ORDER BY. In a team-pool
|
|
25
|
+
// house two members can ship the same adapter-local session id; grouping on
|
|
26
|
+
// session_id alone would merge them and make any(source) an arbitrary member's.
|
|
27
|
+
{ label: 'Full-text search', sql: `SELECT session_id, user_id, any(source) AS source, count() AS hits FROM messages WHERE text_ngram LIKE '%clickhouse%' GROUP BY session_id, user_id ORDER BY hits DESC LIMIT 10` },
|
|
28
|
+
]
|
|
29
|
+
const LEGACY_EXAMPLES = [
|
|
30
|
+
{ label: 'Sessions per editor', sql: `SELECT source, count() AS count FROM chats GROUP BY source ORDER BY count DESC` },
|
|
31
|
+
{ label: 'Top 10 projects', sql: `SELECT folder, count() AS sessions, sum(bubble_count) AS messages FROM chats WHERE folder IS NOT NULL GROUP BY folder ORDER BY sessions DESC LIMIT 10` },
|
|
32
|
+
{ label: 'Messages per day', sql: `SELECT toDate(toDateTime(intDiv(created_at, 1000))) AS day, count() AS count FROM chats WHERE created_at IS NOT NULL GROUP BY day ORDER BY day` },
|
|
33
|
+
{ label: 'Top models', sql: `SELECT model, count() AS count FROM messages WHERE model IS NOT NULL GROUP BY model ORDER BY count DESC LIMIT 10` },
|
|
34
|
+
{ label: 'Top tools', sql: `SELECT tool_name, count() AS count FROM tool_calls GROUP BY tool_name ORDER BY count DESC LIMIT 15` },
|
|
35
|
+
{ label: 'Token usage by editor', sql: `SELECT c.source AS source, sum(cs.total_input_tokens) AS input_tokens, sum(cs.total_output_tokens) AS output_tokens FROM chat_stats AS cs JOIN chats AS c ON c.id = cs.chat_id GROUP BY c.source ORDER BY input_tokens DESC` },
|
|
36
|
+
{ label: 'Sessions by mode', sql: `SELECT mode, count() AS count FROM chats WHERE mode IS NOT NULL GROUP BY mode ORDER BY count DESC` },
|
|
37
|
+
{ label: 'Hourly distribution', sql: `SELECT toHour(toDateTime(intDiv(created_at, 1000))) AS hour, count() AS count FROM chats WHERE created_at IS NOT NULL GROUP BY hour ORDER BY hour` },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
export default function SqlViewer() {
|
|
41
|
+
const { dark } = useTheme()
|
|
42
|
+
// Three states, and they must stay distinguishable: 'loading' while /api/schema is
|
|
43
|
+
// in flight, an array once the house's flavor is known, and null when the request
|
|
44
|
+
// finished without telling us. Guessing wrong offers queries against tables that do
|
|
45
|
+
// not exist, so nothing is offered until the answer is in — but a page that is
|
|
46
|
+
// merely still loading must not claim detection failed.
|
|
47
|
+
const [examples, setExamples] = useState('loading')
|
|
48
|
+
const [sql, setSql] = useState('')
|
|
49
|
+
const [result, setResult] = useState(null)
|
|
50
|
+
const [error, setError] = useState(null)
|
|
51
|
+
const [loading, setLoading] = useState(false)
|
|
52
|
+
const [schema, setSchema] = useState(null)
|
|
53
|
+
const [showSchema, setShowSchema] = useState(false)
|
|
54
|
+
const [chartType, setChartType] = useState('bar')
|
|
55
|
+
const [elapsed, setElapsed] = useState(null)
|
|
56
|
+
const textareaRef = useRef(null)
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
fetchSchema().then(s => {
|
|
60
|
+
setSchema(s)
|
|
61
|
+
// Three outcomes, not two. The typed house has the sessions_v rollup; the
|
|
62
|
+
// legacy one has chats. Anything else — an error object with no `tables`, a
|
|
63
|
+
// half-applied schema with neither marker — is indeterminate, and offering
|
|
64
|
+
// either set would hand the user queries that error.
|
|
65
|
+
const tables = s?.tables
|
|
66
|
+
const ex = !Array.isArray(tables) ? null
|
|
67
|
+
: tables.includes('sessions_v') ? TYPED_EXAMPLES
|
|
68
|
+
: tables.includes('chats') ? LEGACY_EXAMPLES
|
|
69
|
+
: null
|
|
70
|
+
setExamples(ex)
|
|
71
|
+
if (!ex) return
|
|
72
|
+
// Swap the prefilled query only if the user hasn't edited it yet.
|
|
73
|
+
setSql(prev => (prev === '' || prev === TYPED_EXAMPLES[0].sql || prev === LEGACY_EXAMPLES[0].sql) ? ex[0].sql : prev)
|
|
74
|
+
}).catch(() => setExamples(null))
|
|
75
|
+
}, [])
|
|
76
|
+
|
|
77
|
+
const runQuery = async () => {
|
|
78
|
+
// Guard here, not only on the button: ⌘+Enter reaches this directly, and an
|
|
79
|
+
// empty submit comes back as a bare "sql string required" from /api/query.
|
|
80
|
+
if (!sql.trim() || loading) return
|
|
81
|
+
setLoading(true)
|
|
82
|
+
setError(null)
|
|
83
|
+
setResult(null)
|
|
84
|
+
const t0 = performance.now()
|
|
85
|
+
try {
|
|
86
|
+
const data = await executeQuery(sql.trim())
|
|
87
|
+
setElapsed(((performance.now() - t0)).toFixed(0))
|
|
88
|
+
if (data.error) {
|
|
89
|
+
setError(data.error)
|
|
90
|
+
} else {
|
|
91
|
+
setResult(data)
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
setError(e.message)
|
|
95
|
+
setElapsed(null)
|
|
96
|
+
}
|
|
97
|
+
setLoading(false)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const handleKeyDown = (e) => {
|
|
101
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
102
|
+
e.preventDefault()
|
|
103
|
+
runQuery()
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const copyResults = () => {
|
|
108
|
+
if (!result) return
|
|
109
|
+
const header = result.columns.join('\t')
|
|
110
|
+
const rows = result.rows.map(r => result.columns.map(c => r[c] ?? '').join('\t'))
|
|
111
|
+
navigator.clipboard.writeText([header, ...rows].join('\n'))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const downloadCsv = () => {
|
|
115
|
+
if (!result) return
|
|
116
|
+
const escape = (v) => {
|
|
117
|
+
const s = String(v ?? '')
|
|
118
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s
|
|
119
|
+
}
|
|
120
|
+
const header = result.columns.map(escape).join(',')
|
|
121
|
+
const rows = result.rows.map(r => result.columns.map(c => escape(r[c])).join(','))
|
|
122
|
+
const blob = new Blob([header + '\n' + rows.join('\n')], { type: 'text/csv' })
|
|
123
|
+
const url = URL.createObjectURL(blob)
|
|
124
|
+
const a = document.createElement('a')
|
|
125
|
+
a.href = url
|
|
126
|
+
a.download = 'query-results.csv'
|
|
127
|
+
a.click()
|
|
128
|
+
URL.revokeObjectURL(url)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Auto-detect chartable data
|
|
132
|
+
const chartData = useMemo(() => {
|
|
133
|
+
if (!result || result.columns.length < 2 || result.rows.length === 0) return null
|
|
134
|
+
const cols = result.columns
|
|
135
|
+
// Find a label column (string-like) and value columns (numeric)
|
|
136
|
+
const labelCol = cols.find(c => result.rows.every(r => typeof r[c] === 'string' || r[c] === null)) || cols[0]
|
|
137
|
+
const valueCols = cols.filter(c => c !== labelCol && result.rows.some(r => typeof r[c] === 'number'))
|
|
138
|
+
if (valueCols.length === 0) return null
|
|
139
|
+
|
|
140
|
+
const labels = result.rows.map(r => String(r[labelCol] ?? ''))
|
|
141
|
+
const palette = ['#6366f1', '#f59e0b', '#06b6d4', '#10b981', '#f97316', '#ec4899', '#8b5cf6', '#3b82f6', '#ef4444', '#14b8a6']
|
|
142
|
+
|
|
143
|
+
const datasets = valueCols.map((col, i) => ({
|
|
144
|
+
label: col,
|
|
145
|
+
data: result.rows.map(r => r[col] ?? 0),
|
|
146
|
+
backgroundColor: chartType === 'doughnut'
|
|
147
|
+
? palette.slice(0, labels.length)
|
|
148
|
+
: palette[i % palette.length] + '99',
|
|
149
|
+
borderColor: palette[i % palette.length],
|
|
150
|
+
borderWidth: chartType === 'line' ? 2 : 1,
|
|
151
|
+
borderRadius: chartType === 'bar' ? 4 : 0,
|
|
152
|
+
tension: 0.3,
|
|
153
|
+
fill: chartType === 'line',
|
|
154
|
+
pointRadius: chartType === 'line' ? 3 : 0,
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
return { labels, datasets }
|
|
158
|
+
}, [result, chartType])
|
|
159
|
+
|
|
160
|
+
const chartOptions = {
|
|
161
|
+
responsive: true,
|
|
162
|
+
maintainAspectRatio: false,
|
|
163
|
+
plugins: {
|
|
164
|
+
legend: { display: chartData?.datasets?.length > 1 || chartType === 'doughnut', labels: { color: dark ? '#ccc' : '#555', font: { size: 11 } } },
|
|
165
|
+
tooltip: { backgroundColor: dark ? '#1e1e2e' : '#fff', titleColor: dark ? '#fff' : '#111', bodyColor: dark ? '#ccc' : '#555', borderColor: dark ? '#333' : '#ddd', borderWidth: 1 },
|
|
166
|
+
},
|
|
167
|
+
scales: chartType !== 'doughnut' ? {
|
|
168
|
+
x: { ticks: { color: dark ? '#888' : '#666', font: { size: 10 }, maxRotation: 45 }, grid: { color: dark ? '#ffffff08' : '#00000008' } },
|
|
169
|
+
y: { ticks: { color: dark ? '#888' : '#666', font: { size: 10 } }, grid: { color: dark ? '#ffffff08' : '#00000008' } },
|
|
170
|
+
} : undefined,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const txtStyle = { color: 'var(--c-text)' }
|
|
174
|
+
const txt2Style = { color: 'var(--c-text2)' }
|
|
175
|
+
const cardBg = { background: 'var(--c-card)', border: '1px solid var(--c-border)' }
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="fade-in space-y-3">
|
|
179
|
+
{/* Header */}
|
|
180
|
+
<PageHeader icon={Database} title="SQL Viewer">
|
|
181
|
+
<span className="text-[10px] px-1.5 py-0.5" style={{ color: 'var(--c-text3)', background: 'var(--c-bg3)' }}>cache.db</span>
|
|
182
|
+
<div className="ml-auto">
|
|
183
|
+
<button
|
|
184
|
+
onClick={() => setShowSchema(!showSchema)}
|
|
185
|
+
className="flex items-center gap-1 text-[11px] px-2 py-1 transition hover:bg-[var(--c-card)]"
|
|
186
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
187
|
+
>
|
|
188
|
+
<Table2 size={11} />
|
|
189
|
+
Schema
|
|
190
|
+
<ChevronDown size={10} className={`transition ${showSchema ? 'rotate-180' : ''}`} />
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</PageHeader>
|
|
194
|
+
|
|
195
|
+
{/* Schema panel */}
|
|
196
|
+
{showSchema && schema && (
|
|
197
|
+
<div className="rounded-lg p-3 space-y-2 text-[12px]" style={cardBg}>
|
|
198
|
+
<div className="flex flex-wrap gap-4">
|
|
199
|
+
{schema.tables.map(table => (
|
|
200
|
+
<div key={table} className="min-w-[180px]">
|
|
201
|
+
<div className="font-semibold mb-1" style={txtStyle}>{table}</div>
|
|
202
|
+
<div className="space-y-0.5">
|
|
203
|
+
{schema.schema[table]?.map(col => (
|
|
204
|
+
<div key={col.name} className="flex gap-2" style={txt2Style}>
|
|
205
|
+
<span style={txtStyle}>{col.name}</span>
|
|
206
|
+
<span className="text-[11px] opacity-60">{col.type}</span>
|
|
207
|
+
{col.pk ? <span className="text-[10px] px-1 rounded" style={{ background: '#6366f133', color: '#6366f1' }}>PK</span> : null}
|
|
208
|
+
</div>
|
|
209
|
+
))}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
))}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{/* Example queries — withheld until the schema flavor is known, since the
|
|
218
|
+
typed and legacy sets target entirely different tables. */}
|
|
219
|
+
<div className="flex flex-wrap gap-1">
|
|
220
|
+
{examples === 'loading' ? (
|
|
221
|
+
<span className="text-[11px]" style={txt2Style}>loading examples…</span>
|
|
222
|
+
) : examples === null ? (
|
|
223
|
+
<span className="text-[11px]" style={txt2Style}>
|
|
224
|
+
examples unavailable — could not determine the schema from /api/schema
|
|
225
|
+
</span>
|
|
226
|
+
) : examples.map((q, i) => (
|
|
227
|
+
<button
|
|
228
|
+
key={i}
|
|
229
|
+
onClick={() => setSql(q.sql)}
|
|
230
|
+
className="text-[11px] px-2 py-0.5 rounded transition hover:bg-[var(--c-card)]"
|
|
231
|
+
style={{ ...txt2Style, border: '1px solid var(--c-border)' }}
|
|
232
|
+
>
|
|
233
|
+
{q.label}
|
|
234
|
+
</button>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
{/* SQL editor */}
|
|
239
|
+
<div className="rounded-lg overflow-hidden" style={cardBg}>
|
|
240
|
+
<div className="flex items-center justify-between px-3 py-1.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
241
|
+
<span className="text-[11px] font-mono" style={txt2Style}>SQL</span>
|
|
242
|
+
<div className="flex items-center gap-1">
|
|
243
|
+
<span className="text-[11px]" style={txt2Style}>⌘+Enter to run</span>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<textarea
|
|
247
|
+
ref={textareaRef}
|
|
248
|
+
value={sql}
|
|
249
|
+
onChange={e => setSql(e.target.value)}
|
|
250
|
+
onKeyDown={handleKeyDown}
|
|
251
|
+
spellCheck={false}
|
|
252
|
+
className="w-full p-3 text-[12px] font-mono resize-y outline-none"
|
|
253
|
+
style={{ background: 'transparent', color: 'var(--c-text)', minHeight: 80, maxHeight: 300 }}
|
|
254
|
+
rows={4}
|
|
255
|
+
/>
|
|
256
|
+
<div className="flex items-center gap-2 px-3 py-1.5" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
257
|
+
<button
|
|
258
|
+
onClick={runQuery}
|
|
259
|
+
disabled={loading || !sql.trim()}
|
|
260
|
+
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded transition"
|
|
261
|
+
style={{ background: '#6366f1', color: '#fff', opacity: loading ? 0.5 : 1 }}
|
|
262
|
+
>
|
|
263
|
+
<Play size={11} />
|
|
264
|
+
{loading ? 'Running...' : 'Run Query'}
|
|
265
|
+
</button>
|
|
266
|
+
{elapsed && result && (
|
|
267
|
+
<span className="text-[11px]" style={txt2Style}>
|
|
268
|
+
{result.count} row{result.count !== 1 ? 's' : ''} in {elapsed}ms
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
271
|
+
{result && (
|
|
272
|
+
<div className="ml-auto flex items-center gap-1">
|
|
273
|
+
<button onClick={copyResults} className="p-1 rounded hover:bg-[var(--c-bg2)] transition" style={txt2Style} title="Copy to clipboard">
|
|
274
|
+
<Copy size={12} />
|
|
275
|
+
</button>
|
|
276
|
+
<button onClick={downloadCsv} className="p-1 rounded hover:bg-[var(--c-bg2)] transition" style={txt2Style} title="Download CSV">
|
|
277
|
+
<Download size={12} />
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
{/* Error */}
|
|
285
|
+
{error && (
|
|
286
|
+
<div className="rounded-lg px-3 py-2 text-[12px]" style={{ background: '#ef444420', border: '1px solid #ef444440', color: '#ef4444' }}>
|
|
287
|
+
{error}
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{/* Results table */}
|
|
292
|
+
{result && result.rows.length > 0 && (
|
|
293
|
+
<div className="rounded-lg overflow-hidden" style={cardBg}>
|
|
294
|
+
<div className="overflow-x-auto" style={{ maxHeight: 400 }}>
|
|
295
|
+
<table className="w-full text-[12px]" style={{ borderCollapse: 'collapse' }}>
|
|
296
|
+
<thead>
|
|
297
|
+
<tr style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
298
|
+
{result.columns.map(col => (
|
|
299
|
+
<th key={col} className="text-left px-3 py-1.5 font-semibold sticky top-0" style={{ ...txtStyle, background: 'var(--c-card)' }}>
|
|
300
|
+
{col}
|
|
301
|
+
</th>
|
|
302
|
+
))}
|
|
303
|
+
</tr>
|
|
304
|
+
</thead>
|
|
305
|
+
<tbody>
|
|
306
|
+
{result.rows.map((row, i) => (
|
|
307
|
+
<tr key={i} className="hover:bg-[var(--c-bg2)] transition" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
308
|
+
{result.columns.map(col => (
|
|
309
|
+
<td key={col} className="px-3 py-1 font-mono" style={txt2Style}>
|
|
310
|
+
{formatCell(row[col])}
|
|
311
|
+
</td>
|
|
312
|
+
))}
|
|
313
|
+
</tr>
|
|
314
|
+
))}
|
|
315
|
+
</tbody>
|
|
316
|
+
</table>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{result && result.rows.length === 0 && (
|
|
322
|
+
<div className="rounded-lg px-3 py-6 text-center text-[12px]" style={{ ...cardBg, ...txt2Style }}>
|
|
323
|
+
Query returned 0 rows
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
{/* Chart visualization */}
|
|
328
|
+
{chartData && (
|
|
329
|
+
<div className="rounded-lg p-4" style={cardBg}>
|
|
330
|
+
<div className="flex items-center gap-2 mb-3">
|
|
331
|
+
<span className="text-[12px] font-semibold" style={txtStyle}>Visualization</span>
|
|
332
|
+
<div className="flex gap-0.5 ml-2" style={{ border: '1px solid var(--c-border)', borderRadius: 6 }}>
|
|
333
|
+
{[
|
|
334
|
+
{ type: 'bar', icon: BarChart3 },
|
|
335
|
+
{ type: 'line', icon: LineChart },
|
|
336
|
+
{ type: 'doughnut', icon: PieChart },
|
|
337
|
+
].map(({ type, icon: Icon }) => (
|
|
338
|
+
<button
|
|
339
|
+
key={type}
|
|
340
|
+
onClick={() => setChartType(type)}
|
|
341
|
+
className="p-1.5 transition"
|
|
342
|
+
style={{
|
|
343
|
+
background: chartType === type ? 'var(--c-bg2)' : 'transparent',
|
|
344
|
+
color: chartType === type ? 'var(--c-white)' : 'var(--c-text3)',
|
|
345
|
+
borderRadius: 4,
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<Icon size={12} />
|
|
349
|
+
</button>
|
|
350
|
+
))}
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
<div style={{ height: chartType === 'doughnut' ? 280 : 260 }}>
|
|
354
|
+
{chartType === 'bar' && <Bar data={chartData} options={chartOptions} />}
|
|
355
|
+
{chartType === 'line' && <Line data={chartData} options={chartOptions} />}
|
|
356
|
+
{chartType === 'doughnut' && <Doughnut data={chartData} options={chartOptions} />}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formatCell(value) {
|
|
365
|
+
if (value === null || value === undefined) return <span className="opacity-30">NULL</span>
|
|
366
|
+
if (typeof value === 'number') return value.toLocaleString()
|
|
367
|
+
const str = String(value)
|
|
368
|
+
if (str.length > 120) return str.substring(0, 120) + '…'
|
|
369
|
+
return str
|
|
370
|
+
}
|