agentlytics 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/editors/copilot.js +174 -0
- package/editors/gemini.js +174 -0
- package/editors/index.js +3 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/server.js +36 -0
- package/ui/src/App.jsx +4 -1
- package/ui/src/components/ActivityHeatmap.jsx +2 -2
- package/ui/src/lib/api.js +14 -0
- package/ui/src/lib/constants.js +4 -0
- package/ui/src/pages/Dashboard.jsx +1 -1
- package/ui/src/pages/SqlViewer.jsx +322 -0
package/README.md
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<strong>Unified analytics for your AI coding agents</strong><br>
|
|
9
|
-
<sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode</sub>
|
|
9
|
+
<sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI</sub>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<a href="https://www.npmjs.com/package/agentlytics"><img src="https://img.shields.io/npm/v/agentlytics?color=6366f1&label=npm" alt="npm"></a>
|
|
14
|
-
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-
|
|
14
|
+
<a href="#supported-editors"><img src="https://img.shields.io/badge/editors-11-818cf8" alt="editors"></a>
|
|
15
15
|
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
|
|
16
16
|
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A518-brightgreen" alt="node"></a>
|
|
17
17
|
</p>
|
|
@@ -54,6 +54,8 @@ Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
|
|
|
54
54
|
| **VS Code Insiders** | `vscode-insiders` | ✅ | ✅ | ✅ | ✅ |
|
|
55
55
|
| **Zed** | `zed` | ✅ | ✅ | ✅ | ❌ |
|
|
56
56
|
| **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
|
|
57
|
+
| **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
|
|
58
|
+
| **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
|
|
57
59
|
|
|
58
60
|
> Windsurf, Windsurf Next, and Antigravity must be running during scan.
|
|
59
61
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const COPILOT_DIR = path.join(os.homedir(), '.copilot');
|
|
6
|
+
const SESSION_STATE_DIR = path.join(COPILOT_DIR, 'session-state');
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// Adapter interface
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
const name = 'copilot-cli';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse workspace.yaml from a session directory.
|
|
16
|
+
* Fields: id, cwd, git_root, repository, branch, summary, created_at, updated_at
|
|
17
|
+
*/
|
|
18
|
+
function parseWorkspace(sessionDir) {
|
|
19
|
+
const yamlPath = path.join(sessionDir, 'workspace.yaml');
|
|
20
|
+
if (!fs.existsSync(yamlPath)) return null;
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(yamlPath, 'utf-8');
|
|
23
|
+
// Simple YAML parsing — handle key: value lines
|
|
24
|
+
const meta = {};
|
|
25
|
+
for (const line of raw.split('\n')) {
|
|
26
|
+
const match = line.match(/^(\w+):\s*(.*)$/);
|
|
27
|
+
if (match) meta[match[1]] = match[2].trim();
|
|
28
|
+
}
|
|
29
|
+
return meta;
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse events.jsonl and extract user/assistant messages.
|
|
35
|
+
* Event types: session.start, user.message, assistant.message,
|
|
36
|
+
* assistant.turn_start, assistant.turn_end, session.shutdown
|
|
37
|
+
*/
|
|
38
|
+
function parseEvents(sessionDir) {
|
|
39
|
+
const eventsPath = path.join(sessionDir, 'events.jsonl');
|
|
40
|
+
if (!fs.existsSync(eventsPath)) return [];
|
|
41
|
+
try {
|
|
42
|
+
const raw = fs.readFileSync(eventsPath, 'utf-8');
|
|
43
|
+
return raw.split('\n').filter(Boolean).map(line => {
|
|
44
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
45
|
+
}).filter(Boolean);
|
|
46
|
+
} catch { return []; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getChats() {
|
|
50
|
+
const chats = [];
|
|
51
|
+
if (!fs.existsSync(SESSION_STATE_DIR)) return chats;
|
|
52
|
+
|
|
53
|
+
let sessionDirs;
|
|
54
|
+
try { sessionDirs = fs.readdirSync(SESSION_STATE_DIR); } catch { return chats; }
|
|
55
|
+
|
|
56
|
+
for (const dirName of sessionDirs) {
|
|
57
|
+
const sessionDir = path.join(SESSION_STATE_DIR, dirName);
|
|
58
|
+
try { if (!fs.statSync(sessionDir).isDirectory()) continue; } catch { continue; }
|
|
59
|
+
|
|
60
|
+
const meta = parseWorkspace(sessionDir);
|
|
61
|
+
if (!meta) continue;
|
|
62
|
+
|
|
63
|
+
const events = parseEvents(sessionDir);
|
|
64
|
+
const userMessages = events.filter(e => e.type === 'user.message');
|
|
65
|
+
const assistantMessages = events.filter(e => e.type === 'assistant.message');
|
|
66
|
+
const firstUser = userMessages[0];
|
|
67
|
+
|
|
68
|
+
// Count meaningful messages (user + assistant)
|
|
69
|
+
const bubbleCount = userMessages.length + assistantMessages.length;
|
|
70
|
+
if (bubbleCount === 0) continue;
|
|
71
|
+
|
|
72
|
+
// Extract model from shutdown event or assistant messages
|
|
73
|
+
const shutdown = events.find(e => e.type === 'session.shutdown');
|
|
74
|
+
const model = shutdown?.data?.currentModel || null;
|
|
75
|
+
|
|
76
|
+
chats.push({
|
|
77
|
+
source: 'copilot-cli',
|
|
78
|
+
composerId: meta.id || dirName,
|
|
79
|
+
name: meta.summary || cleanPrompt(firstUser?.data?.content),
|
|
80
|
+
createdAt: meta.created_at ? new Date(meta.created_at).getTime() : null,
|
|
81
|
+
lastUpdatedAt: meta.updated_at ? new Date(meta.updated_at).getTime() : null,
|
|
82
|
+
mode: 'copilot',
|
|
83
|
+
folder: meta.cwd || meta.git_root || null,
|
|
84
|
+
encrypted: false,
|
|
85
|
+
bubbleCount,
|
|
86
|
+
_sessionDir: sessionDir,
|
|
87
|
+
_model: model,
|
|
88
|
+
_shutdownData: shutdown?.data || null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return chats;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cleanPrompt(text) {
|
|
96
|
+
if (!text) return null;
|
|
97
|
+
return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getMessages(chat) {
|
|
101
|
+
const sessionDir = chat._sessionDir;
|
|
102
|
+
if (!sessionDir || !fs.existsSync(sessionDir)) return [];
|
|
103
|
+
|
|
104
|
+
const events = parseEvents(sessionDir);
|
|
105
|
+
const result = [];
|
|
106
|
+
|
|
107
|
+
// Aggregate token usage from shutdown event's modelMetrics
|
|
108
|
+
const shutdown = events.find(e => e.type === 'session.shutdown');
|
|
109
|
+
const modelMetrics = shutdown?.data?.modelMetrics || {};
|
|
110
|
+
|
|
111
|
+
// Build total token counts from model metrics
|
|
112
|
+
let totalInput = 0, totalOutput = 0, totalCacheRead = 0;
|
|
113
|
+
for (const metrics of Object.values(modelMetrics)) {
|
|
114
|
+
const u = metrics.usage || {};
|
|
115
|
+
totalInput += u.inputTokens || 0;
|
|
116
|
+
totalOutput += u.outputTokens || 0;
|
|
117
|
+
totalCacheRead += u.cacheReadTokens || 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const event of events) {
|
|
121
|
+
if (event.type === 'user.message') {
|
|
122
|
+
const content = event.data?.content;
|
|
123
|
+
if (content) result.push({ role: 'user', content });
|
|
124
|
+
|
|
125
|
+
} else if (event.type === 'assistant.message') {
|
|
126
|
+
const data = event.data || {};
|
|
127
|
+
const parts = [];
|
|
128
|
+
const toolCalls = [];
|
|
129
|
+
|
|
130
|
+
// Main text content
|
|
131
|
+
if (data.content) parts.push(data.content);
|
|
132
|
+
|
|
133
|
+
// Tool requests
|
|
134
|
+
if (data.toolRequests && Array.isArray(data.toolRequests)) {
|
|
135
|
+
for (const tr of data.toolRequests) {
|
|
136
|
+
const tcName = tr.name || tr.toolName || 'unknown';
|
|
137
|
+
const args = tr.args || tr.arguments || tr.input || {};
|
|
138
|
+
const parsedArgs = typeof args === 'string' ? safeParse(args) : args;
|
|
139
|
+
const argKeys = typeof parsedArgs === 'object' ? Object.keys(parsedArgs).join(', ') : '';
|
|
140
|
+
parts.push(`[tool-call: ${tcName}(${argKeys})]`);
|
|
141
|
+
toolCalls.push({ name: tcName, args: parsedArgs });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (parts.length > 0) {
|
|
146
|
+
result.push({
|
|
147
|
+
role: 'assistant',
|
|
148
|
+
content: parts.join('\n'),
|
|
149
|
+
_model: shutdown?.data?.currentModel || chat._model,
|
|
150
|
+
_outputTokens: data.outputTokens,
|
|
151
|
+
_toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Attach aggregate token info to the first assistant message if available
|
|
158
|
+
if (result.length > 0 && totalInput > 0) {
|
|
159
|
+
const firstAssistant = result.find(m => m.role === 'assistant');
|
|
160
|
+
if (firstAssistant) {
|
|
161
|
+
firstAssistant._inputTokens = totalInput;
|
|
162
|
+
if (!firstAssistant._outputTokens) firstAssistant._outputTokens = totalOutput;
|
|
163
|
+
if (totalCacheRead) firstAssistant._cacheRead = totalCacheRead;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function safeParse(str) {
|
|
171
|
+
try { return JSON.parse(str); } catch { return {}; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { name, getChats, getMessages };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const GEMINI_DIR = path.join(os.homedir(), '.gemini');
|
|
6
|
+
const TMP_DIR = path.join(GEMINI_DIR, 'tmp');
|
|
7
|
+
const PROJECTS_JSON = path.join(GEMINI_DIR, 'projects.json');
|
|
8
|
+
|
|
9
|
+
// ============================================================
|
|
10
|
+
// Adapter interface
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
const name = 'gemini-cli';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load project path mapping from ~/.gemini/projects.json
|
|
17
|
+
* Format: { "projects": { "/Users/dev/Code/myapp": "myapp" } }
|
|
18
|
+
* Returns Map<projectName, folderPath>
|
|
19
|
+
*/
|
|
20
|
+
function loadProjectMap() {
|
|
21
|
+
const map = new Map();
|
|
22
|
+
try {
|
|
23
|
+
const data = JSON.parse(fs.readFileSync(PROJECTS_JSON, 'utf-8'));
|
|
24
|
+
if (data.projects) {
|
|
25
|
+
for (const [folderPath, projName] of Object.entries(data.projects)) {
|
|
26
|
+
map.set(projName, folderPath);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {}
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getChats() {
|
|
34
|
+
const chats = [];
|
|
35
|
+
if (!fs.existsSync(TMP_DIR)) return chats;
|
|
36
|
+
|
|
37
|
+
const projectMap = loadProjectMap();
|
|
38
|
+
|
|
39
|
+
// Each subdirectory under tmp/ is a project name (e.g. "codename-share")
|
|
40
|
+
let projectDirs;
|
|
41
|
+
try { projectDirs = fs.readdirSync(TMP_DIR); } catch { return chats; }
|
|
42
|
+
|
|
43
|
+
for (const projName of projectDirs) {
|
|
44
|
+
const projDir = path.join(TMP_DIR, projName);
|
|
45
|
+
try { if (!fs.statSync(projDir).isDirectory()) continue; } catch { continue; }
|
|
46
|
+
|
|
47
|
+
// Sessions are in <projDir>/chats/session-*.json
|
|
48
|
+
const chatsDir = path.join(projDir, 'chats');
|
|
49
|
+
if (!fs.existsSync(chatsDir)) continue;
|
|
50
|
+
|
|
51
|
+
let files;
|
|
52
|
+
try {
|
|
53
|
+
files = fs.readdirSync(chatsDir).filter(f => f.startsWith('session-') && f.endsWith('.json'));
|
|
54
|
+
} catch { continue; }
|
|
55
|
+
|
|
56
|
+
// Resolve folder from projects.json mapping
|
|
57
|
+
const folder = projectMap.get(projName) || null;
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const fullPath = path.join(chatsDir, file);
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(fullPath, 'utf-8');
|
|
63
|
+
const record = JSON.parse(raw);
|
|
64
|
+
if (!record || !record.messages) continue;
|
|
65
|
+
|
|
66
|
+
const sessionId = record.sessionId || file.replace('.json', '');
|
|
67
|
+
const messages = record.messages || [];
|
|
68
|
+
|
|
69
|
+
// Extract first user prompt for title
|
|
70
|
+
const firstUser = messages.find(m => m.type === 'user');
|
|
71
|
+
const firstPrompt = extractTextContent(firstUser?.content);
|
|
72
|
+
|
|
73
|
+
chats.push({
|
|
74
|
+
source: 'gemini-cli',
|
|
75
|
+
composerId: sessionId,
|
|
76
|
+
name: firstPrompt ? cleanPrompt(firstPrompt) : null,
|
|
77
|
+
createdAt: record.startTime ? new Date(record.startTime).getTime() : null,
|
|
78
|
+
lastUpdatedAt: record.lastUpdated ? new Date(record.lastUpdated).getTime() : null,
|
|
79
|
+
mode: 'gemini',
|
|
80
|
+
folder,
|
|
81
|
+
encrypted: false,
|
|
82
|
+
bubbleCount: messages.length,
|
|
83
|
+
_fullPath: fullPath,
|
|
84
|
+
});
|
|
85
|
+
} catch { /* skip malformed files */ }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return chats;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function cleanPrompt(text) {
|
|
93
|
+
if (!text) return null;
|
|
94
|
+
return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractTextContent(content) {
|
|
98
|
+
if (!content) return '';
|
|
99
|
+
if (typeof content === 'string') return content;
|
|
100
|
+
// Array of { text } parts (user messages)
|
|
101
|
+
if (Array.isArray(content)) {
|
|
102
|
+
return content
|
|
103
|
+
.filter(p => p.text)
|
|
104
|
+
.map(p => p.text)
|
|
105
|
+
.join('\n') || '';
|
|
106
|
+
}
|
|
107
|
+
if (content.text) return content.text;
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getMessages(chat) {
|
|
112
|
+
const filePath = chat._fullPath;
|
|
113
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
114
|
+
|
|
115
|
+
let record;
|
|
116
|
+
try {
|
|
117
|
+
record = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
118
|
+
} catch { return []; }
|
|
119
|
+
|
|
120
|
+
if (!record || !record.messages) return [];
|
|
121
|
+
|
|
122
|
+
const result = [];
|
|
123
|
+
for (const msg of record.messages) {
|
|
124
|
+
const type = msg.type;
|
|
125
|
+
const text = extractTextContent(msg.content || msg.displayContent);
|
|
126
|
+
|
|
127
|
+
if (type === 'user') {
|
|
128
|
+
if (text) result.push({ role: 'user', content: text });
|
|
129
|
+
} else if (type === 'gemini') {
|
|
130
|
+
const parts = [];
|
|
131
|
+
const toolCalls = [];
|
|
132
|
+
|
|
133
|
+
// Thoughts have { subject, description, timestamp }
|
|
134
|
+
if (msg.thoughts && Array.isArray(msg.thoughts)) {
|
|
135
|
+
for (const t of msg.thoughts) {
|
|
136
|
+
const thought = t.description || t.subject || '';
|
|
137
|
+
if (thought) parts.push(`[thinking] ${thought}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Main text content (string for gemini messages)
|
|
142
|
+
if (text) parts.push(text);
|
|
143
|
+
|
|
144
|
+
// Tool calls
|
|
145
|
+
if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
|
|
146
|
+
for (const tc of msg.toolCalls) {
|
|
147
|
+
const args = tc.args || {};
|
|
148
|
+
const argKeys = typeof args === 'object' ? Object.keys(args).join(', ') : '';
|
|
149
|
+
parts.push(`[tool-call: ${tc.name || 'unknown'}(${argKeys})]`);
|
|
150
|
+
toolCalls.push({ name: tc.name || 'unknown', args });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (parts.length > 0) {
|
|
155
|
+
const tokens = msg.tokens || {};
|
|
156
|
+
result.push({
|
|
157
|
+
role: 'assistant',
|
|
158
|
+
content: parts.join('\n'),
|
|
159
|
+
_model: msg.model,
|
|
160
|
+
_inputTokens: tokens.input,
|
|
161
|
+
_outputTokens: tokens.output,
|
|
162
|
+
_cacheRead: tokens.cached,
|
|
163
|
+
_toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
} else if (type === 'info' || type === 'error' || type === 'warning') {
|
|
167
|
+
if (text) result.push({ role: 'system', content: `[${type}] ${text}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { name, getChats, getMessages };
|
package/editors/index.js
CHANGED
|
@@ -4,8 +4,10 @@ const claude = require('./claude');
|
|
|
4
4
|
const vscode = require('./vscode');
|
|
5
5
|
const zed = require('./zed');
|
|
6
6
|
const opencode = require('./opencode');
|
|
7
|
+
const gemini = require('./gemini');
|
|
8
|
+
const copilot = require('./copilot');
|
|
7
9
|
|
|
8
|
-
const editors = [cursor, windsurf, claude, vscode, zed, opencode];
|
|
10
|
+
const editors = [cursor, windsurf, claude, vscode, zed, opencode, gemini, copilot];
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Get all chats from all editor adapters, sorted by most recent first.
|
package/index.js
CHANGED
|
@@ -94,7 +94,7 @@ console.log(chalk.dim(' Initializing cache database...'));
|
|
|
94
94
|
cache.initDb();
|
|
95
95
|
|
|
96
96
|
// Scan all editors and populate cache
|
|
97
|
-
console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode'));
|
|
97
|
+
console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode, Gemini CLI, Copilot CLI'));
|
|
98
98
|
const startTime = Date.now();
|
|
99
99
|
const result = cache.scanAll((progress) => {
|
|
100
100
|
process.stdout.write(chalk.dim(`\r Scanning: ${progress.scanned}/${progress.total} chats (${progress.analyzed} analyzed, ${progress.skipped} cached)`));
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -112,6 +112,42 @@ app.get('/api/tool-calls', (req, res) => {
|
|
|
112
112
|
}
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
app.post('/api/query', (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const { sql } = req.body;
|
|
118
|
+
if (!sql || typeof sql !== 'string') return res.status(400).json({ error: 'sql string required' });
|
|
119
|
+
// Only allow SELECT / PRAGMA / EXPLAIN / WITH statements
|
|
120
|
+
const trimmed = sql.trim().replace(/^--.*$/gm, '').trim();
|
|
121
|
+
const first = trimmed.split(/\s+/)[0].toUpperCase();
|
|
122
|
+
if (!['SELECT', 'PRAGMA', 'EXPLAIN', 'WITH'].includes(first)) {
|
|
123
|
+
return res.status(403).json({ error: 'Only SELECT queries are allowed' });
|
|
124
|
+
}
|
|
125
|
+
const db = cache.getDb();
|
|
126
|
+
if (!db) return res.status(500).json({ error: 'Database not initialized' });
|
|
127
|
+
const stmt = db.prepare(sql);
|
|
128
|
+
const rows = stmt.all();
|
|
129
|
+
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
130
|
+
res.json({ columns, rows, count: rows.length });
|
|
131
|
+
} catch (err) {
|
|
132
|
+
res.status(400).json({ error: err.message });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
app.get('/api/schema', (req, res) => {
|
|
137
|
+
try {
|
|
138
|
+
const db = cache.getDb();
|
|
139
|
+
if (!db) return res.status(500).json({ error: 'Database not initialized' });
|
|
140
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
|
|
141
|
+
const schema = {};
|
|
142
|
+
for (const { name } of tables) {
|
|
143
|
+
schema[name] = db.prepare(`PRAGMA table_info(${name})`).all();
|
|
144
|
+
}
|
|
145
|
+
res.json({ tables: tables.map(t => t.name), schema });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
res.status(500).json({ error: err.message });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
115
151
|
app.get('/api/refetch', async (req, res) => {
|
|
116
152
|
res.writeHead(200, {
|
|
117
153
|
'Content-Type': 'text/event-stream',
|
package/ui/src/App.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react'
|
|
2
2
|
import { Routes, Route, NavLink } from 'react-router-dom'
|
|
3
|
-
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal } from 'lucide-react'
|
|
3
|
+
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database } from 'lucide-react'
|
|
4
4
|
import { fetchOverview, refetchAgents } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
6
|
import Dashboard from './pages/Dashboard'
|
|
@@ -9,6 +9,7 @@ import DeepAnalysis from './pages/DeepAnalysis'
|
|
|
9
9
|
import Compare from './pages/Compare'
|
|
10
10
|
import ChatDetail from './pages/ChatDetail'
|
|
11
11
|
import Projects from './pages/Projects'
|
|
12
|
+
import SqlViewer from './pages/SqlViewer'
|
|
12
13
|
|
|
13
14
|
export default function App() {
|
|
14
15
|
const [overview, setOverview] = useState(null)
|
|
@@ -35,6 +36,7 @@ export default function App() {
|
|
|
35
36
|
{ to: '/sessions', icon: MessageSquare, label: 'Sessions' },
|
|
36
37
|
{ to: '/analysis', icon: BarChart3, label: 'Analysis' },
|
|
37
38
|
{ to: '/compare', icon: GitCompare, label: 'Compare' },
|
|
39
|
+
{ to: '/sql', icon: Database, label: 'SQL' },
|
|
38
40
|
]
|
|
39
41
|
|
|
40
42
|
return (
|
|
@@ -100,6 +102,7 @@ export default function App() {
|
|
|
100
102
|
<Route path="/sessions/:id" element={<ChatDetail />} />
|
|
101
103
|
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
102
104
|
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
105
|
+
<Route path="/sql" element={<SqlViewer />} />
|
|
103
106
|
</Routes>
|
|
104
107
|
</main>
|
|
105
108
|
|
|
@@ -38,11 +38,11 @@ export default function ActivityHeatmap({ dailyData }) {
|
|
|
38
38
|
if (d.total > maxCount) maxCount = d.total
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
// End on
|
|
41
|
+
// End on Saturday of the current week, start WEEK_COLS-1 weeks before
|
|
42
42
|
const today = new Date()
|
|
43
43
|
today.setHours(0, 0, 0, 0)
|
|
44
44
|
const start = new Date(today)
|
|
45
|
-
start.setDate(start.getDate() - (
|
|
45
|
+
start.setDate(start.getDate() - start.getDay() - (WEEK_COLS - 1) * 7)
|
|
46
46
|
|
|
47
47
|
const weeks = []
|
|
48
48
|
const months = []
|
package/ui/src/lib/api.js
CHANGED
|
@@ -69,6 +69,20 @@ export async function fetchDashboardStats(params = {}) {
|
|
|
69
69
|
return res.json();
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
export async function executeQuery(sql) {
|
|
73
|
+
const res = await fetch(`${BASE}/api/query`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ sql }),
|
|
77
|
+
});
|
|
78
|
+
return res.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function fetchSchema() {
|
|
82
|
+
const res = await fetch(`${BASE}/api/schema`);
|
|
83
|
+
return res.json();
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
export async function fetchToolCalls(name, opts = {}) {
|
|
73
87
|
const q = new URLSearchParams({ name });
|
|
74
88
|
if (opts.limit) q.set('limit', opts.limit);
|
package/ui/src/lib/constants.js
CHANGED
|
@@ -9,6 +9,8 @@ export const EDITOR_COLORS = {
|
|
|
9
9
|
'vscode-insiders': '#60a5fa',
|
|
10
10
|
'zed': '#10b981',
|
|
11
11
|
'opencode': '#ec4899',
|
|
12
|
+
'gemini-cli': '#4285f4',
|
|
13
|
+
'copilot-cli': '#8957e5',
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
export const EDITOR_LABELS = {
|
|
@@ -22,6 +24,8 @@ export const EDITOR_LABELS = {
|
|
|
22
24
|
'vscode-insiders': 'VS Code Insiders',
|
|
23
25
|
'zed': 'Zed',
|
|
24
26
|
'opencode': 'OpenCode',
|
|
27
|
+
'gemini-cli': 'Gemini CLI',
|
|
28
|
+
'copilot-cli': 'Copilot CLI',
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
export function editorColor(src) {
|
|
@@ -226,7 +226,7 @@ export default function Dashboard({ overview }) {
|
|
|
226
226
|
|
|
227
227
|
{/* Activity Heatmap */}
|
|
228
228
|
<div className="card p-3">
|
|
229
|
-
<SectionTitle>activity</SectionTitle>
|
|
229
|
+
<SectionTitle>agentic coding activity</SectionTitle>
|
|
230
230
|
{dailyData ? <ActivityHeatmap dailyData={dailyData} /> : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>loading...</div>}
|
|
231
231
|
</div>
|
|
232
232
|
|
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
|
|
8
|
+
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
|
|
9
|
+
|
|
10
|
+
const EXAMPLE_QUERIES = [
|
|
11
|
+
{ label: 'Sessions per editor', sql: `SELECT source, COUNT(*) as count FROM chats GROUP BY source ORDER BY count DESC` },
|
|
12
|
+
{ 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` },
|
|
13
|
+
{ label: 'Messages per day', sql: `SELECT date(created_at/1000, 'unixepoch') as day, COUNT(*) as count FROM chats WHERE created_at IS NOT NULL GROUP BY day ORDER BY day` },
|
|
14
|
+
{ 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` },
|
|
15
|
+
{ label: 'Top tools', sql: `SELECT tool_name, COUNT(*) as count FROM tool_calls GROUP BY tool_name ORDER BY count DESC LIMIT 15` },
|
|
16
|
+
{ label: 'Token usage by editor', sql: `SELECT c.source, SUM(cs.total_input_tokens) as input_tokens, SUM(cs.total_output_tokens) as output_tokens FROM chat_stats cs JOIN chats c ON c.id = cs.chat_id GROUP BY c.source ORDER BY input_tokens DESC` },
|
|
17
|
+
{ label: 'Sessions by mode', sql: `SELECT mode, COUNT(*) as count FROM chats WHERE mode IS NOT NULL GROUP BY mode ORDER BY count DESC` },
|
|
18
|
+
{ label: 'Hourly distribution', sql: `SELECT CAST(strftime('%H', created_at/1000, 'unixepoch') AS INTEGER) as hour, COUNT(*) as count FROM chats WHERE created_at IS NOT NULL GROUP BY hour ORDER BY hour` },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export default function SqlViewer() {
|
|
22
|
+
const { dark } = useTheme()
|
|
23
|
+
const [sql, setSql] = useState(EXAMPLE_QUERIES[0].sql)
|
|
24
|
+
const [result, setResult] = useState(null)
|
|
25
|
+
const [error, setError] = useState(null)
|
|
26
|
+
const [loading, setLoading] = useState(false)
|
|
27
|
+
const [schema, setSchema] = useState(null)
|
|
28
|
+
const [showSchema, setShowSchema] = useState(false)
|
|
29
|
+
const [chartType, setChartType] = useState('bar')
|
|
30
|
+
const [elapsed, setElapsed] = useState(null)
|
|
31
|
+
const textareaRef = useRef(null)
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
fetchSchema().then(setSchema).catch(() => {})
|
|
35
|
+
}, [])
|
|
36
|
+
|
|
37
|
+
const runQuery = async () => {
|
|
38
|
+
setLoading(true)
|
|
39
|
+
setError(null)
|
|
40
|
+
setResult(null)
|
|
41
|
+
const t0 = performance.now()
|
|
42
|
+
try {
|
|
43
|
+
const data = await executeQuery(sql.trim())
|
|
44
|
+
setElapsed(((performance.now() - t0)).toFixed(0))
|
|
45
|
+
if (data.error) {
|
|
46
|
+
setError(data.error)
|
|
47
|
+
} else {
|
|
48
|
+
setResult(data)
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
setError(e.message)
|
|
52
|
+
setElapsed(null)
|
|
53
|
+
}
|
|
54
|
+
setLoading(false)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const handleKeyDown = (e) => {
|
|
58
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
runQuery()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const copyResults = () => {
|
|
65
|
+
if (!result) return
|
|
66
|
+
const header = result.columns.join('\t')
|
|
67
|
+
const rows = result.rows.map(r => result.columns.map(c => r[c] ?? '').join('\t'))
|
|
68
|
+
navigator.clipboard.writeText([header, ...rows].join('\n'))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const downloadCsv = () => {
|
|
72
|
+
if (!result) return
|
|
73
|
+
const escape = (v) => {
|
|
74
|
+
const s = String(v ?? '')
|
|
75
|
+
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s
|
|
76
|
+
}
|
|
77
|
+
const header = result.columns.map(escape).join(',')
|
|
78
|
+
const rows = result.rows.map(r => result.columns.map(c => escape(r[c])).join(','))
|
|
79
|
+
const blob = new Blob([header + '\n' + rows.join('\n')], { type: 'text/csv' })
|
|
80
|
+
const url = URL.createObjectURL(blob)
|
|
81
|
+
const a = document.createElement('a')
|
|
82
|
+
a.href = url
|
|
83
|
+
a.download = 'query-results.csv'
|
|
84
|
+
a.click()
|
|
85
|
+
URL.revokeObjectURL(url)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Auto-detect chartable data
|
|
89
|
+
const chartData = useMemo(() => {
|
|
90
|
+
if (!result || result.columns.length < 2 || result.rows.length === 0) return null
|
|
91
|
+
const cols = result.columns
|
|
92
|
+
// Find a label column (string-like) and value columns (numeric)
|
|
93
|
+
const labelCol = cols.find(c => result.rows.every(r => typeof r[c] === 'string' || r[c] === null)) || cols[0]
|
|
94
|
+
const valueCols = cols.filter(c => c !== labelCol && result.rows.some(r => typeof r[c] === 'number'))
|
|
95
|
+
if (valueCols.length === 0) return null
|
|
96
|
+
|
|
97
|
+
const labels = result.rows.map(r => String(r[labelCol] ?? ''))
|
|
98
|
+
const palette = ['#6366f1', '#f59e0b', '#06b6d4', '#10b981', '#f97316', '#ec4899', '#8b5cf6', '#3b82f6', '#ef4444', '#14b8a6']
|
|
99
|
+
|
|
100
|
+
const datasets = valueCols.map((col, i) => ({
|
|
101
|
+
label: col,
|
|
102
|
+
data: result.rows.map(r => r[col] ?? 0),
|
|
103
|
+
backgroundColor: chartType === 'doughnut'
|
|
104
|
+
? palette.slice(0, labels.length)
|
|
105
|
+
: palette[i % palette.length] + '99',
|
|
106
|
+
borderColor: palette[i % palette.length],
|
|
107
|
+
borderWidth: chartType === 'line' ? 2 : 1,
|
|
108
|
+
borderRadius: chartType === 'bar' ? 4 : 0,
|
|
109
|
+
tension: 0.3,
|
|
110
|
+
fill: chartType === 'line',
|
|
111
|
+
pointRadius: chartType === 'line' ? 3 : 0,
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
return { labels, datasets }
|
|
115
|
+
}, [result, chartType])
|
|
116
|
+
|
|
117
|
+
const chartOptions = {
|
|
118
|
+
responsive: true,
|
|
119
|
+
maintainAspectRatio: false,
|
|
120
|
+
plugins: {
|
|
121
|
+
legend: { display: chartData?.datasets?.length > 1 || chartType === 'doughnut', labels: { color: dark ? '#ccc' : '#555', font: { size: 11 } } },
|
|
122
|
+
tooltip: { backgroundColor: dark ? '#1e1e2e' : '#fff', titleColor: dark ? '#fff' : '#111', bodyColor: dark ? '#ccc' : '#555', borderColor: dark ? '#333' : '#ddd', borderWidth: 1 },
|
|
123
|
+
},
|
|
124
|
+
scales: chartType !== 'doughnut' ? {
|
|
125
|
+
x: { ticks: { color: dark ? '#888' : '#666', font: { size: 10 }, maxRotation: 45 }, grid: { color: dark ? '#ffffff08' : '#00000008' } },
|
|
126
|
+
y: { ticks: { color: dark ? '#888' : '#666', font: { size: 10 } }, grid: { color: dark ? '#ffffff08' : '#00000008' } },
|
|
127
|
+
} : undefined,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const txtStyle = { color: 'var(--c-text)' }
|
|
131
|
+
const txt2Style = { color: 'var(--c-text2)' }
|
|
132
|
+
const cardBg = { background: 'var(--c-card)', border: '1px solid var(--c-border)' }
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="space-y-3">
|
|
136
|
+
{/* Header */}
|
|
137
|
+
<div className="flex items-center justify-between">
|
|
138
|
+
<div className="flex items-center gap-2">
|
|
139
|
+
<Database size={16} style={txtStyle} />
|
|
140
|
+
<h1 className="text-sm font-semibold" style={txtStyle}>SQL Viewer</h1>
|
|
141
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ ...txt2Style, background: 'var(--c-bg2)' }}>cache.db</span>
|
|
142
|
+
</div>
|
|
143
|
+
<button
|
|
144
|
+
onClick={() => setShowSchema(!showSchema)}
|
|
145
|
+
className="flex items-center gap-1 text-[11px] px-2 py-1 rounded transition hover:bg-[var(--c-card)]"
|
|
146
|
+
style={txt2Style}
|
|
147
|
+
>
|
|
148
|
+
<Table2 size={12} />
|
|
149
|
+
Schema
|
|
150
|
+
<ChevronDown size={10} className={`transition ${showSchema ? 'rotate-180' : ''}`} />
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Schema panel */}
|
|
155
|
+
{showSchema && schema && (
|
|
156
|
+
<div className="rounded-lg p-3 space-y-2 text-[11px]" style={cardBg}>
|
|
157
|
+
<div className="flex flex-wrap gap-4">
|
|
158
|
+
{schema.tables.map(table => (
|
|
159
|
+
<div key={table} className="min-w-[180px]">
|
|
160
|
+
<div className="font-semibold mb-1" style={txtStyle}>{table}</div>
|
|
161
|
+
<div className="space-y-0.5">
|
|
162
|
+
{schema.schema[table]?.map(col => (
|
|
163
|
+
<div key={col.name} className="flex gap-2" style={txt2Style}>
|
|
164
|
+
<span style={txtStyle}>{col.name}</span>
|
|
165
|
+
<span className="text-[10px] opacity-60">{col.type}</span>
|
|
166
|
+
{col.pk ? <span className="text-[9px] px-1 rounded" style={{ background: '#6366f133', color: '#6366f1' }}>PK</span> : null}
|
|
167
|
+
</div>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Example queries */}
|
|
177
|
+
<div className="flex flex-wrap gap-1">
|
|
178
|
+
{EXAMPLE_QUERIES.map((q, i) => (
|
|
179
|
+
<button
|
|
180
|
+
key={i}
|
|
181
|
+
onClick={() => setSql(q.sql)}
|
|
182
|
+
className="text-[10px] px-2 py-0.5 rounded transition hover:bg-[var(--c-card)]"
|
|
183
|
+
style={{ ...txt2Style, border: '1px solid var(--c-border)' }}
|
|
184
|
+
>
|
|
185
|
+
{q.label}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* SQL editor */}
|
|
191
|
+
<div className="rounded-lg overflow-hidden" style={cardBg}>
|
|
192
|
+
<div className="flex items-center justify-between px-3 py-1.5" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
193
|
+
<span className="text-[10px] font-mono" style={txt2Style}>SQL</span>
|
|
194
|
+
<div className="flex items-center gap-1">
|
|
195
|
+
<span className="text-[10px]" style={txt2Style}>⌘+Enter to run</span>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
<textarea
|
|
199
|
+
ref={textareaRef}
|
|
200
|
+
value={sql}
|
|
201
|
+
onChange={e => setSql(e.target.value)}
|
|
202
|
+
onKeyDown={handleKeyDown}
|
|
203
|
+
spellCheck={false}
|
|
204
|
+
className="w-full p-3 text-[12px] font-mono resize-y outline-none"
|
|
205
|
+
style={{ background: 'transparent', color: 'var(--c-text)', minHeight: 80, maxHeight: 300 }}
|
|
206
|
+
rows={4}
|
|
207
|
+
/>
|
|
208
|
+
<div className="flex items-center gap-2 px-3 py-1.5" style={{ borderTop: '1px solid var(--c-border)' }}>
|
|
209
|
+
<button
|
|
210
|
+
onClick={runQuery}
|
|
211
|
+
disabled={loading || !sql.trim()}
|
|
212
|
+
className="flex items-center gap-1.5 px-3 py-1 text-[11px] font-medium rounded transition"
|
|
213
|
+
style={{ background: '#6366f1', color: '#fff', opacity: loading ? 0.5 : 1 }}
|
|
214
|
+
>
|
|
215
|
+
<Play size={11} />
|
|
216
|
+
{loading ? 'Running...' : 'Run Query'}
|
|
217
|
+
</button>
|
|
218
|
+
{elapsed && result && (
|
|
219
|
+
<span className="text-[10px]" style={txt2Style}>
|
|
220
|
+
{result.count} row{result.count !== 1 ? 's' : ''} in {elapsed}ms
|
|
221
|
+
</span>
|
|
222
|
+
)}
|
|
223
|
+
{result && (
|
|
224
|
+
<div className="ml-auto flex items-center gap-1">
|
|
225
|
+
<button onClick={copyResults} className="p-1 rounded hover:bg-[var(--c-bg2)] transition" style={txt2Style} title="Copy to clipboard">
|
|
226
|
+
<Copy size={12} />
|
|
227
|
+
</button>
|
|
228
|
+
<button onClick={downloadCsv} className="p-1 rounded hover:bg-[var(--c-bg2)] transition" style={txt2Style} title="Download CSV">
|
|
229
|
+
<Download size={12} />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
{/* Error */}
|
|
237
|
+
{error && (
|
|
238
|
+
<div className="rounded-lg px-3 py-2 text-[11px]" style={{ background: '#ef444420', border: '1px solid #ef444440', color: '#ef4444' }}>
|
|
239
|
+
{error}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Results table */}
|
|
244
|
+
{result && result.rows.length > 0 && (
|
|
245
|
+
<div className="rounded-lg overflow-hidden" style={cardBg}>
|
|
246
|
+
<div className="overflow-x-auto" style={{ maxHeight: 400 }}>
|
|
247
|
+
<table className="w-full text-[11px]" style={{ borderCollapse: 'collapse' }}>
|
|
248
|
+
<thead>
|
|
249
|
+
<tr style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
250
|
+
{result.columns.map(col => (
|
|
251
|
+
<th key={col} className="text-left px-3 py-1.5 font-semibold sticky top-0" style={{ ...txtStyle, background: 'var(--c-card)' }}>
|
|
252
|
+
{col}
|
|
253
|
+
</th>
|
|
254
|
+
))}
|
|
255
|
+
</tr>
|
|
256
|
+
</thead>
|
|
257
|
+
<tbody>
|
|
258
|
+
{result.rows.map((row, i) => (
|
|
259
|
+
<tr key={i} className="hover:bg-[var(--c-bg2)] transition" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
260
|
+
{result.columns.map(col => (
|
|
261
|
+
<td key={col} className="px-3 py-1 font-mono" style={txt2Style}>
|
|
262
|
+
{formatCell(row[col])}
|
|
263
|
+
</td>
|
|
264
|
+
))}
|
|
265
|
+
</tr>
|
|
266
|
+
))}
|
|
267
|
+
</tbody>
|
|
268
|
+
</table>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{result && result.rows.length === 0 && (
|
|
274
|
+
<div className="rounded-lg px-3 py-6 text-center text-[11px]" style={{ ...cardBg, ...txt2Style }}>
|
|
275
|
+
Query returned 0 rows
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{/* Chart visualization */}
|
|
280
|
+
{chartData && (
|
|
281
|
+
<div className="rounded-lg p-4" style={cardBg}>
|
|
282
|
+
<div className="flex items-center gap-2 mb-3">
|
|
283
|
+
<span className="text-[11px] font-semibold" style={txtStyle}>Visualization</span>
|
|
284
|
+
<div className="flex gap-0.5 ml-2" style={{ border: '1px solid var(--c-border)', borderRadius: 6 }}>
|
|
285
|
+
{[
|
|
286
|
+
{ type: 'bar', icon: BarChart3 },
|
|
287
|
+
{ type: 'line', icon: LineChart },
|
|
288
|
+
{ type: 'doughnut', icon: PieChart },
|
|
289
|
+
].map(({ type, icon: Icon }) => (
|
|
290
|
+
<button
|
|
291
|
+
key={type}
|
|
292
|
+
onClick={() => setChartType(type)}
|
|
293
|
+
className="p-1.5 transition"
|
|
294
|
+
style={{
|
|
295
|
+
background: chartType === type ? 'var(--c-bg2)' : 'transparent',
|
|
296
|
+
color: chartType === type ? 'var(--c-white)' : 'var(--c-text3)',
|
|
297
|
+
borderRadius: 4,
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
<Icon size={12} />
|
|
301
|
+
</button>
|
|
302
|
+
))}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
<div style={{ height: chartType === 'doughnut' ? 280 : 260 }}>
|
|
306
|
+
{chartType === 'bar' && <Bar data={chartData} options={chartOptions} />}
|
|
307
|
+
{chartType === 'line' && <Line data={chartData} options={chartOptions} />}
|
|
308
|
+
{chartType === 'doughnut' && <Doughnut data={chartData} options={chartOptions} />}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function formatCell(value) {
|
|
317
|
+
if (value === null || value === undefined) return <span className="opacity-30">NULL</span>
|
|
318
|
+
if (typeof value === 'number') return value.toLocaleString()
|
|
319
|
+
const str = String(value)
|
|
320
|
+
if (str.length > 120) return str.substring(0, 120) + '…'
|
|
321
|
+
return str
|
|
322
|
+
}
|