agentlytics 0.1.6 → 0.1.7
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 +27 -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
|
@@ -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); }
|
|
@@ -542,6 +559,10 @@ function getCachedProjects(opts = {}) {
|
|
|
542
559
|
// Build date filter
|
|
543
560
|
let dateFilter = '';
|
|
544
561
|
const dateParams = [];
|
|
562
|
+
if (!opts.includeHidden) {
|
|
563
|
+
const hf = hiddenFolderFilter(opts);
|
|
564
|
+
if (hf.sql) { dateFilter += hf.sql; dateParams.push(...hf.params); }
|
|
565
|
+
}
|
|
545
566
|
if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
|
|
546
567
|
if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
|
|
547
568
|
|
|
@@ -745,6 +766,8 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
745
766
|
// Build conditions dynamically to support editor + date range filters
|
|
746
767
|
const conditions = [];
|
|
747
768
|
const params = [];
|
|
769
|
+
const hf = hiddenFolderFilter(opts);
|
|
770
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
748
771
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
749
772
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
750
773
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -1103,6 +1126,8 @@ function estimateCosts(whereClause = '', params = []) {
|
|
|
1103
1126
|
function getCostBreakdown(opts = {}) {
|
|
1104
1127
|
let whereClause = '';
|
|
1105
1128
|
const params = [];
|
|
1129
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
1130
|
+
if (hf.sql) { whereClause += hf.sql; params.push(...hf.params); }
|
|
1106
1131
|
if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
1107
1132
|
if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
|
|
1108
1133
|
if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
|
|
@@ -1114,6 +1139,8 @@ function getCostBreakdown(opts = {}) {
|
|
|
1114
1139
|
function getCostAnalytics(opts = {}) {
|
|
1115
1140
|
const conditions = [];
|
|
1116
1141
|
const params = [];
|
|
1142
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
1143
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
1117
1144
|
if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
|
|
1118
1145
|
if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
|
|
1119
1146
|
if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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
|
+
}
|