agentlytics 0.0.1 → 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 +74 -269
- 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
|
@@ -1,301 +1,106 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="misc/logo.svg" width="120" alt="Agentlytics">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">Agentlytics</h1>
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Unified analytics for your AI coding agents</strong><br>
|
|
9
|
+
<sub>Cursor · Windsurf · Claude Code · VS Code Copilot · Zed · Antigravity · OpenCode · Gemini CLI · Copilot CLI</sub>
|
|
10
|
+
</p>
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
<p align="center">
|
|
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-11-818cf8" alt="editors"></a>
|
|
15
|
+
<a href="#license"><img src="https://img.shields.io/badge/license-MIT-green" alt="license"></a>
|
|
16
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%E2%89%A518-brightgreen" alt="node"></a>
|
|
17
|
+
</p>
|
|
8
18
|
|
|
9
|
-
<
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="https://github.com/user-attachments/assets/fdb0acb2-db0f-4091-af23-949ca0fae9c8" alt="Agentlytics demo" width="100%">
|
|
21
|
+
</p>
|
|
10
22
|
|
|
11
23
|
---
|
|
12
24
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **Dashboard** — KPI cards (total sessions, daily avg, current month), activity heatmap, editor breakdown with click-to-filter, mode distribution, top projects
|
|
16
|
-
- **Sessions** — Paginated list of every chat session with editor/project/mode badges, search, and editor filter. Click any session to see the full conversation with syntax-highlighted markdown, expandable tool call details, and diff views
|
|
17
|
-
- **Projects** — Per-project analytics: sessions, messages, tokens, tool calls, models, and editor breakdown. Filterable by editor and searchable
|
|
18
|
-
- **Deep Analysis** — Aggregated tool call frequency, model distribution (doughnut chart), token breakdown (input/output/cache read/write). Click any tool bar to drill down into individual calls with full arguments
|
|
19
|
-
- **Compare** — Side-by-side editor comparison: totals, efficiency ratios (avg msgs/session, output/input ratio, cache hit rate), grouped bar charts, tool and model breakdowns
|
|
20
|
-
- **Refetch** — One-click cache rebuild with live SSE progress streaming
|
|
21
|
-
|
|
22
|
-
### Supported Editors
|
|
23
|
-
|
|
24
|
-
| Editor | Source ID | Data Location | Messages | Tool Args | Models | Tokens |
|
|
25
|
-
|--------|-----------|---------------|----------|-----------|--------|--------|
|
|
26
|
-
| **Cursor** | `cursor` | `~/.cursor/chats/` + `~/Library/Application Support/Cursor/` | ✅ | ✅ | ⚠️ provider only | ⚠️ partial |
|
|
27
|
-
| **Windsurf** | `windsurf` | ConnectRPC from running language server | ✅ | ✅ | ✅ | ✅ |
|
|
28
|
-
| **Windsurf Next** | `windsurf-next` | ConnectRPC from running language server | ✅ | ✅ | ✅ | ✅ |
|
|
29
|
-
| **Antigravity** | `antigravity` | ConnectRPC from running language server (HTTPS) | ✅ | ✅ | ✅ | ✅ |
|
|
30
|
-
| **Claude Code** | `claude-code` | `~/.claude/projects/` | ✅ | ✅ | ✅ | ✅ |
|
|
31
|
-
| **VS Code** | `vscode` | `~/Library/Application Support/Code/` | ✅ | ✅ | ✅ | ✅ |
|
|
32
|
-
| **VS Code Insiders** | `vscode-insiders` | `~/Library/Application Support/Code - Insiders/` | ✅ | ✅ | ✅ | ✅ |
|
|
33
|
-
| **Zed** | `zed` | `~/Library/Application Support/Zed/threads/threads.db` | ✅ | ✅ | ✅ | ❌ |
|
|
34
|
-
| **OpenCode** | `opencode` | `~/.local/share/opencode/opencode.db` | ✅ | ✅ | ✅ | ✅ |
|
|
35
|
-
|
|
36
|
-
> **Note:** Windsurf, Windsurf Next, and Antigravity require their app to be running during scan — they expose data via a local ConnectRPC API from the language server process.
|
|
37
|
-
|
|
38
|
-
---
|
|
25
|
+
Agentlytics reads local chat history from every major AI coding assistant and presents a unified analytics dashboard in your browser. **No data ever leaves your machine.**
|
|
39
26
|
|
|
40
27
|
## Quick Start
|
|
41
28
|
|
|
42
|
-
### Prerequisites
|
|
43
|
-
|
|
44
|
-
- **Node.js** ≥ 18
|
|
45
|
-
- **macOS** (currently the only supported platform — all editor paths are macOS-specific)
|
|
46
|
-
|
|
47
|
-
### Install & Run
|
|
48
|
-
|
|
49
29
|
```bash
|
|
50
|
-
|
|
51
|
-
cd agentlytics
|
|
52
|
-
|
|
53
|
-
# Install backend dependencies
|
|
54
|
-
npm install
|
|
55
|
-
|
|
56
|
-
# Build the frontend
|
|
57
|
-
cd ui && npm install && npm run build && cd ..
|
|
58
|
-
|
|
59
|
-
# Start the dashboard
|
|
60
|
-
npm start
|
|
30
|
+
npx agentlytics
|
|
61
31
|
```
|
|
62
32
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
On first run, Agentlytics scans all detected editors and populates a local SQLite cache at `~/.agentlytics/cache.db`. Subsequent launches skip unchanged sessions.
|
|
66
|
-
|
|
67
|
-
### Options
|
|
68
|
-
|
|
69
|
-
```bash
|
|
70
|
-
npm start # normal start (uses cache)
|
|
71
|
-
npm start -- --no-cache # wipe cache and rescan everything
|
|
72
|
-
```
|
|
33
|
+
Opens at **http://localhost:4637**. Requires Node.js ≥ 18, macOS.
|
|
73
34
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
# Start Vite dev server with hot reload (port 5173)
|
|
78
|
-
cd ui && npm run dev
|
|
79
|
-
|
|
80
|
-
# In another terminal, start the backend (port 4637)
|
|
81
|
-
npm start
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
The Vite dev server proxies API requests to the backend via `vite.config.js`.
|
|
85
|
-
|
|
86
|
-
---
|
|
35
|
+
## Features
|
|
87
36
|
|
|
88
|
-
|
|
37
|
+
- **Dashboard** — KPIs, activity heatmap, editor breakdown, coding streaks, token economy, peak hours, top models & tools
|
|
38
|
+
- **Sessions** — Search, filter, full conversation viewer with syntax highlighting and diff views
|
|
39
|
+
- **Projects** — Per-project analytics: sessions, messages, tokens, models, editor breakdown
|
|
40
|
+
- **Deep Analysis** — Tool frequency, model distribution, token breakdown with drill-down
|
|
41
|
+
- **Compare** — Side-by-side editor comparison with efficiency ratios
|
|
42
|
+
- **Refetch** — One-click cache rebuild with live progress
|
|
43
|
+
|
|
44
|
+
## Supported Editors
|
|
45
|
+
|
|
46
|
+
| Editor | ID | Msgs | Tools | Models | Tokens |
|
|
47
|
+
|--------|----|:----:|:-----:|:------:|:------:|
|
|
48
|
+
| **Cursor** | `cursor` | ✅ | ✅ | ⚠️ | ⚠️ |
|
|
49
|
+
| **Windsurf** | `windsurf` | ✅ | ✅ | ✅ | ✅ |
|
|
50
|
+
| **Windsurf Next** | `windsurf-next` | ✅ | ✅ | ✅ | ✅ |
|
|
51
|
+
| **Antigravity** | `antigravity` | ✅ | ✅ | ✅ | ✅ |
|
|
52
|
+
| **Claude Code** | `claude-code` | ✅ | ✅ | ✅ | ✅ |
|
|
53
|
+
| **VS Code** | `vscode` | ✅ | ✅ | ✅ | ✅ |
|
|
54
|
+
| **VS Code Insiders** | `vscode-insiders` | ✅ | ✅ | ✅ | ✅ |
|
|
55
|
+
| **Zed** | `zed` | ✅ | ✅ | ✅ | ❌ |
|
|
56
|
+
| **OpenCode** | `opencode` | ✅ | ✅ | ✅ | ✅ |
|
|
57
|
+
| **Gemini CLI** | `gemini-cli` | ✅ | ✅ | ✅ | ✅ |
|
|
58
|
+
| **Copilot CLI** | `copilot-cli` | ✅ | ✅ | ✅ | ✅ |
|
|
59
|
+
|
|
60
|
+
> Windsurf, Windsurf Next, and Antigravity must be running during scan.
|
|
61
|
+
|
|
62
|
+
## How It Works
|
|
89
63
|
|
|
90
64
|
```
|
|
91
|
-
Editor
|
|
65
|
+
Editor files/APIs → editors/*.js → cache.js (SQLite) → server.js (REST) → React SPA
|
|
92
66
|
```
|
|
93
67
|
|
|
94
|
-
|
|
95
|
-
2. **Cache layer** normalizes everything into a single SQLite DB (`~/.agentlytics/cache.db`) with tables for `chats`, `messages`, `chat_stats`, and `tool_calls`
|
|
96
|
-
3. **Express server** exposes read-only REST endpoints against the cache
|
|
97
|
-
4. **React frontend** fetches data from the API and renders charts via Chart.js
|
|
98
|
-
|
|
99
|
-
---
|
|
100
|
-
|
|
101
|
-
## API Reference
|
|
102
|
-
|
|
103
|
-
All endpoints are `GET` and return JSON.
|
|
104
|
-
|
|
105
|
-
| Endpoint | Description | Query Params |
|
|
106
|
-
|----------|-------------|--------------|
|
|
107
|
-
| `/api/overview` | Dashboard overview: totals, editors, modes, monthly trend, top projects | `editor` |
|
|
108
|
-
| `/api/daily-activity` | Daily activity data for heatmap | `editor` |
|
|
109
|
-
| `/api/chats` | Paginated chat list | `editor`, `folder`, `named`, `limit`, `offset` |
|
|
110
|
-
| `/api/chats/:id` | Full chat detail with messages and stats | — |
|
|
111
|
-
| `/api/projects` | All projects with aggregated analytics | — |
|
|
112
|
-
| `/api/deep-analytics` | Aggregated tool/model/token analytics | `editor`, `folder`, `limit` |
|
|
113
|
-
| `/api/tool-calls` | Individual tool call instances | `name` (required), `folder`, `limit` |
|
|
114
|
-
| `/api/refetch` | SSE stream: wipe cache and rescan all editors | — |
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
## Cache Database Schema
|
|
119
|
-
|
|
120
|
-
Location: `~/.agentlytics/cache.db`
|
|
68
|
+
All data is normalized into a local SQLite cache at `~/.agentlytics/cache.db`. The Express server exposes read-only REST endpoints consumed by the React frontend.
|
|
121
69
|
|
|
122
|
-
|
|
123
|
-
Stores one row per chat session, normalized across all editors.
|
|
70
|
+
## API
|
|
124
71
|
|
|
125
|
-
|
|
|
126
|
-
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
72
|
+
| Endpoint | Description |
|
|
73
|
+
|----------|-------------|
|
|
74
|
+
| `GET /api/overview` | Dashboard KPIs, editors, modes, trends |
|
|
75
|
+
| `GET /api/daily-activity` | Daily counts for heatmap |
|
|
76
|
+
| `GET /api/dashboard-stats` | Hourly, weekday, streaks, tokens, velocity |
|
|
77
|
+
| `GET /api/chats` | Paginated session list |
|
|
78
|
+
| `GET /api/chats/:id` | Full chat with messages |
|
|
79
|
+
| `GET /api/projects` | Project-level aggregations |
|
|
80
|
+
| `GET /api/deep-analytics` | Tool/model/token breakdowns |
|
|
81
|
+
| `GET /api/tool-calls` | Individual tool call instances |
|
|
82
|
+
| `GET /api/refetch` | SSE: wipe cache and rescan |
|
|
136
83
|
|
|
137
|
-
|
|
138
|
-
Individual messages per chat, stored with truncation at 50K characters.
|
|
84
|
+
All endpoints accept optional `editor` filter. See **[API.md](API.md)** for full request/response documentation.
|
|
139
85
|
|
|
140
|
-
|
|
141
|
-
|--------|------|-------------|
|
|
142
|
-
| `chat_id` | TEXT FK | References `chats.id` |
|
|
143
|
-
| `seq` | INTEGER | Message sequence number |
|
|
144
|
-
| `role` | TEXT | `user`, `assistant`, `system`, or `tool` |
|
|
145
|
-
| `content` | TEXT | Message content (may contain `[tool-call:]` / `[tool-result:]` markers) |
|
|
146
|
-
| `model` | TEXT | Model name (if available) |
|
|
147
|
-
| `input_tokens` | INTEGER | Input token count |
|
|
148
|
-
| `output_tokens` | INTEGER | Output token count |
|
|
86
|
+
## Roadmap
|
|
149
87
|
|
|
150
|
-
|
|
151
|
-
|
|
88
|
+
- [ ] **Offline Windsurf/Antigravity support** — Read cascade data from local file structure instead of requiring the app to be running (see below)
|
|
89
|
+
- [ ] **LLM-powered insights** — Use an LLM to analyze session patterns, generate summaries, detect coding habits, and surface actionable recommendations
|
|
90
|
+
- [ ] **Linux & Windows support** — Adapt editor paths for non-macOS platforms
|
|
91
|
+
- [ ] **Export & reports** — PDF/CSV export of analytics and session data
|
|
92
|
+
- [ ] **Cost tracking** — Estimate API costs per editor/model based on token usage
|
|
152
93
|
|
|
153
|
-
|
|
154
|
-
|--------|------|-------------|
|
|
155
|
-
| `chat_id` | TEXT PK | References `chats.id` |
|
|
156
|
-
| `total_messages` | INTEGER | Total message count |
|
|
157
|
-
| `user_messages` | INTEGER | User message count |
|
|
158
|
-
| `assistant_messages` | INTEGER | Assistant message count |
|
|
159
|
-
| `tool_calls` | TEXT | JSON array of tool call names |
|
|
160
|
-
| `models` | TEXT | JSON array of model names |
|
|
161
|
-
| `total_input_tokens` | INTEGER | Sum of input tokens |
|
|
162
|
-
| `total_output_tokens` | INTEGER | Sum of output tokens |
|
|
163
|
-
| `total_cache_read` | INTEGER | Sum of cache read tokens |
|
|
164
|
-
| `total_cache_write` | INTEGER | Sum of cache write tokens |
|
|
94
|
+
## Contributions Needed
|
|
165
95
|
|
|
166
|
-
|
|
167
|
-
Individual tool call records with full argument JSON.
|
|
96
|
+
**Windsurf / Windsurf Next / Antigravity offline reading** — Currently these editors require their app to be running because data is fetched via ConnectRPC from the language server process. Unlike Cursor or Claude Code, there's no known local file structure to read cascade history from. If you know where Windsurf stores trajectory data on disk, or can help reverse-engineer the storage format, contributions are very welcome.
|
|
168
97
|
|
|
169
|
-
|
|
170
|
-
|--------|------|-------------|
|
|
171
|
-
| `chat_id` | TEXT FK | References `chats.id` |
|
|
172
|
-
| `tool_name` | TEXT | Tool function name |
|
|
173
|
-
| `args_json` | TEXT | Full arguments as JSON |
|
|
174
|
-
| `source` | TEXT | Editor source |
|
|
175
|
-
| `folder` | TEXT | Project directory |
|
|
176
|
-
| `timestamp` | INTEGER | Call timestamp (ms) |
|
|
98
|
+
**LLM-based analytics** — We'd love to add intelligent analysis on top of the raw data — session summaries, coding pattern detection, productivity insights, and natural language queries over your agent history. If you have ideas or want to build this, open an issue or PR.
|
|
177
99
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
## Editor Adapters — Technical Details
|
|
181
|
-
|
|
182
|
-
### Cursor
|
|
183
|
-
|
|
184
|
-
Reads from **two separate data stores**:
|
|
185
|
-
|
|
186
|
-
1. **Agent Store** (`~/.cursor/chats/<workspace>/<chatId>/store.db`)
|
|
187
|
-
- SQLite with `meta` table (hex-encoded JSON) and `blobs` table (content-addressed SHA-256 tree)
|
|
188
|
-
- Meta contains: `agentId`, `latestRootBlobId`, `name`, `createdAt`
|
|
189
|
-
- Messages are retrieved by walking the blob tree: tree nodes contain message refs and child refs
|
|
190
|
-
- Tool calls extracted from OpenAI-format `tool_calls` array on assistant messages
|
|
191
|
-
|
|
192
|
-
2. **Workspace Composers** (`~/Library/Application Support/Cursor/User/`)
|
|
193
|
-
- `workspaceStorage/<hash>/state.vscdb` — `composer.composerData` key holds all composer headers
|
|
194
|
-
- `globalStorage/state.vscdb` — `cursorDiskKV` table with `bubbleId:<composerId>:<n>` keys
|
|
195
|
-
- Each bubble is a JSON object with `type` (1=user, 2=assistant), `text`, `toolFormerData`, `tokenCount`
|
|
196
|
-
- Tool args extracted from `toolFormerData.rawArgs` with fallback to `toolFormerData.params`
|
|
197
|
-
|
|
198
|
-
**Limitations:** Cursor does not persist model names per chat or per message. Provider name (e.g., "anthropic") is extracted from `providerOptions` when available.
|
|
199
|
-
|
|
200
|
-
### Windsurf / Windsurf Next / Antigravity
|
|
201
|
-
|
|
202
|
-
Connects to the **running language server** via ConnectRPC (buf Connect protocol):
|
|
203
|
-
|
|
204
|
-
1. Discovers process via `ps aux` — finds `language_server_macos_arm` with `--csrf_token`
|
|
205
|
-
2. Extracts CSRF token and PID, finds listening port via `lsof`
|
|
206
|
-
3. Calls `GetAllCascadeTrajectories` for session summaries
|
|
207
|
-
4. Calls `GetCascadeTrajectory` per session for full conversation data
|
|
208
|
-
|
|
209
|
-
**Requires the application to be running** — data is served from the language server process, not from files on disk.
|
|
210
|
-
|
|
211
|
-
### Claude Code
|
|
212
|
-
|
|
213
|
-
Reads from `~/.claude/projects/<encoded-path>/`:
|
|
214
|
-
- `sessions-index.json` — session index with titles and timestamps
|
|
215
|
-
- Individual `.jsonl` session files — each line is a JSON message with `type`, `role`, `content`, `model`, `usage`
|
|
216
|
-
- Tool calls extracted from `tool_use` content blocks and `tool_result` messages
|
|
217
|
-
|
|
218
|
-
### VS Code / VS Code Insiders
|
|
219
|
-
|
|
220
|
-
Reads from `~/Library/Application Support/{Code,Code - Insiders}/User/`:
|
|
221
|
-
- `workspaceStorage/<hash>/state.vscdb` — workspace-to-folder mapping
|
|
222
|
-
- Chat sessions stored as `.jsonl` files in the Copilot Chat extension directory
|
|
223
|
-
- JSONL reconstruction: `kind:0` = init state, `kind:1` = JSON patch at key path
|
|
224
|
-
- Messages, tool calls, and token usage extracted from reconstructed chat state
|
|
225
|
-
|
|
226
|
-
### Zed
|
|
227
|
-
|
|
228
|
-
Reads from `~/Library/Application Support/Zed/threads/threads.db`:
|
|
229
|
-
- SQLite database with `threads` table containing zstd-compressed JSON blobs
|
|
230
|
-
- Each thread decompressed via `zstd` CLI to extract messages, tool calls, and model info
|
|
231
|
-
- Messages in OpenAI format with `tool_calls` array on assistant messages
|
|
232
|
-
|
|
233
|
-
### OpenCode
|
|
100
|
+
## Contributing
|
|
234
101
|
|
|
235
|
-
|
|
236
|
-
- SQLite database with `session`, `message`, and `project` tables
|
|
237
|
-
- Messages queried directly via SQL with full content, model, and token data
|
|
238
|
-
|
|
239
|
-
---
|
|
240
|
-
|
|
241
|
-
## Adding a New Editor
|
|
242
|
-
|
|
243
|
-
1. Create `editors/<name>.js` exporting:
|
|
244
|
-
|
|
245
|
-
```javascript
|
|
246
|
-
const name = 'my-editor';
|
|
247
|
-
|
|
248
|
-
function getChats() {
|
|
249
|
-
// Return array of chat objects:
|
|
250
|
-
return [{
|
|
251
|
-
source: name, // editor identifier
|
|
252
|
-
composerId: '...', // unique chat ID
|
|
253
|
-
name: '...', // chat title (nullable)
|
|
254
|
-
createdAt: 1234567, // timestamp in ms (nullable)
|
|
255
|
-
lastUpdatedAt: 1234567, // timestamp in ms (nullable)
|
|
256
|
-
mode: 'agent', // session mode (nullable)
|
|
257
|
-
folder: '/path/to/project', // working directory (nullable)
|
|
258
|
-
encrypted: false, // true if messages can't be read
|
|
259
|
-
bubbleCount: 10, // message count hint (nullable)
|
|
260
|
-
}];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function getMessages(chat) {
|
|
264
|
-
// Return array of message objects:
|
|
265
|
-
return [{
|
|
266
|
-
role: 'user', // 'user' | 'assistant' | 'system' | 'tool'
|
|
267
|
-
content: '...', // message text
|
|
268
|
-
_model: 'gpt-4', // model name (optional)
|
|
269
|
-
_inputTokens: 500, // input token count (optional)
|
|
270
|
-
_outputTokens: 200, // output token count (optional)
|
|
271
|
-
_cacheRead: 100, // cache read tokens (optional)
|
|
272
|
-
_cacheWrite: 50, // cache write tokens (optional)
|
|
273
|
-
_toolCalls: [{ // tool calls (optional)
|
|
274
|
-
name: 'read_file',
|
|
275
|
-
args: { path: '/foo.js' },
|
|
276
|
-
}],
|
|
277
|
-
}];
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
module.exports = { name, getChats, getMessages };
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
2. Register in `editors/index.js`:
|
|
284
|
-
|
|
285
|
-
```javascript
|
|
286
|
-
const myEditor = require('./my-editor');
|
|
287
|
-
const editors = [...existingEditors, myEditor];
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
3. Add color and label in `ui/src/lib/constants.js`:
|
|
291
|
-
|
|
292
|
-
```javascript
|
|
293
|
-
export const EDITOR_COLORS = { ..., 'my-editor': '#hex' };
|
|
294
|
-
export const EDITOR_LABELS = { ..., 'my-editor': 'My Editor' };
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
---
|
|
102
|
+
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for development setup, editor adapter details, database schema, and how to add support for new editors.
|
|
298
103
|
|
|
299
104
|
## License
|
|
300
105
|
|
|
301
|
-
MIT
|
|
106
|
+
MIT — Built by [@f](https://github.com/f)
|
|
@@ -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
|
+
}
|