agentlytics 0.0.8 → 0.0.10
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 +8 -2
- package/cache.js +67 -24
- package/editors/codex.js +453 -0
- package/editors/commandcode.js +159 -0
- package/editors/index.js +3 -1
- package/index.js +1 -1
- package/package.json +3 -2
- package/server.js +15 -4
- package/ui/src/App.jsx +40 -3
- package/ui/src/components/ActivityHeatmap.jsx +11 -7
- package/ui/src/components/ChatSidebar.jsx +313 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/index.css +6 -0
- package/ui/src/lib/api.js +16 -2
- package/ui/src/lib/constants.js +16 -0
- package/ui/src/pages/Dashboard.jsx +14 -14
- package/ui/src/pages/DeepAnalysis.jsx +6 -3
- package/ui/src/pages/ProjectDetail.jsx +236 -0
- package/ui/src/pages/Projects.jsx +50 -121
- package/ui/src/pages/Sessions.jsx +58 -46
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const COMMANDCODE_DIR = path.join(os.homedir(), '.commandcode');
|
|
6
|
+
const PROJECTS_DIR = path.join(COMMANDCODE_DIR, 'projects');
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// Adapter interface
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
const name = 'commandcode';
|
|
13
|
+
|
|
14
|
+
function getChats() {
|
|
15
|
+
const chats = [];
|
|
16
|
+
if (!fs.existsSync(PROJECTS_DIR)) return chats;
|
|
17
|
+
|
|
18
|
+
let projDirs;
|
|
19
|
+
try { projDirs = fs.readdirSync(PROJECTS_DIR); } catch { return chats; }
|
|
20
|
+
|
|
21
|
+
for (const projDir of projDirs) {
|
|
22
|
+
const dir = path.join(PROJECTS_DIR, projDir);
|
|
23
|
+
try { if (!fs.statSync(dir).isDirectory()) continue; } catch { continue; }
|
|
24
|
+
|
|
25
|
+
// Decode folder path from dir name (e.g. users-fka-code-foo -> /users/fka/code/foo)
|
|
26
|
+
const decodedFolder = '/' + projDir.replace(/-/g, '/');
|
|
27
|
+
|
|
28
|
+
let files;
|
|
29
|
+
try { files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl') && !f.includes('.checkpoints.')); } catch { continue; }
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
const sessionId = file.replace('.jsonl', '');
|
|
33
|
+
const fullPath = path.join(dir, file);
|
|
34
|
+
const metaPath = path.join(dir, `${sessionId}.meta.json`);
|
|
35
|
+
|
|
36
|
+
// Read meta.json for title
|
|
37
|
+
let title = null;
|
|
38
|
+
try {
|
|
39
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
40
|
+
title = meta.title || null;
|
|
41
|
+
} catch { /* no meta */ }
|
|
42
|
+
|
|
43
|
+
// Parse first and last lines for timestamps
|
|
44
|
+
try {
|
|
45
|
+
const stat = fs.statSync(fullPath);
|
|
46
|
+
const lines = fs.readFileSync(fullPath, 'utf-8').split('\n').filter(Boolean);
|
|
47
|
+
if (lines.length === 0) continue;
|
|
48
|
+
|
|
49
|
+
const first = JSON.parse(lines[0]);
|
|
50
|
+
const last = JSON.parse(lines[lines.length - 1]);
|
|
51
|
+
|
|
52
|
+
const firstPrompt = extractFirstPrompt(first);
|
|
53
|
+
const bubbleCount = lines.length;
|
|
54
|
+
|
|
55
|
+
chats.push({
|
|
56
|
+
source: 'commandcode',
|
|
57
|
+
composerId: sessionId,
|
|
58
|
+
name: title || cleanPrompt(firstPrompt),
|
|
59
|
+
createdAt: first.timestamp ? new Date(first.timestamp).getTime() : stat.birthtime.getTime(),
|
|
60
|
+
lastUpdatedAt: last.timestamp ? new Date(last.timestamp).getTime() : stat.mtime.getTime(),
|
|
61
|
+
mode: 'commandcode',
|
|
62
|
+
folder: decodedFolder,
|
|
63
|
+
encrypted: false,
|
|
64
|
+
bubbleCount,
|
|
65
|
+
_fullPath: fullPath,
|
|
66
|
+
_gitBranch: first.gitBranch || null,
|
|
67
|
+
});
|
|
68
|
+
} catch { /* skip */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return chats;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractFirstPrompt(obj) {
|
|
76
|
+
if (obj.role !== 'user') return null;
|
|
77
|
+
if (!obj.content) return null;
|
|
78
|
+
if (typeof obj.content === 'string') return obj.content;
|
|
79
|
+
if (Array.isArray(obj.content)) {
|
|
80
|
+
return obj.content
|
|
81
|
+
.filter(c => c.type === 'text')
|
|
82
|
+
.map(c => c.text)
|
|
83
|
+
.join(' ')
|
|
84
|
+
.substring(0, 200);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cleanPrompt(text) {
|
|
90
|
+
if (!text) return null;
|
|
91
|
+
return text.replace(/\s+/g, ' ').trim().substring(0, 120) || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getMessages(chat) {
|
|
95
|
+
const filePath = chat._fullPath;
|
|
96
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
97
|
+
|
|
98
|
+
const messages = [];
|
|
99
|
+
const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean);
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
let obj;
|
|
103
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
104
|
+
|
|
105
|
+
if (obj.role === 'user') {
|
|
106
|
+
const content = extractContent(obj.content);
|
|
107
|
+
if (content) messages.push({ role: 'user', content });
|
|
108
|
+
} else if (obj.role === 'assistant') {
|
|
109
|
+
const { text, toolCalls } = extractAssistantContent(obj.content);
|
|
110
|
+
if (text) {
|
|
111
|
+
messages.push({
|
|
112
|
+
role: 'assistant',
|
|
113
|
+
content: text,
|
|
114
|
+
_toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} else if (obj.role === 'system') {
|
|
118
|
+
const text = extractContent(obj.content);
|
|
119
|
+
if (text) messages.push({ role: 'system', content: text });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return messages;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractContent(content) {
|
|
127
|
+
if (typeof content === 'string') return content;
|
|
128
|
+
if (!Array.isArray(content)) return '';
|
|
129
|
+
// Skip tool_result blocks for user messages (they are tool outputs)
|
|
130
|
+
return content
|
|
131
|
+
.filter(c => c.type === 'text')
|
|
132
|
+
.map(c => c.text)
|
|
133
|
+
.join('\n') || '';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractAssistantContent(content) {
|
|
137
|
+
if (typeof content === 'string') return { text: content, toolCalls: [] };
|
|
138
|
+
if (!Array.isArray(content)) return { text: '', toolCalls: [] };
|
|
139
|
+
const parts = [];
|
|
140
|
+
const toolCalls = [];
|
|
141
|
+
for (const block of content) {
|
|
142
|
+
if (block.type === 'thinking' && block.thinking) {
|
|
143
|
+
parts.push(`[thinking] ${block.thinking}`);
|
|
144
|
+
} else if (block.type === 'text' && block.text) {
|
|
145
|
+
parts.push(block.text);
|
|
146
|
+
} else if (block.type === 'tool_use') {
|
|
147
|
+
const args = block.input || {};
|
|
148
|
+
const argKeys = Object.keys(args).join(', ');
|
|
149
|
+
parts.push(`[tool-call: ${block.name || 'unknown'}(${argKeys})]`);
|
|
150
|
+
toolCalls.push({ name: block.name || 'unknown', args });
|
|
151
|
+
} else if (block.type === 'tool_result') {
|
|
152
|
+
const text = typeof block.content === 'string' ? block.content : '';
|
|
153
|
+
parts.push(`[tool-result: ${block.name || 'tool'}] ${text.substring(0, 500)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { text: parts.join('\n') || '', toolCalls };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { name, getChats, getMessages };
|
package/editors/index.js
CHANGED
|
@@ -4,11 +4,13 @@ const claude = require('./claude');
|
|
|
4
4
|
const vscode = require('./vscode');
|
|
5
5
|
const zed = require('./zed');
|
|
6
6
|
const opencode = require('./opencode');
|
|
7
|
+
const codex = require('./codex');
|
|
7
8
|
const gemini = require('./gemini');
|
|
8
9
|
const copilot = require('./copilot');
|
|
9
10
|
const cursorAgent = require('./cursor-agent');
|
|
11
|
+
const commandcode = require('./commandcode');
|
|
10
12
|
|
|
11
|
-
const editors = [cursor, windsurf, claude, vscode, zed, opencode, gemini, copilot, cursorAgent];
|
|
13
|
+
const editors = [cursor, windsurf, claude, vscode, zed, opencode, codex, gemini, copilot, cursorAgent, commandcode];
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* 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, Gemini CLI, Copilot CLI, Cursor Agent'));
|
|
97
|
+
console.log(chalk.dim(' Scanning editors: Cursor, Windsurf, Claude Code, VS Code, Zed, Antigravity, OpenCode, Codex, Gemini CLI, Copilot CLI, Cursor Agent'));
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Comprehensive analytics dashboard for AI coding agents — Cursor, Windsurf, Claude Code, VS Code Copilot, Zed, Antigravity, OpenCode",
|
|
3
|
+
"version": "0.0.10",
|
|
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": {
|
|
7
7
|
"agentlytics": "./index.js"
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"zed",
|
|
35
35
|
"antigravity",
|
|
36
36
|
"opencode",
|
|
37
|
+
"codex",
|
|
37
38
|
"analytics",
|
|
38
39
|
"ai",
|
|
39
40
|
"agent"
|
package/server.js
CHANGED
|
@@ -11,9 +11,17 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
11
11
|
// API endpoints — all reads from SQLite cache
|
|
12
12
|
// ============================================================
|
|
13
13
|
|
|
14
|
+
// Helper: parse date query params into Unix ms timestamps
|
|
15
|
+
function parseDateOpts(query) {
|
|
16
|
+
const opts = {};
|
|
17
|
+
if (query.dateFrom) opts.dateFrom = parseInt(query.dateFrom) || null;
|
|
18
|
+
if (query.dateTo) opts.dateTo = parseInt(query.dateTo) || null;
|
|
19
|
+
return opts;
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
app.get('/api/overview', (req, res) => {
|
|
15
23
|
try {
|
|
16
|
-
const opts = { editor: req.query.editor || null };
|
|
24
|
+
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|
|
17
25
|
res.json(cache.getCachedOverview(opts));
|
|
18
26
|
} catch (err) {
|
|
19
27
|
res.status(500).json({ error: err.message });
|
|
@@ -22,7 +30,7 @@ app.get('/api/overview', (req, res) => {
|
|
|
22
30
|
|
|
23
31
|
app.get('/api/daily-activity', (req, res) => {
|
|
24
32
|
try {
|
|
25
|
-
const opts = { editor: req.query.editor || null };
|
|
33
|
+
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|
|
26
34
|
res.json(cache.getCachedDailyActivity(opts));
|
|
27
35
|
} catch (err) {
|
|
28
36
|
res.status(500).json({ error: err.message });
|
|
@@ -37,6 +45,7 @@ app.get('/api/chats', (req, res) => {
|
|
|
37
45
|
named: req.query.named !== 'false',
|
|
38
46
|
limit: req.query.limit ? parseInt(req.query.limit) : 200,
|
|
39
47
|
offset: req.query.offset ? parseInt(req.query.offset) : 0,
|
|
48
|
+
...parseDateOpts(req.query),
|
|
40
49
|
};
|
|
41
50
|
const total = cache.countCachedChats(opts);
|
|
42
51
|
const rows = cache.getCachedChats(opts);
|
|
@@ -52,6 +61,7 @@ app.get('/api/chats', (req, res) => {
|
|
|
52
61
|
lastUpdatedAt: c.last_updated_at,
|
|
53
62
|
encrypted: !!c.encrypted,
|
|
54
63
|
bubbleCount: c.bubble_count,
|
|
64
|
+
topModel: c.top_model || null,
|
|
55
65
|
})),
|
|
56
66
|
});
|
|
57
67
|
} catch (err) {
|
|
@@ -116,7 +126,7 @@ app.get('/api/chats/:id/markdown', (req, res) => {
|
|
|
116
126
|
|
|
117
127
|
app.get('/api/projects', (req, res) => {
|
|
118
128
|
try {
|
|
119
|
-
res.json(cache.getCachedProjects());
|
|
129
|
+
res.json(cache.getCachedProjects(parseDateOpts(req.query)));
|
|
120
130
|
} catch (err) {
|
|
121
131
|
res.status(500).json({ error: err.message });
|
|
122
132
|
}
|
|
@@ -128,6 +138,7 @@ app.get('/api/deep-analytics', (req, res) => {
|
|
|
128
138
|
editor: req.query.editor || null,
|
|
129
139
|
folder: req.query.folder || null,
|
|
130
140
|
limit: Math.min(parseInt(req.query.limit) || 500, 5000),
|
|
141
|
+
...parseDateOpts(req.query),
|
|
131
142
|
};
|
|
132
143
|
res.json(cache.getCachedDeepAnalytics(opts));
|
|
133
144
|
} catch (err) {
|
|
@@ -137,7 +148,7 @@ app.get('/api/deep-analytics', (req, res) => {
|
|
|
137
148
|
|
|
138
149
|
app.get('/api/dashboard-stats', (req, res) => {
|
|
139
150
|
try {
|
|
140
|
-
const opts = { editor: req.query.editor || null };
|
|
151
|
+
const opts = { editor: req.query.editor || null, ...parseDateOpts(req.query) };
|
|
141
152
|
res.json(cache.getCachedDashboardStats(opts));
|
|
142
153
|
} catch (err) {
|
|
143
154
|
res.status(500).json({ error: err.message });
|
package/ui/src/App.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
2
|
import { Routes, Route, NavLink } from 'react-router-dom'
|
|
3
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'
|
|
@@ -9,17 +9,37 @@ 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 ProjectDetail from './pages/ProjectDetail'
|
|
12
13
|
import SqlViewer from './pages/SqlViewer'
|
|
13
14
|
|
|
14
15
|
export default function App() {
|
|
15
16
|
const [overview, setOverview] = useState(null)
|
|
16
17
|
const [refetchState, setRefetchState] = useState(null) // null | { scanned, total }
|
|
18
|
+
const [live, setLive] = useState(false)
|
|
19
|
+
const liveRef = useRef(null)
|
|
17
20
|
const { dark, toggle } = useTheme()
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
const refreshOverview = useCallback(() => {
|
|
20
23
|
fetchOverview().then(setOverview)
|
|
21
24
|
}, [])
|
|
22
25
|
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
refreshOverview()
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
// Live mode: refetch overview every 60s
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (live) {
|
|
33
|
+
liveRef.current = setInterval(() => {
|
|
34
|
+
refreshOverview()
|
|
35
|
+
}, 60000)
|
|
36
|
+
} else {
|
|
37
|
+
if (liveRef.current) clearInterval(liveRef.current)
|
|
38
|
+
liveRef.current = null
|
|
39
|
+
}
|
|
40
|
+
return () => { if (liveRef.current) clearInterval(liveRef.current) }
|
|
41
|
+
}, [live, refreshOverview])
|
|
42
|
+
|
|
23
43
|
const handleRefetch = async () => {
|
|
24
44
|
setRefetchState({ scanned: 0, total: 0 })
|
|
25
45
|
try {
|
|
@@ -61,6 +81,22 @@ export default function App() {
|
|
|
61
81
|
))}
|
|
62
82
|
</nav>
|
|
63
83
|
<div className="ml-auto flex items-center gap-3">
|
|
84
|
+
<button
|
|
85
|
+
onClick={() => setLive(!live)}
|
|
86
|
+
className="flex items-center gap-1.5 px-2 py-0.5 text-[10px] transition"
|
|
87
|
+
style={{
|
|
88
|
+
color: live ? '#22c55e' : 'var(--c-text3)',
|
|
89
|
+
border: live ? '1px solid rgba(34,197,94,0.3)' : '1px solid var(--c-border)',
|
|
90
|
+
background: live ? 'rgba(34,197,94,0.08)' : 'transparent',
|
|
91
|
+
}}
|
|
92
|
+
title={live ? 'Disable live refresh' : 'Enable live refresh (every 60s)'}
|
|
93
|
+
>
|
|
94
|
+
<span
|
|
95
|
+
className={`inline-block w-1.5 h-1.5 rounded-full ${live ? 'pulse-dot' : ''}`}
|
|
96
|
+
style={{ background: live ? '#22c55e' : 'var(--c-text3)' }}
|
|
97
|
+
/>
|
|
98
|
+
Live
|
|
99
|
+
</button>
|
|
64
100
|
<button
|
|
65
101
|
onClick={handleRefetch}
|
|
66
102
|
disabled={!!refetchState}
|
|
@@ -98,8 +134,9 @@ export default function App() {
|
|
|
98
134
|
<Routes>
|
|
99
135
|
<Route path="/" element={<Dashboard overview={overview} />} />
|
|
100
136
|
<Route path="/projects" element={<Projects overview={overview} />} />
|
|
137
|
+
<Route path="/projects/detail" element={<ProjectDetail />} />
|
|
101
138
|
<Route path="/sessions" element={<Sessions overview={overview} />} />
|
|
102
|
-
|
|
139
|
+
{/* ChatDetail is now a sidebar in Sessions */}
|
|
103
140
|
<Route path="/analysis" element={<DeepAnalysis overview={overview} />} />
|
|
104
141
|
<Route path="/compare" element={<Compare overview={overview} />} />
|
|
105
142
|
<Route path="/sql" element={<SqlViewer />} />
|
|
@@ -2,6 +2,7 @@ import { useState, useMemo, useRef } from 'react'
|
|
|
2
2
|
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from 'chart.js'
|
|
3
3
|
import { Line } from 'react-chartjs-2'
|
|
4
4
|
import { editorColor, editorLabel } from '../lib/constants'
|
|
5
|
+
import { useTheme } from '../lib/theme'
|
|
5
6
|
|
|
6
7
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
|
|
7
8
|
|
|
@@ -24,6 +25,7 @@ const INTENSITY_COLORS_DARK = ['rgba(255,255,255,0.03)', '#0e4429', '#006d32', '
|
|
|
24
25
|
const INTENSITY_COLORS_LIGHT = ['rgba(0,0,0,0.04)', '#9be9a8', '#40c463', '#30a14e', '#216e39']
|
|
25
26
|
|
|
26
27
|
export default function ActivityHeatmap({ dailyData }) {
|
|
28
|
+
const { dark } = useTheme()
|
|
27
29
|
const [selectedDay, setSelectedDay] = useState(null)
|
|
28
30
|
const containerRef = useRef(null)
|
|
29
31
|
|
|
@@ -96,8 +98,10 @@ export default function ActivityHeatmap({ dailyData }) {
|
|
|
96
98
|
|
|
97
99
|
if (!grid.weeks.length) return null
|
|
98
100
|
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
+
const COLORS = dark ? INTENSITY_COLORS_DARK : INTENSITY_COLORS_LIGHT
|
|
102
|
+
const txtDim = dark ? '#555' : '#999'
|
|
103
|
+
const gridColor = dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.06)'
|
|
104
|
+
const legendColor = dark ? '#888' : '#555'
|
|
101
105
|
const svgWidth = WEEK_COLS * (CELL_SIZE + CELL_GAP) + 28
|
|
102
106
|
const svgHeight = 7 * (CELL_SIZE + CELL_GAP) + 20
|
|
103
107
|
|
|
@@ -172,19 +176,19 @@ export default function ActivityHeatmap({ dailyData }) {
|
|
|
172
176
|
interaction: { mode: 'index', intersect: false },
|
|
173
177
|
scales: {
|
|
174
178
|
x: {
|
|
175
|
-
grid: { color:
|
|
176
|
-
ticks: { color:
|
|
179
|
+
grid: { color: gridColor },
|
|
180
|
+
ticks: { color: txtDim, font: { size: 9, family: 'JetBrains Mono, monospace' }, maxRotation: 0 },
|
|
177
181
|
},
|
|
178
182
|
y: {
|
|
179
183
|
beginAtZero: true,
|
|
180
|
-
grid: { color:
|
|
181
|
-
ticks: { color:
|
|
184
|
+
grid: { color: gridColor },
|
|
185
|
+
ticks: { color: txtDim, stepSize: 1, font: { size: 9, family: 'JetBrains Mono, monospace' } },
|
|
182
186
|
},
|
|
183
187
|
},
|
|
184
188
|
plugins: {
|
|
185
189
|
legend: {
|
|
186
190
|
position: 'top',
|
|
187
|
-
labels: { color:
|
|
191
|
+
labels: { color: legendColor, font: { size: 9, family: 'JetBrains Mono, monospace' }, usePointStyle: true, pointStyle: 'circle', padding: 8 },
|
|
188
192
|
},
|
|
189
193
|
tooltip: {
|
|
190
194
|
bodyFont: { family: 'JetBrains Mono, monospace', size: 10 },
|