agentlytics 0.1.18 → 0.1.19
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/package.json +1 -1
- package/server.js +21 -0
- package/ui/src/components/AiAuditCard.jsx +255 -0
- package/ui/src/lib/api.js +6 -0
- package/ui/src/pages/ProjectDetail.jsx +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentlytics",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
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
|
@@ -333,6 +333,27 @@ app.put('/api/config', (req, res) => {
|
|
|
333
333
|
}
|
|
334
334
|
});
|
|
335
335
|
|
|
336
|
+
app.get('/api/check-ai', async (req, res) => {
|
|
337
|
+
const folder = req.query.folder;
|
|
338
|
+
if (!folder) return res.status(400).json({ error: 'folder query param required' });
|
|
339
|
+
try {
|
|
340
|
+
const { execFile } = require('child_process');
|
|
341
|
+
const result = await new Promise((resolve, reject) => {
|
|
342
|
+
execFile('npx', ['-y', 'check-ai', '--json', folder], { timeout: 60000, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
343
|
+
try {
|
|
344
|
+
const json = JSON.parse(stdout);
|
|
345
|
+
resolve(json);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
reject(new Error(err ? err.message : 'Failed to parse check-ai output'));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
res.json(result);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
res.status(500).json({ error: err.message });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
336
357
|
app.get('/api/all-projects', (req, res) => {
|
|
337
358
|
try {
|
|
338
359
|
res.json(cache.getCachedProjects({ ...parseDateOpts(req.query), includeHidden: true }));
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { ShieldCheck, Loader2, CheckCircle2, AlertTriangle, RefreshCw, Info, ChevronRight, ChevronDown, Brush, FileText, FlaskConical, Bot, Lock, Puzzle, Plug, Package } from 'lucide-react'
|
|
3
|
+
import { fetchCheckAi } from '../lib/api'
|
|
4
|
+
import SectionTitle from './SectionTitle'
|
|
5
|
+
|
|
6
|
+
const GRADE_COLORS = {
|
|
7
|
+
'A+': '#22c55e', A: '#22c55e',
|
|
8
|
+
'B+': '#4ade80', B: '#4ade80',
|
|
9
|
+
'C+': '#facc15', C: '#facc15',
|
|
10
|
+
'D+': '#f97316', D: '#f97316',
|
|
11
|
+
F: '#ef4444',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SECTION_ICONS = {
|
|
15
|
+
'Repo Hygiene': Brush,
|
|
16
|
+
'Grounding Docs': FileText,
|
|
17
|
+
'Testing': FlaskConical,
|
|
18
|
+
'Agent Configs': Bot,
|
|
19
|
+
'AI Context': Lock,
|
|
20
|
+
'Prompts & Skills': Puzzle,
|
|
21
|
+
'MCP': Plug,
|
|
22
|
+
'AI Dependencies': Package,
|
|
23
|
+
'AI Deps': Package,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Tip({ missing }) {
|
|
27
|
+
const [open, setOpen] = useState(false)
|
|
28
|
+
const ref = useRef(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!open) return
|
|
32
|
+
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
|
|
33
|
+
document.addEventListener('mousedown', handler)
|
|
34
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
35
|
+
}, [open])
|
|
36
|
+
|
|
37
|
+
const weighted = missing.filter(f => f.weight > 0)
|
|
38
|
+
const items = weighted.length > 0 ? weighted : missing.slice(0, 5)
|
|
39
|
+
if (items.length === 0) return null
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<span ref={ref} className="relative flex-shrink-0">
|
|
43
|
+
<button
|
|
44
|
+
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
|
|
45
|
+
className="flex items-center justify-center w-4 h-4 rounded-full transition hover:opacity-80"
|
|
46
|
+
style={{ color: 'var(--c-text3)', background: 'var(--c-bg3)' }}
|
|
47
|
+
>
|
|
48
|
+
<Info size={10} />
|
|
49
|
+
</button>
|
|
50
|
+
{open && (
|
|
51
|
+
<div
|
|
52
|
+
className="absolute right-0 top-6 z-50 w-64 p-2.5 rounded shadow-lg"
|
|
53
|
+
style={{ background: 'var(--c-bg)', border: '1px solid var(--c-border)', boxShadow: '0 4px 24px rgba(0,0,0,0.3)' }}
|
|
54
|
+
>
|
|
55
|
+
<div className="text-[11px] font-medium mb-1.5" style={{ color: 'var(--c-white)' }}>How to improve</div>
|
|
56
|
+
<div className="space-y-1">
|
|
57
|
+
{items.map(f => (
|
|
58
|
+
<div key={f.id} className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
|
|
59
|
+
<span style={{ color: 'var(--c-text)' }}>+ {f.label}</span>
|
|
60
|
+
{f.weight > 0 && <span style={{ color: 'var(--c-text3)' }}> ({f.weight}pt)</span>}
|
|
61
|
+
</div>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default function AiAuditCard({ folder }) {
|
|
71
|
+
const [audit, setAudit] = useState(null)
|
|
72
|
+
const [loading, setLoading] = useState(false)
|
|
73
|
+
const [error, setError] = useState(null)
|
|
74
|
+
const [expanded, setExpanded] = useState(new Set())
|
|
75
|
+
const ran = useRef(false)
|
|
76
|
+
|
|
77
|
+
const runAudit = async () => {
|
|
78
|
+
setLoading(true)
|
|
79
|
+
setError(null)
|
|
80
|
+
try {
|
|
81
|
+
const result = await fetchCheckAi(folder)
|
|
82
|
+
if (result.error) throw new Error(result.error)
|
|
83
|
+
setAudit(result)
|
|
84
|
+
} catch (e) {
|
|
85
|
+
setError(e.message)
|
|
86
|
+
}
|
|
87
|
+
setLoading(false)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (folder && !ran.current) {
|
|
92
|
+
ran.current = true
|
|
93
|
+
runAudit()
|
|
94
|
+
}
|
|
95
|
+
}, [folder])
|
|
96
|
+
|
|
97
|
+
if (loading) {
|
|
98
|
+
return (
|
|
99
|
+
<div className="card p-4">
|
|
100
|
+
<div className="flex items-center gap-2">
|
|
101
|
+
<Loader2 size={14} className="animate-spin" style={{ color: 'var(--c-accent)' }} />
|
|
102
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Running AI readiness audit...</span>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (error) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="card p-4">
|
|
111
|
+
<div className="flex items-center justify-between">
|
|
112
|
+
<div className="flex items-center gap-2">
|
|
113
|
+
<AlertTriangle size={14} style={{ color: '#ef4444' }} />
|
|
114
|
+
<span className="text-[12px]" style={{ color: 'var(--c-text2)' }}>Audit failed: {error}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<button
|
|
117
|
+
onClick={runAudit}
|
|
118
|
+
className="flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded transition hover:opacity-80"
|
|
119
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
120
|
+
>
|
|
121
|
+
<RefreshCw size={10} />
|
|
122
|
+
Retry
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!audit) return null
|
|
130
|
+
|
|
131
|
+
const gradeColor = GRADE_COLORS[audit.grade] || 'var(--c-text2)'
|
|
132
|
+
const sections = audit.sections || {}
|
|
133
|
+
const findings = audit.findings || []
|
|
134
|
+
|
|
135
|
+
const findingsBySection = {}
|
|
136
|
+
for (const f of findings) {
|
|
137
|
+
const sec = f.section || 'Other'
|
|
138
|
+
if (!findingsBySection[sec]) findingsBySection[sec] = []
|
|
139
|
+
findingsBySection[sec].push(f)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="card p-4 space-y-3">
|
|
144
|
+
{/* Header row */}
|
|
145
|
+
<div className="flex items-center gap-3">
|
|
146
|
+
<ShieldCheck size={16} style={{ color: gradeColor }} />
|
|
147
|
+
<div>
|
|
148
|
+
<SectionTitle>ai readiness audit</SectionTitle>
|
|
149
|
+
<div className="text-[10px] -mt-1.5" style={{ color: 'var(--c-text3)' }}>powered by <code style={{ fontFamily: 'JetBrains Mono, monospace' }}>npx check-ai</code></div>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="flex items-center gap-2 ml-auto">
|
|
152
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>
|
|
153
|
+
{audit.checks?.passed || 0}/{audit.checks?.total || 0} checks
|
|
154
|
+
· {audit.points?.earned || 0}/{audit.points?.max || 0} pts
|
|
155
|
+
</span>
|
|
156
|
+
<button
|
|
157
|
+
onClick={runAudit}
|
|
158
|
+
className="flex items-center gap-1 px-2 py-1 text-[11px] rounded transition hover:opacity-80"
|
|
159
|
+
style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}
|
|
160
|
+
>
|
|
161
|
+
<RefreshCw size={10} />
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Score row */}
|
|
167
|
+
<div className="flex items-center gap-3">
|
|
168
|
+
<div
|
|
169
|
+
className="w-11 h-11 rounded-lg flex items-center justify-center text-base font-black flex-shrink-0"
|
|
170
|
+
style={{ background: `${gradeColor}15`, color: gradeColor, border: `1.5px solid ${gradeColor}30` }}
|
|
171
|
+
>
|
|
172
|
+
{audit.grade}
|
|
173
|
+
</div>
|
|
174
|
+
<div className="flex-1 min-w-0">
|
|
175
|
+
<div className="flex items-baseline gap-1.5">
|
|
176
|
+
<span className="text-base font-bold" style={{ color: 'var(--c-white)' }}>{audit.score}</span>
|
|
177
|
+
<span className="text-[11px]" style={{ color: 'var(--c-text3)' }}>/10</span>
|
|
178
|
+
<span className="text-[11px] ml-1" style={{ color: 'var(--c-text2)' }}>{audit.label}</span>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="w-full h-2 rounded-full overflow-hidden mt-1" style={{ background: 'var(--c-code-bg)' }}>
|
|
181
|
+
<div className="h-full rounded-full" style={{ width: `${((audit.score || 0) / 10 * 100).toFixed(1)}%`, background: gradeColor }} />
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Section rows */}
|
|
187
|
+
<div>
|
|
188
|
+
{Object.entries(sections).map(([name, sec]) => {
|
|
189
|
+
const Icon = SECTION_ICONS[name] || FileText
|
|
190
|
+
const sectionFindings = findingsBySection[name] || []
|
|
191
|
+
const passed = sectionFindings.filter(f => f.found)
|
|
192
|
+
const missing = sectionFindings.filter(f => !f.found)
|
|
193
|
+
const pct = sec.pct || 0
|
|
194
|
+
const barColor = pct >= 70 ? '#22c55e' : pct >= 40 ? '#facc15' : pct > 0 ? '#f97316' : 'var(--c-text3)'
|
|
195
|
+
|
|
196
|
+
// Build description from found findings
|
|
197
|
+
const details = []
|
|
198
|
+
for (const f of passed) {
|
|
199
|
+
if (f.detail) details.push(f.detail)
|
|
200
|
+
else if (f.matchedPath) details.push(f.matchedPath)
|
|
201
|
+
else if (f.matches && f.matches.length > 0) details.push(f.matches.join(', '))
|
|
202
|
+
else details.push(f.label)
|
|
203
|
+
}
|
|
204
|
+
const desc = details.length > 0
|
|
205
|
+
? details.slice(0, 3).join(' · ') + (details.length > 3 ? ' …' : '')
|
|
206
|
+
: 'none detected'
|
|
207
|
+
|
|
208
|
+
const isOpen = expanded.has(name)
|
|
209
|
+
const toggle = () => setExpanded(prev => {
|
|
210
|
+
const next = new Set(prev)
|
|
211
|
+
if (next.has(name)) next.delete(name)
|
|
212
|
+
else next.add(name)
|
|
213
|
+
return next
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div key={name} style={{ borderBottom: '1px solid var(--c-border)' }}>
|
|
218
|
+
{/* Main row — clickable */}
|
|
219
|
+
<div className="flex items-center gap-2.5 py-2 cursor-pointer transition hover:bg-[var(--c-bg3)]" onClick={toggle}>
|
|
220
|
+
{isOpen
|
|
221
|
+
? <ChevronDown size={12} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
|
|
222
|
+
: <ChevronRight size={12} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
|
|
223
|
+
}
|
|
224
|
+
<Icon size={14} style={{ color: 'var(--c-text3)', flexShrink: 0 }} />
|
|
225
|
+
<span className="text-[12px] font-medium w-32 flex-shrink-0" style={{ color: 'var(--c-white)' }}>{name}</span>
|
|
226
|
+
<span className="text-[11px] flex-1 truncate" style={{ color: passed.length > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
|
|
227
|
+
{desc}
|
|
228
|
+
</span>
|
|
229
|
+
<span className="text-[11px] flex-shrink-0" style={{ color: 'var(--c-text3)' }}>{passed.length}/{sectionFindings.length}</span>
|
|
230
|
+
<div className="w-20 h-2 rounded-full overflow-hidden flex-shrink-0" style={{ background: 'var(--c-code-bg)' }}>
|
|
231
|
+
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: barColor }} />
|
|
232
|
+
</div>
|
|
233
|
+
<span className="text-[11px] w-9 text-right font-bold flex-shrink-0" style={{ color: barColor }}>{pct}%</span>
|
|
234
|
+
{missing.length > 0 && <Tip missing={missing} />}
|
|
235
|
+
</div>
|
|
236
|
+
{/* Expanded: found findings */}
|
|
237
|
+
{isOpen && passed.length > 0 && (
|
|
238
|
+
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pb-2 pl-12">
|
|
239
|
+
{passed.map(f => (
|
|
240
|
+
<span key={f.id} className="inline-flex items-center gap-1.5 text-[11px] py-0.5">
|
|
241
|
+
<CheckCircle2 size={10} style={{ color: '#22c55e', flexShrink: 0 }} />
|
|
242
|
+
<span style={{ color: 'var(--c-text)' }}>{f.label}</span>
|
|
243
|
+
{f.matchedPath && <span style={{ color: 'var(--c-text3)' }}>{f.matchedPath}</span>}
|
|
244
|
+
{f.detail && <span style={{ color: 'var(--c-text2)' }}>— {f.detail}</span>}
|
|
245
|
+
</span>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
)
|
|
251
|
+
})}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
package/ui/src/lib/api.js
CHANGED
|
@@ -203,6 +203,12 @@ export async function fetchToolCalls(name, opts = {}) {
|
|
|
203
203
|
return res.json();
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
export async function fetchCheckAi(folder) {
|
|
207
|
+
const q = new URLSearchParams({ folder });
|
|
208
|
+
const res = await fetch(`${BASE}/api/check-ai?${q}`);
|
|
209
|
+
return res.json();
|
|
210
|
+
}
|
|
211
|
+
|
|
206
212
|
export async function fetchUsage() {
|
|
207
213
|
const res = await fetch(`${BASE}/api/usage`);
|
|
208
214
|
return res.json();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import { useSearchParams, useNavigate } from 'react-router-dom'
|
|
3
|
-
import { ArrowLeft, Search, FolderOpen, Calendar, MessageSquare, Wrench, Cpu, Zap, AlertTriangle } from 'lucide-react'
|
|
3
|
+
import { ArrowLeft, Search, FolderOpen, Calendar, MessageSquare, Wrench, Cpu, Zap, AlertTriangle, ShieldCheck } from 'lucide-react'
|
|
4
4
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
|
|
5
5
|
import { Doughnut, Bar } from 'react-chartjs-2'
|
|
6
6
|
import { fetchProjects, fetchChats, fetchCosts } from '../lib/api'
|
|
@@ -10,6 +10,7 @@ import KpiCard from '../components/KpiCard'
|
|
|
10
10
|
import EditorIcon from '../components/EditorIcon'
|
|
11
11
|
import SectionTitle from '../components/SectionTitle'
|
|
12
12
|
import ChatSidebar from '../components/ChatSidebar'
|
|
13
|
+
import AiAuditCard from '../components/AiAuditCard'
|
|
13
14
|
|
|
14
15
|
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement)
|
|
15
16
|
|
|
@@ -233,6 +234,9 @@ export default function ProjectDetail() {
|
|
|
233
234
|
</div>
|
|
234
235
|
</div>
|
|
235
236
|
|
|
237
|
+
{/* AI Readiness Audit */}
|
|
238
|
+
<AiAuditCard folder={folder} />
|
|
239
|
+
|
|
236
240
|
{/* Sessions */}
|
|
237
241
|
<div>
|
|
238
242
|
<div className="flex items-center gap-3 mb-2">
|