agentlytics 0.1.6 → 0.1.8
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/cache.js +48 -2
- package/editors/cursor.js +1 -0
- package/package.json +1 -1
- package/server.js +57 -4
- package/ui/src/App.jsx +11 -1
- package/ui/src/lib/api.js +19 -0
- package/ui/src/pages/Dashboard.jsx +4 -1
- package/ui/src/pages/Settings.jsx +142 -0
package/cache.js
CHANGED
|
@@ -7,7 +7,7 @@ const { calculateCost, getModelPricing, normalizeModelName } = require('./pricin
|
|
|
7
7
|
|
|
8
8
|
const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
|
|
9
9
|
const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
|
|
10
|
-
const SCHEMA_VERSION =
|
|
10
|
+
const SCHEMA_VERSION = 5; // bump this when schema changes to auto-revalidate
|
|
11
11
|
|
|
12
12
|
let db = null;
|
|
13
13
|
|
|
@@ -293,6 +293,13 @@ function scanAll(onProgress, opts = {}) {
|
|
|
293
293
|
// Query helpers (used by server.js)
|
|
294
294
|
// ============================================================
|
|
295
295
|
|
|
296
|
+
// Returns { sql, params } for excluding hidden folders
|
|
297
|
+
function hiddenFolderFilter(opts, colName = 'folder') {
|
|
298
|
+
if (!opts.hiddenFolders || opts.hiddenFolders.length === 0) return { sql: '', params: [] };
|
|
299
|
+
const placeholders = opts.hiddenFolders.map(() => '?').join(',');
|
|
300
|
+
return { sql: ` AND (${colName} IS NULL OR ${colName} NOT IN (${placeholders}))`, params: [...opts.hiddenFolders] };
|
|
301
|
+
}
|
|
302
|
+
|
|
296
303
|
function getCachedChats(opts = {}) {
|
|
297
304
|
let sql = `SELECT c.*,
|
|
298
305
|
cs.models AS _models,
|
|
@@ -301,6 +308,8 @@ function getCachedChats(opts = {}) {
|
|
|
301
308
|
cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
|
|
302
309
|
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1`;
|
|
303
310
|
const params = [];
|
|
311
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
312
|
+
if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
|
|
304
313
|
if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
305
314
|
if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
|
|
306
315
|
if (opts.named !== false) { sql += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)'; }
|
|
@@ -335,6 +344,8 @@ function getCachedChats(opts = {}) {
|
|
|
335
344
|
function countCachedChats(opts = {}) {
|
|
336
345
|
let sql = 'SELECT COUNT(*) as cnt FROM chats WHERE 1=1';
|
|
337
346
|
const params = [];
|
|
347
|
+
const hf = hiddenFolderFilter(opts);
|
|
348
|
+
if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
|
|
338
349
|
if (opts.editor) { sql += ' AND source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
339
350
|
if (opts.folder) { sql += ' AND folder LIKE ?'; params.push(`%${opts.folder}%`); }
|
|
340
351
|
if (opts.named !== false) { sql += ' AND (name IS NOT NULL OR bubble_count > 0)'; }
|
|
@@ -347,6 +358,8 @@ function getCachedOverview(opts = {}) {
|
|
|
347
358
|
// Build conditions dynamically to support editor + date range filters
|
|
348
359
|
const conditions = [];
|
|
349
360
|
const params = [];
|
|
361
|
+
const hf = hiddenFolderFilter(opts);
|
|
362
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
350
363
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
351
364
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
352
365
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -411,6 +424,8 @@ function getCachedOverview(opts = {}) {
|
|
|
411
424
|
function getCachedDailyActivity(opts = {}) {
|
|
412
425
|
const conditions = [];
|
|
413
426
|
const params = [];
|
|
427
|
+
const hf = hiddenFolderFilter(opts);
|
|
428
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
414
429
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
415
430
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
416
431
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -441,6 +456,8 @@ function getCachedDailyActivity(opts = {}) {
|
|
|
441
456
|
function getCachedDeepAnalytics(opts = {}) {
|
|
442
457
|
let sql = 'SELECT cs.* FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id WHERE 1=1';
|
|
443
458
|
const params = [];
|
|
459
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
460
|
+
if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
|
|
444
461
|
if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
445
462
|
if (opts.folder) { sql += ' AND c.folder = ?'; params.push(opts.folder); }
|
|
446
463
|
if (opts.dateFrom) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
|
|
@@ -499,7 +516,26 @@ function getCachedChat(id) {
|
|
|
499
516
|
if (!chat) return null;
|
|
500
517
|
|
|
501
518
|
const stats = db.prepare('SELECT * FROM chat_stats WHERE chat_id = ?').get(chat.id);
|
|
502
|
-
|
|
519
|
+
let messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
|
|
520
|
+
|
|
521
|
+
// If no cached messages, try fetching live from the editor
|
|
522
|
+
if (messages.length === 0 && !chat.encrypted) {
|
|
523
|
+
try {
|
|
524
|
+
const meta = JSON.parse(chat._meta || '{}');
|
|
525
|
+
const reconstructed = {
|
|
526
|
+
composerId: chat.id, source: chat.source, name: chat.name, mode: chat.mode,
|
|
527
|
+
folder: chat.folder, createdAt: chat.created_at, lastUpdatedAt: chat.last_updated_at,
|
|
528
|
+
encrypted: !!chat.encrypted, bubbleCount: chat.bubble_count,
|
|
529
|
+
...meta,
|
|
530
|
+
};
|
|
531
|
+
const liveMessages = getMessages(reconstructed);
|
|
532
|
+
if (liveMessages && liveMessages.length > 0) {
|
|
533
|
+
// Store for next time
|
|
534
|
+
try { analyzeAndStore(reconstructed); } catch {}
|
|
535
|
+
messages = db.prepare('SELECT role, content, model, input_tokens, output_tokens FROM messages WHERE chat_id = ? ORDER BY seq').all(chat.id);
|
|
536
|
+
}
|
|
537
|
+
} catch {}
|
|
538
|
+
}
|
|
503
539
|
|
|
504
540
|
let parsedStats = null;
|
|
505
541
|
if (stats) {
|
|
@@ -542,6 +578,10 @@ function getCachedProjects(opts = {}) {
|
|
|
542
578
|
// Build date filter
|
|
543
579
|
let dateFilter = '';
|
|
544
580
|
const dateParams = [];
|
|
581
|
+
if (!opts.includeHidden) {
|
|
582
|
+
const hf = hiddenFolderFilter(opts);
|
|
583
|
+
if (hf.sql) { dateFilter += hf.sql; dateParams.push(...hf.params); }
|
|
584
|
+
}
|
|
545
585
|
if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
|
|
546
586
|
if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
|
|
547
587
|
|
|
@@ -745,6 +785,8 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
745
785
|
// Build conditions dynamically to support editor + date range filters
|
|
746
786
|
const conditions = [];
|
|
747
787
|
const params = [];
|
|
788
|
+
const hf = hiddenFolderFilter(opts);
|
|
789
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
748
790
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
749
791
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
750
792
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -1103,6 +1145,8 @@ function estimateCosts(whereClause = '', params = []) {
|
|
|
1103
1145
|
function getCostBreakdown(opts = {}) {
|
|
1104
1146
|
let whereClause = '';
|
|
1105
1147
|
const params = [];
|
|
1148
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
1149
|
+
if (hf.sql) { whereClause += hf.sql; params.push(...hf.params); }
|
|
1106
1150
|
if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
1107
1151
|
if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
|
|
1108
1152
|
if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
|
|
@@ -1114,6 +1158,8 @@ function getCostBreakdown(opts = {}) {
|
|
|
1114
1158
|
function getCostAnalytics(opts = {}) {
|
|
1115
1159
|
const conditions = [];
|
|
1116
1160
|
const params = [];
|
|
1161
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
1162
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
1117
1163
|
if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
|
|
1118
1164
|
if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
|
|
1119
1165
|
if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
|
package/editors/cursor.js
CHANGED
|
@@ -182,6 +182,7 @@ function getComposerBubbles(globalDb, composerId) {
|
|
|
182
182
|
function bubblesToMessages(bubbles) {
|
|
183
183
|
const messages = [];
|
|
184
184
|
for (const b of bubbles) {
|
|
185
|
+
if (!b) continue;
|
|
185
186
|
const type = b.type; // 1=user, 2=assistant
|
|
186
187
|
if (type === 1) {
|
|
187
188
|
const text = b.text || '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode, Command Code",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/server.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const express = require('express');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
3
5
|
const cache = require('./cache');
|
|
4
6
|
const { generateShareSvg } = require('./share-image');
|
|
5
7
|
|
|
@@ -7,6 +9,26 @@ const app = express();
|
|
|
7
9
|
app.use(express.json());
|
|
8
10
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
9
11
|
|
|
12
|
+
// ============================================================
|
|
13
|
+
// Config: ~/.agentlytics/config.json
|
|
14
|
+
// ============================================================
|
|
15
|
+
|
|
16
|
+
const CONFIG_PATH = path.join(os.homedir(), '.agentlytics', 'config.json');
|
|
17
|
+
|
|
18
|
+
function readConfig() {
|
|
19
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeConfig(config) {
|
|
23
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
24
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getHiddenFolders() {
|
|
29
|
+
return readConfig().hiddenProjects || [];
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
// ============================================================
|
|
11
33
|
// API endpoints — all reads from SQLite cache
|
|
12
34
|
// ============================================================
|
|
@@ -25,7 +47,7 @@ app.get('/api/mode', (req, res) => {
|
|
|
25
47
|
|
|
26
48
|
app.get('/api/overview', (req, res) => {
|
|
27
49
|
try {
|
|
28
|
-
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|
|
50
|
+
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
|
|
29
51
|
res.json(cache.getCachedOverview(opts));
|
|
30
52
|
} catch (err) {
|
|
31
53
|
res.status(500).json({ error: err.message });
|
|
@@ -34,7 +56,7 @@ app.get('/api/overview', (req, res) => {
|
|
|
34
56
|
|
|
35
57
|
app.get('/api/daily-activity', (req, res) => {
|
|
36
58
|
try {
|
|
37
|
-
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|
|
59
|
+
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
|
|
38
60
|
res.json(cache.getCachedDailyActivity(opts));
|
|
39
61
|
} catch (err) {
|
|
40
62
|
res.status(500).json({ error: err.message });
|
|
@@ -50,6 +72,7 @@ app.get('/api/chats', (req, res) => {
|
|
|
50
72
|
limit: req.query.limit ? parseInt(req.query.limit) : 200,
|
|
51
73
|
offset: req.query.offset ? parseInt(req.query.offset) : 0,
|
|
52
74
|
...parseDateOpts(req.query),
|
|
75
|
+
hiddenFolders: getHiddenFolders(),
|
|
53
76
|
};
|
|
54
77
|
const total = cache.countCachedChats(opts);
|
|
55
78
|
const rows = cache.getCachedChats(opts);
|
|
@@ -131,7 +154,7 @@ app.get('/api/chats/:id/markdown', (req, res) => {
|
|
|
131
154
|
|
|
132
155
|
app.get('/api/projects', (req, res) => {
|
|
133
156
|
try {
|
|
134
|
-
res.json(cache.getCachedProjects(parseDateOpts(req.query)));
|
|
157
|
+
res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() }));
|
|
135
158
|
} catch (err) {
|
|
136
159
|
res.status(500).json({ error: err.message });
|
|
137
160
|
}
|
|
@@ -144,6 +167,7 @@ app.get('/api/deep-analytics', (req, res) => {
|
|
|
144
167
|
folder: req.query.folder || null,
|
|
145
168
|
limit: Math.min(parseInt(req.query.limit) || 500, 5000),
|
|
146
169
|
...parseDateOpts(req.query),
|
|
170
|
+
hiddenFolders: getHiddenFolders(),
|
|
147
171
|
};
|
|
148
172
|
res.json(cache.getCachedDeepAnalytics(opts));
|
|
149
173
|
} catch (err) {
|
|
@@ -153,7 +177,7 @@ app.get('/api/deep-analytics', (req, res) => {
|
|
|
153
177
|
|
|
154
178
|
app.get('/api/dashboard-stats', (req, res) => {
|
|
155
179
|
try {
|
|
156
|
-
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|
|
180
|
+
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: getHiddenFolders() };
|
|
157
181
|
res.json(cache.getCachedDashboardStats(opts));
|
|
158
182
|
} catch (err) {
|
|
159
183
|
res.status(500).json({ error: err.message });
|
|
@@ -165,6 +189,7 @@ app.get('/api/cost-analytics', (req, res) => {
|
|
|
165
189
|
const opts = {
|
|
166
190
|
editor: req.query.editor || null,
|
|
167
191
|
...parseDateOpts(req.query),
|
|
192
|
+
hiddenFolders: getHiddenFolders(),
|
|
168
193
|
};
|
|
169
194
|
res.json(cache.getCostAnalytics(opts));
|
|
170
195
|
} catch (err) {
|
|
@@ -179,6 +204,7 @@ app.get('/api/costs', (req, res) => {
|
|
|
179
204
|
folder: req.query.folder || null,
|
|
180
205
|
chatId: req.query.chatId || null,
|
|
181
206
|
...parseDateOpts(req.query),
|
|
207
|
+
hiddenFolders: getHiddenFolders(),
|
|
182
208
|
};
|
|
183
209
|
res.json(cache.getCostBreakdown(opts));
|
|
184
210
|
} catch (err) {
|
|
@@ -266,6 +292,33 @@ app.get('/api/refetch', async (req, res) => {
|
|
|
266
292
|
res.end();
|
|
267
293
|
});
|
|
268
294
|
|
|
295
|
+
// ============================================================
|
|
296
|
+
// Config endpoints
|
|
297
|
+
// ============================================================
|
|
298
|
+
|
|
299
|
+
app.get('/api/config', (req, res) => {
|
|
300
|
+
res.json(readConfig());
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
app.put('/api/config', (req, res) => {
|
|
304
|
+
try {
|
|
305
|
+
const config = readConfig();
|
|
306
|
+
Object.assign(config, req.body);
|
|
307
|
+
writeConfig(config);
|
|
308
|
+
res.json(config);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
res.status(500).json({ error: err.message });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
app.get('/api/all-projects', (req, res) => {
|
|
315
|
+
try {
|
|
316
|
+
res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), includeHidden: true }));
|
|
317
|
+
} catch (err) {
|
|
318
|
+
res.status(500).json({ error: err.message });
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
269
322
|
// SPA fallback
|
|
270
323
|
app.get('*', (req, res) => {
|
|
271
324
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
package/ui/src/App.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { Routes, Route, NavLink } from 'react-router-dom'
|
|
3
|
-
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check } from 'lucide-react'
|
|
3
|
+
import { Activity, BarChart3, GitCompare, MessageSquare, FolderOpen, DollarSign, Sun, Moon, RefreshCw, AlertTriangle, Github, Terminal, Database, Users, Radio, Plug, Copy, Check, Settings as SettingsIcon } from 'lucide-react'
|
|
4
4
|
import { fetchOverview, refetchAgents, fetchMode, fetchRelayConfig, getAuthToken, setOnAuthFailure } from './lib/api'
|
|
5
5
|
import { useTheme } from './lib/theme'
|
|
6
6
|
import AnimatedLogo from './components/AnimatedLogo'
|
|
@@ -14,6 +14,7 @@ import Projects from './pages/Projects'
|
|
|
14
14
|
import ProjectDetail from './pages/ProjectDetail'
|
|
15
15
|
import CostAnalysis from './pages/CostAnalysis'
|
|
16
16
|
import SqlViewer from './pages/SqlViewer'
|
|
17
|
+
import Settings from './pages/Settings'
|
|
17
18
|
import RelayDashboard from './pages/RelayDashboard'
|
|
18
19
|
import RelayUserDetail from './pages/RelayUserDetail'
|
|
19
20
|
|
|
@@ -168,6 +169,14 @@ export default function App() {
|
|
|
168
169
|
Connect
|
|
169
170
|
</button>
|
|
170
171
|
)}
|
|
172
|
+
<NavLink
|
|
173
|
+
to="/settings"
|
|
174
|
+
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
175
|
+
style={({ isActive }) => ({ color: isActive ? '#6366f1' : 'var(--c-text2)' })}
|
|
176
|
+
title="Settings"
|
|
177
|
+
>
|
|
178
|
+
<SettingsIcon size={13} />
|
|
179
|
+
</NavLink>
|
|
171
180
|
<button
|
|
172
181
|
onClick={toggle}
|
|
173
182
|
className="p-1 rounded transition hover:bg-[var(--c-card)]"
|
|
@@ -206,6 +215,7 @@ export default function App() {
|
|
|
206
215
|
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
207
216
|
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
208
217
|
<Route path="/sql" element={<SqlViewer />} />
|
|
218
|
+
<Route path="/settings" element={<Settings />} />
|
|
209
219
|
</Routes>
|
|
210
220
|
)}
|
|
211
221
|
</main>
|
package/ui/src/lib/api.js
CHANGED
|
@@ -147,6 +147,25 @@ export async function fetchCosts(params = {}) {
|
|
|
147
147
|
return res.json();
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
export async function fetchConfig() {
|
|
151
|
+
const res = await fetch(`${BASE}/api/config`);
|
|
152
|
+
return res.json();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function updateConfig(data) {
|
|
156
|
+
const res = await fetch(`${BASE}/api/config`, {
|
|
157
|
+
method: 'PUT',
|
|
158
|
+
headers: { 'Content-Type': 'application/json' },
|
|
159
|
+
body: JSON.stringify(data),
|
|
160
|
+
});
|
|
161
|
+
return res.json();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function fetchAllProjects() {
|
|
165
|
+
const res = await fetch(`${BASE}/api/all-projects`);
|
|
166
|
+
return res.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
export async function executeQuery(sql) {
|
|
151
170
|
const res = await fetch(`${BASE}/api/query`, {
|
|
152
171
|
method: 'POST',
|
|
@@ -9,6 +9,7 @@ import DateRangePicker from '../components/DateRangePicker'
|
|
|
9
9
|
import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
|
|
10
10
|
import EditorIcon from '../components/EditorIcon'
|
|
11
11
|
import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats, fetchCosts } from '../lib/api'
|
|
12
|
+
import ChatSidebar from '../components/ChatSidebar'
|
|
12
13
|
import { useTheme } from '../lib/theme'
|
|
13
14
|
import SectionTitle from '../components/SectionTitle'
|
|
14
15
|
|
|
@@ -33,6 +34,7 @@ export default function Dashboard({ overview }) {
|
|
|
33
34
|
const [costs, setCosts] = useState(null)
|
|
34
35
|
const [sharing, setSharing] = useState(false)
|
|
35
36
|
const [largeContextChats, setLargeContextChats] = useState(null)
|
|
37
|
+
const [selectedChatId, setSelectedChatId] = useState(null)
|
|
36
38
|
const txtColor = dark ? '#888' : '#555'
|
|
37
39
|
const txtDim = dark ? '#555' : '#999'
|
|
38
40
|
const gridColor = dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)'
|
|
@@ -542,7 +544,7 @@ export default function Dashboard({ overview }) {
|
|
|
542
544
|
key={c.id}
|
|
543
545
|
className="flex items-center gap-1.5 px-1.5 py-1 rounded-sm cursor-pointer transition hover:opacity-80"
|
|
544
546
|
style={{ background: c.bubbleCount >= 500 ? 'rgba(239,68,68,0.06)' : 'rgba(245,158,11,0.06)' }}
|
|
545
|
-
onClick={() =>
|
|
547
|
+
onClick={() => setSelectedChatId(c.id)}
|
|
546
548
|
>
|
|
547
549
|
<EditorIcon source={c.source} size={10} />
|
|
548
550
|
<span className="text-[10px] truncate flex-1" style={{ color: 'var(--c-text)' }}>{c.name || 'Untitled'}</span>
|
|
@@ -558,6 +560,7 @@ export default function Dashboard({ overview }) {
|
|
|
558
560
|
</div>
|
|
559
561
|
)}
|
|
560
562
|
</div>
|
|
563
|
+
<ChatSidebar chatId={selectedChatId} onClose={() => setSelectedChatId(null)} />
|
|
561
564
|
</div>
|
|
562
565
|
)
|
|
563
566
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import { Settings as SettingsIcon, EyeOff, Eye, FolderOpen, Search } from 'lucide-react'
|
|
3
|
+
import { fetchConfig, updateConfig, fetchAllProjects } from '../lib/api'
|
|
4
|
+
import { editorLabel, formatNumber, formatDate } from '../lib/constants'
|
|
5
|
+
import EditorIcon from '../components/EditorIcon'
|
|
6
|
+
import SectionTitle from '../components/SectionTitle'
|
|
7
|
+
|
|
8
|
+
export default function Settings() {
|
|
9
|
+
const [config, setConfig] = useState(null)
|
|
10
|
+
const [projects, setProjects] = useState([])
|
|
11
|
+
const [loading, setLoading] = useState(true)
|
|
12
|
+
const [saving, setSaving] = useState(false)
|
|
13
|
+
const [search, setSearch] = useState('')
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
Promise.all([fetchConfig(), fetchAllProjects()]).then(([cfg, projs]) => {
|
|
17
|
+
setConfig(cfg)
|
|
18
|
+
setProjects(projs)
|
|
19
|
+
setLoading(false)
|
|
20
|
+
}).catch(() => setLoading(false))
|
|
21
|
+
}, [])
|
|
22
|
+
|
|
23
|
+
if (loading || !config) {
|
|
24
|
+
return <div className="text-sm py-12 text-center" style={{ color: 'var(--c-text2)' }}>loading settings...</div>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hiddenProjects = config.hiddenProjects || []
|
|
28
|
+
|
|
29
|
+
const toggleProject = async (folder) => {
|
|
30
|
+
setSaving(true)
|
|
31
|
+
const isHidden = hiddenProjects.includes(folder)
|
|
32
|
+
const updated = isHidden
|
|
33
|
+
? hiddenProjects.filter(f => f !== folder)
|
|
34
|
+
: [...hiddenProjects, folder]
|
|
35
|
+
const newConfig = await updateConfig({ hiddenProjects: updated })
|
|
36
|
+
setConfig(newConfig)
|
|
37
|
+
setSaving(false)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const filtered = projects.filter(p => {
|
|
41
|
+
if (!search) return true
|
|
42
|
+
const q = search.toLowerCase()
|
|
43
|
+
return p.folder.toLowerCase().includes(q) || p.name.toLowerCase().includes(q)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const sorted = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="fade-in space-y-4">
|
|
50
|
+
<div className="flex items-center gap-1.5 text-[13px] font-bold" style={{ color: 'var(--c-white)' }}>
|
|
51
|
+
<SettingsIcon size={14} style={{ color: '#6366f1' }} />
|
|
52
|
+
Settings
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="card overflow-hidden">
|
|
56
|
+
<div className="px-3 py-2 flex items-center justify-between" style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
57
|
+
<SectionTitle>
|
|
58
|
+
<FolderOpen size={11} className="inline mr-1" />
|
|
59
|
+
projects ({projects.length})
|
|
60
|
+
</SectionTitle>
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
{hiddenProjects.length > 0 && (
|
|
63
|
+
<span className="text-[10px] px-1.5 py-0.5" style={{ background: 'rgba(239,68,68,0.08)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.15)' }}>
|
|
64
|
+
{hiddenProjects.length} hidden
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
<div className="relative">
|
|
68
|
+
<Search size={11} className="absolute left-2 top-1/2 -translate-y-1/2" style={{ color: 'var(--c-text3)' }} />
|
|
69
|
+
<input
|
|
70
|
+
type="text"
|
|
71
|
+
value={search}
|
|
72
|
+
onChange={e => setSearch(e.target.value)}
|
|
73
|
+
placeholder="Filter projects..."
|
|
74
|
+
className="pl-6 pr-2 py-1 text-[11px] outline-none w-[180px]"
|
|
75
|
+
style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="text-[11px] px-3 py-1.5" style={{ color: 'var(--c-text3)', borderBottom: '1px solid var(--c-border)', background: 'var(--c-bg3)' }}>
|
|
81
|
+
Hidden projects are excluded from all dashboard stats, sessions, costs, and analytics.
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{sorted.map(p => (
|
|
85
|
+
<ProjectRow key={p.folder} project={p} hidden={hiddenProjects.includes(p.folder)} onToggle={toggleProject} saving={saving} />
|
|
86
|
+
))}
|
|
87
|
+
|
|
88
|
+
{sorted.length === 0 && (
|
|
89
|
+
<div className="text-center py-6 text-[12px]" style={{ color: 'var(--c-text3)' }}>no projects match filter</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ProjectRow({ project: p, hidden, onToggle, saving }) {
|
|
97
|
+
const editors = Object.entries(p.editors || {}).sort((a, b) => b[1] - a[1])
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className="flex items-center gap-3 px-3 py-2 transition"
|
|
102
|
+
style={{
|
|
103
|
+
borderBottom: '1px solid var(--c-border)',
|
|
104
|
+
opacity: hidden ? 0.5 : 1,
|
|
105
|
+
}}
|
|
106
|
+
onMouseEnter={e => e.currentTarget.style.background = 'var(--c-bg3)'}
|
|
107
|
+
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
|
108
|
+
>
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => onToggle(p.folder)}
|
|
111
|
+
disabled={saving}
|
|
112
|
+
className="shrink-0 p-1 rounded transition hover:bg-[var(--c-bg)]"
|
|
113
|
+
style={{ color: hidden ? '#ef4444' : 'var(--c-text3)', border: '1px solid var(--c-border)' }}
|
|
114
|
+
title={hidden ? 'Show this project' : 'Hide this project'}
|
|
115
|
+
>
|
|
116
|
+
{hidden ? <EyeOff size={12} /> : <Eye size={12} />}
|
|
117
|
+
</button>
|
|
118
|
+
<div className="min-w-0 flex-1">
|
|
119
|
+
<div className="text-[12px] font-medium truncate" style={{ color: hidden ? 'var(--c-text3)' : 'var(--c-white)' }}>
|
|
120
|
+
{p.name}
|
|
121
|
+
</div>
|
|
122
|
+
<div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }} title={p.folder}>
|
|
123
|
+
{p.folder}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
127
|
+
{editors.slice(0, 3).map(([src, count]) => (
|
|
128
|
+
<span key={src} className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
|
|
129
|
+
<EditorIcon source={src} size={10} />
|
|
130
|
+
{count}
|
|
131
|
+
</span>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
<div className="text-[11px] font-mono shrink-0" style={{ color: 'var(--c-text2)' }}>
|
|
135
|
+
{formatNumber(p.totalSessions)}
|
|
136
|
+
</div>
|
|
137
|
+
<div className="text-[10px] shrink-0 w-[80px] text-right" style={{ color: 'var(--c-text3)' }}>
|
|
138
|
+
{formatDate(p.lastSeen)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|