neoagent 2.4.2-beta.4 → 2.4.2-beta.6
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/.env.example +0 -9
- package/README.md +48 -18
- package/docs/configuration.md +0 -10
- package/flutter_app/lib/main.dart +0 -1
- package/flutter_app/lib/main_controller.dart +0 -160
- package/flutter_app/lib/main_models.dart +28 -0
- package/flutter_app/lib/main_operations.dart +17 -1
- package/flutter_app/lib/main_runtime.dart +0 -22
- package/flutter_app/lib/main_settings.dart +9 -0
- package/flutter_app/lib/main_shared.dart +0 -161
- package/flutter_app/lib/src/backend_client.dart +8 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
- package/flutter_app/pubspec.lock +0 -8
- package/flutter_app/pubspec.yaml +0 -1
- package/package.json +1 -1
- package/server/admin/admin.css +23 -0
- package/server/admin/admin.js +17 -19
- package/server/admin/analytics.js +254 -48
- package/server/admin/index.html +9 -2
- package/server/db/database.js +112 -0
- package/server/http/routes.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/AssetManifest.bin +1 -1
- package/server/public/assets/AssetManifest.bin.json +1 -1
- package/server/public/assets/NOTICES +0 -183
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +77723 -78469
- package/server/routes/admin.js +73 -24
- package/server/routes/mcp.js +9 -0
- package/server/routes/memory.js +35 -2
- package/server/routes/runtime.js +5 -2
- package/server/routes/settings.js +34 -1
- package/server/routes/skills.js +42 -0
- package/server/routes/task_webhooks.js +65 -0
- package/server/services/ai/engine.js +525 -24
- package/server/services/ai/learning.js +56 -8
- package/server/services/ai/models.js +4 -4
- package/server/services/ai/providers/anthropic.js +40 -8
- package/server/services/ai/providers/google.js +10 -0
- package/server/services/ai/providers/openai.js +3 -15
- package/server/services/ai/providers/openaiCompatible.js +11 -0
- package/server/services/ai/repetitionGuard.js +60 -0
- package/server/services/ai/systemPrompt.js +52 -16
- package/server/services/ai/taskAnalysis.js +15 -6
- package/server/services/ai/toolEvidence.js +3 -1
- package/server/services/ai/toolRunner.js +43 -1
- package/server/services/ai/toolSelector.js +92 -46
- package/server/services/ai/tools.js +89 -9
- package/server/services/ai/usage.js +114 -0
- package/server/services/manager.js +34 -0
- package/server/services/memory/manager.js +170 -4
- package/server/services/security/capability_audit.js +107 -0
- package/server/services/tasks/adapters/index.js +1 -0
- package/server/services/tasks/adapters/webhook.js +14 -0
- package/server/services/tasks/runtime.js +1 -1
- package/server/services/tasks/webhooks.js +146 -0
- package/server/services/workspace/code_navigation.js +194 -0
- package/server/services/workspace/structured_data.js +93 -0
- package/server/utils/cloud-security.js +24 -2
- package/flutter_app/lib/src/analytics_service.dart +0 -294
- package/server/config/analytics.js +0 -30
|
@@ -2,12 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
// ── Analytics ─────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
+
let _analyticsRange = 30;
|
|
6
|
+
|
|
7
|
+
function setAnalyticsRange(days, btn) {
|
|
8
|
+
_analyticsRange = days;
|
|
9
|
+
document.querySelectorAll('.range-btn').forEach((b) => b.classList.remove('active'));
|
|
10
|
+
if (btn) btn.classList.add('active');
|
|
11
|
+
loadAnalytics();
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
async function loadAnalytics() {
|
|
6
15
|
const el = document.getElementById('analytics-content');
|
|
7
16
|
if (!el) return;
|
|
8
17
|
el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
|
|
9
18
|
try {
|
|
10
|
-
const data = await api(
|
|
19
|
+
const data = await api(`/admin/api/analytics?range=${_analyticsRange}`).then((r) => r.json());
|
|
11
20
|
renderAnalytics(el, data);
|
|
12
21
|
setTs('analytics-ts', 'Updated');
|
|
13
22
|
} catch (err) {
|
|
@@ -15,89 +24,285 @@ async function loadAnalytics() {
|
|
|
15
24
|
}
|
|
16
25
|
}
|
|
17
26
|
|
|
18
|
-
function renderAnalytics(el, { stats, topUsers, recentRuns }) {
|
|
27
|
+
function renderAnalytics(el, { stats, runsByDay, usersByDay, modelBreakdown, statusBreakdown, topUsers, recentRuns }) {
|
|
19
28
|
const s = stats || {};
|
|
20
29
|
el.innerHTML = `
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
${renderStatGrid(s)}
|
|
31
|
+
${renderCharts(runsByDay || [], usersByDay || [], _analyticsRange)}
|
|
32
|
+
${renderBreakdowns(modelBreakdown || [], statusBreakdown || [])}
|
|
33
|
+
${renderTopUsers(topUsers || [])}
|
|
34
|
+
${renderRecentRuns(recentRuns || [])}
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Stat cards ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function renderStatGrid(s) {
|
|
41
|
+
const cards = [
|
|
42
|
+
{ label: 'Total Users', value: fmtNum(s.totalUsers), sub: null },
|
|
43
|
+
{ label: 'Active Today', value: fmtNum(s.activeToday), sub: null },
|
|
44
|
+
{ label: 'New This Week', value: fmtNum(s.newThisWeek), sub: null },
|
|
45
|
+
{ label: 'Active Sessions', value: fmtNum(s.activeSessions), sub: null },
|
|
46
|
+
{ label: 'Total Runs', value: fmtNum(s.totalRuns), sub: null },
|
|
47
|
+
{ label: 'Runs Today', value: fmtNum(s.runsToday), sub: null },
|
|
48
|
+
{ label: 'Runs This Week', value: fmtNum(s.runsThisWeek), sub: null },
|
|
49
|
+
{ label: 'Success Rate', value: (s.successRate ?? '—') + (s.successRate != null ? '%' : ''), sub: null },
|
|
50
|
+
{ label: 'Total Tokens', value: fmtTokens(s.totalTokens), sub: null },
|
|
51
|
+
{ label: 'Tokens Today', value: fmtTokens(s.tokensToday), sub: null },
|
|
52
|
+
{ label: 'Avg Tokens/Run', value: fmtTokens(s.avgTokensPerRun), sub: null },
|
|
53
|
+
{ label: 'Artifact Storage',value: fmtBytes(s.totalStorage), sub: null },
|
|
54
|
+
];
|
|
55
|
+
return `<div class="stat-grid">${cards.map((c) => `
|
|
56
|
+
<div class="stat-card">
|
|
57
|
+
<div class="stat-value">${c.value}</div>
|
|
58
|
+
<div class="stat-label">${c.label}</div>
|
|
59
|
+
</div>`).join('')}
|
|
60
|
+
</div>`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── SVG Charts ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function renderCharts(runsByDay, usersByDay, range) {
|
|
66
|
+
return `
|
|
67
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px;">
|
|
68
|
+
<div class="card">
|
|
69
|
+
<div class="card-title" style="margin-bottom:12px;">Runs per Day</div>
|
|
70
|
+
${buildBarChart(fillDates(runsByDay, range, 'runs'), 'runs', 'tokens')}
|
|
37
71
|
</div>
|
|
38
|
-
<div class="
|
|
39
|
-
<div class="
|
|
40
|
-
|
|
72
|
+
<div class="card">
|
|
73
|
+
<div class="card-title" style="margin-bottom:12px;">Tokens per Day</div>
|
|
74
|
+
${buildAreaChart(fillDates(runsByDay, range, 'tokens'), 'tokens')}
|
|
41
75
|
</div>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
76
|
+
</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fillDates(data, range, _key) {
|
|
80
|
+
const map = new Map((data || []).map((d) => [d.date, d]));
|
|
81
|
+
const out = [];
|
|
82
|
+
for (let i = range - 1; i >= 0; i--) {
|
|
83
|
+
const d = new Date(Date.now() - i * 86_400_000);
|
|
84
|
+
const key = d.toISOString().slice(0, 10);
|
|
85
|
+
out.push(map.get(key) || { date: key, runs: 0, tokens: 0 });
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildBarChart(data, key, altKey) {
|
|
91
|
+
const W = 560, H = 110, pad = { top: 8, bottom: 22, left: 4, right: 4 };
|
|
92
|
+
const vals = data.map((d) => d[key] || 0);
|
|
93
|
+
const max = Math.max(...vals, 1);
|
|
94
|
+
const n = data.length;
|
|
95
|
+
const gap = 2;
|
|
96
|
+
const barW = Math.max(1, Math.floor((W - pad.left - pad.right - (n - 1) * gap) / n));
|
|
97
|
+
const innerH = H - pad.top - pad.bottom;
|
|
98
|
+
|
|
99
|
+
const accentColor = 'var(--accent)';
|
|
100
|
+
const mutedColor = 'rgba(126,210,126,0.18)';
|
|
101
|
+
|
|
102
|
+
const bars = data.map((d, i) => {
|
|
103
|
+
const v = d[key] || 0;
|
|
104
|
+
const bh = v === 0 ? 1 : Math.max(2, Math.round((v / max) * innerH));
|
|
105
|
+
const x = pad.left + i * (barW + gap);
|
|
106
|
+
const y = pad.top + innerH - bh;
|
|
107
|
+
const altV = altKey ? (d[altKey] || 0) : 0;
|
|
108
|
+
const label = altKey ? `${d.date}\n${key}: ${fmtNum(v)}\n${altKey}: ${fmtTokens(altV)}` : `${d.date}: ${fmtNum(v)}`;
|
|
109
|
+
return `<rect x="${x}" y="${y}" width="${barW}" height="${bh}"
|
|
110
|
+
fill="${v > 0 ? accentColor : mutedColor}" rx="1">
|
|
111
|
+
<title>${esc(label)}</title></rect>`;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// X-axis date labels — only first, middle, last
|
|
115
|
+
const labelIdxs = [0, Math.floor(n / 2), n - 1];
|
|
116
|
+
const labels = labelIdxs.map((i) => {
|
|
117
|
+
const x = pad.left + i * (barW + gap) + barW / 2;
|
|
118
|
+
const y = H - 4;
|
|
119
|
+
return `<text x="${x}" y="${y}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc(data[i]?.date?.slice(5) || '')}</text>`;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;">${bars.join('')}${labels.join('')}</svg>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildAreaChart(data, key) {
|
|
126
|
+
const W = 560, H = 110, pad = { top: 8, bottom: 22, left: 4, right: 4 };
|
|
127
|
+
const vals = data.map((d) => d[key] || 0);
|
|
128
|
+
const max = Math.max(...vals, 1);
|
|
129
|
+
const n = data.length;
|
|
130
|
+
const innerW = W - pad.left - pad.right;
|
|
131
|
+
const innerH = H - pad.top - pad.bottom;
|
|
132
|
+
|
|
133
|
+
const pts = data.map((d, i) => {
|
|
134
|
+
const x = pad.left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
|
|
135
|
+
const v = d[key] || 0;
|
|
136
|
+
const y = pad.top + innerH - Math.max(0, (v / max) * innerH);
|
|
137
|
+
return [x, y, d.date, v];
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!pts.length) return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;"></svg>`;
|
|
141
|
+
|
|
142
|
+
const linePts = pts.map(([x, y]) => `${x},${y}`).join(' ');
|
|
143
|
+
const bottomY = pad.top + innerH;
|
|
144
|
+
const areaPath = `M ${pts[0][0]},${bottomY} L ${pts.map(([x, y]) => `${x},${y}`).join(' L ')} L ${pts[pts.length - 1][0]},${bottomY} Z`;
|
|
145
|
+
|
|
146
|
+
const dots = pts.map(([x, y, date, v]) =>
|
|
147
|
+
`<circle cx="${x}" cy="${y}" r="2.5" fill="var(--accent)" opacity="0.85">
|
|
148
|
+
<title>${esc(date)}: ${fmtTokens(v)}</title>
|
|
149
|
+
</circle>`);
|
|
150
|
+
|
|
151
|
+
const labelIdxs = [0, Math.floor(n / 2), n - 1];
|
|
152
|
+
const labels = labelIdxs.map((i) => {
|
|
153
|
+
const [x, , date] = pts[i] || [];
|
|
154
|
+
if (x == null) return '';
|
|
155
|
+
return `<text x="${x}" y="${H - 4}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc((date || '').slice(5))}</text>`;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;">
|
|
159
|
+
<defs>
|
|
160
|
+
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
|
|
161
|
+
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0.28"/>
|
|
162
|
+
<stop offset="100%" stop-color="var(--accent)" stop-opacity="0.03"/>
|
|
163
|
+
</linearGradient>
|
|
164
|
+
</defs>
|
|
165
|
+
<path d="${areaPath}" fill="url(#areaGrad)"/>
|
|
166
|
+
<polyline points="${linePts}" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
|
|
167
|
+
${dots.join('')}
|
|
168
|
+
${labels.join('')}
|
|
169
|
+
</svg>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Breakdowns ─────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function renderBreakdowns(modelBreakdown, statusBreakdown) {
|
|
175
|
+
return `
|
|
176
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px;">
|
|
177
|
+
${renderModelBreakdown(modelBreakdown)}
|
|
178
|
+
${renderStatusBreakdown(statusBreakdown)}
|
|
179
|
+
</div>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderModelBreakdown(models) {
|
|
183
|
+
if (!models.length) return '<div class="card"><div class="card-title">Model Usage</div><div class="empty" style="padding:20px 0;">No data</div></div>';
|
|
184
|
+
const maxRuns = Math.max(...models.map((m) => m.runs), 1);
|
|
185
|
+
return `
|
|
186
|
+
<div class="card">
|
|
187
|
+
<div class="card-title" style="margin-bottom:12px;">Model Usage (selected period)</div>
|
|
188
|
+
<table class="analytics-table">
|
|
189
|
+
<thead><tr><th>Model</th><th style="text-align:right;">Runs</th><th style="text-align:right;">Tokens</th><th style="width:80px;"></th></tr></thead>
|
|
190
|
+
<tbody>${models.map((m) => `
|
|
191
|
+
<tr>
|
|
192
|
+
<td style="font-family:var(--font-mono);font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(m.model)}</td>
|
|
193
|
+
<td style="text-align:right;">${fmtNum(m.runs)}</td>
|
|
194
|
+
<td style="text-align:right;">${fmtTokens(m.tokens)}</td>
|
|
195
|
+
<td><div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((m.runs / maxRuns) * 100)}%"></div></div></td>
|
|
196
|
+
</tr>`).join('')}
|
|
197
|
+
</tbody>
|
|
198
|
+
</table>
|
|
199
|
+
</div>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const STATUS_COLORS = {
|
|
203
|
+
completed: 'var(--success)',
|
|
204
|
+
error: 'var(--danger)',
|
|
205
|
+
running: 'var(--accent)',
|
|
206
|
+
pending: 'var(--text-muted)',
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function renderStatusBreakdown(statuses) {
|
|
210
|
+
if (!statuses.length) return '<div class="card"><div class="card-title">Run Status</div><div class="empty" style="padding:20px 0;">No data</div></div>';
|
|
211
|
+
const total = statuses.reduce((a, s) => a + s.count, 0);
|
|
212
|
+
const pills = statuses.map((s) => {
|
|
213
|
+
const pct = total > 0 ? Math.round((s.count / total) * 100) : 0;
|
|
214
|
+
const color = STATUS_COLORS[s.status] || 'var(--text-muted)';
|
|
215
|
+
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);">
|
|
216
|
+
<div style="display:flex;align-items:center;gap:10px;">
|
|
217
|
+
<div style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></div>
|
|
218
|
+
<span style="font-size:13px;text-transform:capitalize;">${esc(s.status)}</span>
|
|
45
219
|
</div>
|
|
46
|
-
<div
|
|
47
|
-
<
|
|
48
|
-
<
|
|
220
|
+
<div style="display:flex;align-items:center;gap:12px;">
|
|
221
|
+
<span style="font-family:var(--font-mono);font-size:13px;font-weight:600;">${fmtNum(s.count)}</span>
|
|
222
|
+
<span style="font-size:11px;color:var(--text-muted);width:34px;text-align:right;">${pct}%</span>
|
|
49
223
|
</div>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
224
|
+
</div>`;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Mini donut from status percentages using SVG
|
|
228
|
+
const donut = buildDonut(statuses, total);
|
|
229
|
+
|
|
230
|
+
return `
|
|
231
|
+
<div class="card">
|
|
232
|
+
<div class="card-title" style="margin-bottom:12px;">Run Status (all time)</div>
|
|
233
|
+
<div style="display:flex;gap:20px;align-items:flex-start;">
|
|
234
|
+
<div style="flex:1;">${pills.join('')}</div>
|
|
235
|
+
<div style="flex-shrink:0;margin-top:4px;">${donut}</div>
|
|
53
236
|
</div>
|
|
54
|
-
</div
|
|
237
|
+
</div>`;
|
|
238
|
+
}
|
|
55
239
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
240
|
+
function buildDonut(statuses, total) {
|
|
241
|
+
const R = 36, cx = 44, cy = 44, strokeW = 12;
|
|
242
|
+
const circum = 2 * Math.PI * R;
|
|
243
|
+
let offset = 0;
|
|
244
|
+
const slices = statuses.map((s) => {
|
|
245
|
+
const pct = total > 0 ? s.count / total : 0;
|
|
246
|
+
const dash = pct * circum;
|
|
247
|
+
const gap = circum - dash;
|
|
248
|
+
const color = STATUS_COLORS[s.status] || 'var(--text-muted)';
|
|
249
|
+
const slice = `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none"
|
|
250
|
+
stroke="${color}" stroke-width="${strokeW}"
|
|
251
|
+
stroke-dasharray="${dash} ${gap}"
|
|
252
|
+
stroke-dashoffset="${-offset}"
|
|
253
|
+
transform="rotate(-90 ${cx} ${cy})"/>`;
|
|
254
|
+
offset += dash;
|
|
255
|
+
return slice;
|
|
256
|
+
});
|
|
257
|
+
return `<svg width="88" height="88" viewBox="0 0 88 88">
|
|
258
|
+
<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="var(--border)" stroke-width="${strokeW}"/>
|
|
259
|
+
${slices.join('')}
|
|
260
|
+
</svg>`;
|
|
59
261
|
}
|
|
60
262
|
|
|
263
|
+
// ── Top Users ───────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
61
265
|
function renderTopUsers(users) {
|
|
62
266
|
if (!users.length) return '';
|
|
63
|
-
const
|
|
267
|
+
const maxTokens = Math.max(...users.map((u) => u.tokens), 1);
|
|
64
268
|
return `
|
|
65
|
-
<div class="card" style="margin-top:
|
|
66
|
-
<div class="card-title">Top Users by
|
|
269
|
+
<div class="card" style="margin-top:16px;">
|
|
270
|
+
<div class="card-title" style="margin-bottom:12px;">Top Users by Token Usage</div>
|
|
67
271
|
<table class="analytics-table">
|
|
68
272
|
<thead><tr>
|
|
69
|
-
<th>User</th><th>Runs</th><th>Tokens</th><th>Storage</th><th
|
|
273
|
+
<th>User</th><th style="text-align:right;">Runs</th><th style="text-align:right;">Tokens</th><th style="text-align:right;">Storage</th><th style="width:100px;"></th>
|
|
70
274
|
</tr></thead>
|
|
71
275
|
<tbody>${users.map((u) => `
|
|
72
276
|
<tr>
|
|
73
277
|
<td><span class="user-avatar">${esc((u.display_name || u.username || '?')[0]).toUpperCase()}</span> ${esc(u.display_name || u.username)}</td>
|
|
74
|
-
<td>${fmtNum(u.runs)}</td>
|
|
75
|
-
<td>${fmtTokens(u.tokens)}</td>
|
|
76
|
-
<td>${fmtBytes(u.storage)}</td>
|
|
77
|
-
<td>
|
|
78
|
-
<div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.runs / maxRuns) * 100)}%"></div></div>
|
|
79
|
-
</td>
|
|
278
|
+
<td style="text-align:right;">${fmtNum(u.runs)}</td>
|
|
279
|
+
<td style="text-align:right;">${fmtTokens(u.tokens)}</td>
|
|
280
|
+
<td style="text-align:right;">${fmtBytes(u.storage)}</td>
|
|
281
|
+
<td><div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.tokens / maxTokens) * 100)}%"></div></div></td>
|
|
80
282
|
</tr>`).join('')}
|
|
81
283
|
</tbody>
|
|
82
284
|
</table>
|
|
83
285
|
</div>`;
|
|
84
286
|
}
|
|
85
287
|
|
|
288
|
+
// ── Recent Runs ─────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
86
290
|
function renderRecentRuns(runs) {
|
|
87
291
|
if (!runs.length) return '';
|
|
88
292
|
return `
|
|
89
293
|
<div class="card" style="margin-top:16px;">
|
|
90
|
-
<div class="card-title">Recent Runs</div>
|
|
294
|
+
<div class="card-title" style="margin-bottom:12px;">Recent Runs</div>
|
|
91
295
|
<table class="analytics-table">
|
|
92
296
|
<thead><tr>
|
|
93
|
-
<th>User</th><th>Title</th><th>Status</th><th>Tokens</th><th>Started</th>
|
|
297
|
+
<th>User</th><th>Title</th><th>Model</th><th>Status</th><th style="text-align:right;">Tokens</th><th>Started</th>
|
|
94
298
|
</tr></thead>
|
|
95
299
|
<tbody>${runs.map((r) => `
|
|
96
300
|
<tr>
|
|
97
301
|
<td>${esc(r.username)}</td>
|
|
98
302
|
<td class="cell-truncate">${esc(r.title || '(untitled)')}</td>
|
|
303
|
+
<td style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${esc(r.model || '—')}</td>
|
|
99
304
|
<td><span class="run-badge run-${esc(r.status)}">${esc(r.status)}</span></td>
|
|
100
|
-
<td>${fmtTokens(r.total_tokens)}</td>
|
|
305
|
+
<td style="text-align:right;">${fmtTokens(r.total_tokens)}</td>
|
|
101
306
|
<td>${fmtTime(r.created_at)}</td>
|
|
102
307
|
</tr>`).join('')}
|
|
103
308
|
</tbody>
|
|
@@ -105,7 +310,7 @@ function renderRecentRuns(runs) {
|
|
|
105
310
|
</div>`;
|
|
106
311
|
}
|
|
107
312
|
|
|
108
|
-
// ── Shared number helpers
|
|
313
|
+
// ── Shared number helpers ──────────────────────────────────────────────────
|
|
109
314
|
|
|
110
315
|
function fmtNum(n) {
|
|
111
316
|
if (n == null) return '—';
|
|
@@ -114,6 +319,7 @@ function fmtNum(n) {
|
|
|
114
319
|
|
|
115
320
|
function fmtTokens(n) {
|
|
116
321
|
if (!n) return '0';
|
|
322
|
+
if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
|
117
323
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
118
324
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
|
119
325
|
return String(n);
|
package/server/admin/index.html
CHANGED
|
@@ -915,9 +915,16 @@
|
|
|
915
915
|
<div class="page-header">
|
|
916
916
|
<div>
|
|
917
917
|
<h1 class="page-title">Analytics</h1>
|
|
918
|
-
<p class="page-subtitle">Usage statistics,
|
|
918
|
+
<p class="page-subtitle">Usage statistics, model breakdown, and recent activity</p>
|
|
919
|
+
</div>
|
|
920
|
+
<div style="display:flex;align-items:center;gap:10px;">
|
|
921
|
+
<div style="display:flex;gap:4px;">
|
|
922
|
+
<button class="range-btn" onclick="setAnalyticsRange(7,this)">7d</button>
|
|
923
|
+
<button class="range-btn active" onclick="setAnalyticsRange(30,this)">30d</button>
|
|
924
|
+
<button class="range-btn" onclick="setAnalyticsRange(90,this)">90d</button>
|
|
925
|
+
</div>
|
|
926
|
+
<span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
|
|
919
927
|
</div>
|
|
920
|
-
<span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
|
|
921
928
|
</div>
|
|
922
929
|
<div class="content">
|
|
923
930
|
<div id="analytics-content"><div class="empty"><span class="spinner"></span></div></div>
|
package/server/db/database.js
CHANGED
|
@@ -764,6 +764,112 @@ db.exec(`
|
|
|
764
764
|
CREATE INDEX IF NOT EXISTS idx_agent_run_events_run ON agent_run_events(run_id, sequence_index, id);
|
|
765
765
|
CREATE INDEX IF NOT EXISTS idx_agent_run_events_user ON agent_run_events(user_id, created_at DESC);
|
|
766
766
|
|
|
767
|
+
CREATE TABLE IF NOT EXISTS agent_model_usage (
|
|
768
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
769
|
+
run_id TEXT NOT NULL,
|
|
770
|
+
step_id TEXT,
|
|
771
|
+
user_id INTEGER NOT NULL,
|
|
772
|
+
agent_id TEXT,
|
|
773
|
+
provider TEXT NOT NULL,
|
|
774
|
+
model TEXT NOT NULL,
|
|
775
|
+
phase TEXT DEFAULT 'model_turn',
|
|
776
|
+
input_tokens INTEGER DEFAULT 0,
|
|
777
|
+
output_tokens INTEGER DEFAULT 0,
|
|
778
|
+
reasoning_tokens INTEGER DEFAULT 0,
|
|
779
|
+
cached_read_tokens INTEGER DEFAULT 0,
|
|
780
|
+
cache_write_tokens INTEGER DEFAULT 0,
|
|
781
|
+
total_tokens INTEGER DEFAULT 0,
|
|
782
|
+
estimated_cost_usd REAL,
|
|
783
|
+
latency_ms INTEGER DEFAULT 0,
|
|
784
|
+
metadata_json TEXT DEFAULT '{}',
|
|
785
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
786
|
+
FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
|
|
787
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
788
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL,
|
|
789
|
+
FOREIGN KEY (step_id) REFERENCES agent_steps(id) ON DELETE SET NULL
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
CREATE INDEX IF NOT EXISTS idx_agent_model_usage_run ON agent_model_usage(run_id, id);
|
|
793
|
+
CREATE INDEX IF NOT EXISTS idx_agent_model_usage_user ON agent_model_usage(user_id, created_at DESC);
|
|
794
|
+
|
|
795
|
+
CREATE TABLE IF NOT EXISTS workspace_code_index (
|
|
796
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
797
|
+
user_id INTEGER NOT NULL,
|
|
798
|
+
path TEXT NOT NULL,
|
|
799
|
+
symbol TEXT,
|
|
800
|
+
symbol_type TEXT,
|
|
801
|
+
start_line INTEGER,
|
|
802
|
+
end_line INTEGER,
|
|
803
|
+
content TEXT NOT NULL,
|
|
804
|
+
content_hash TEXT NOT NULL,
|
|
805
|
+
embedding TEXT,
|
|
806
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
807
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
808
|
+
UNIQUE(user_id, path, symbol, start_line)
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
CREATE INDEX IF NOT EXISTS idx_workspace_code_index_user
|
|
812
|
+
ON workspace_code_index(user_id, path);
|
|
813
|
+
|
|
814
|
+
CREATE TABLE IF NOT EXISTS task_webhook_deliveries (
|
|
815
|
+
id TEXT PRIMARY KEY,
|
|
816
|
+
task_id INTEGER NOT NULL,
|
|
817
|
+
user_id INTEGER NOT NULL,
|
|
818
|
+
request_id TEXT NOT NULL,
|
|
819
|
+
payload_hash TEXT NOT NULL,
|
|
820
|
+
status TEXT NOT NULL DEFAULT 'accepted',
|
|
821
|
+
response_json TEXT DEFAULT '{}',
|
|
822
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
823
|
+
completed_at TEXT,
|
|
824
|
+
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE,
|
|
825
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
826
|
+
UNIQUE(task_id, request_id)
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
CREATE TABLE IF NOT EXISTS task_webhook_secrets (
|
|
830
|
+
task_id INTEGER PRIMARY KEY,
|
|
831
|
+
user_id INTEGER NOT NULL,
|
|
832
|
+
secret_encrypted TEXT NOT NULL,
|
|
833
|
+
secret_fingerprint TEXT NOT NULL,
|
|
834
|
+
rotated_at TEXT DEFAULT (datetime('now')),
|
|
835
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
836
|
+
FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE,
|
|
837
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
CREATE INDEX IF NOT EXISTS idx_task_webhook_deliveries_task
|
|
841
|
+
ON task_webhook_deliveries(task_id, created_at DESC);
|
|
842
|
+
|
|
843
|
+
CREATE TABLE IF NOT EXISTS skill_metrics (
|
|
844
|
+
user_id INTEGER NOT NULL,
|
|
845
|
+
agent_id TEXT,
|
|
846
|
+
skill_name TEXT NOT NULL,
|
|
847
|
+
invocation_count INTEGER DEFAULT 0,
|
|
848
|
+
success_count INTEGER DEFAULT 0,
|
|
849
|
+
failure_count INTEGER DEFAULT 0,
|
|
850
|
+
correction_count INTEGER DEFAULT 0,
|
|
851
|
+
total_tokens INTEGER DEFAULT 0,
|
|
852
|
+
last_used_at TEXT,
|
|
853
|
+
metadata_json TEXT DEFAULT '{}',
|
|
854
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
855
|
+
PRIMARY KEY (user_id, agent_id, skill_name),
|
|
856
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
857
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
CREATE TABLE IF NOT EXISTS skill_workflow_observations (
|
|
861
|
+
user_id INTEGER NOT NULL,
|
|
862
|
+
agent_id TEXT NOT NULL,
|
|
863
|
+
workflow_signature TEXT NOT NULL,
|
|
864
|
+
observation_count INTEGER DEFAULT 0,
|
|
865
|
+
latest_run_id TEXT,
|
|
866
|
+
first_observed_at TEXT DEFAULT (datetime('now')),
|
|
867
|
+
last_observed_at TEXT DEFAULT (datetime('now')),
|
|
868
|
+
PRIMARY KEY (user_id, agent_id, workflow_signature),
|
|
869
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
870
|
+
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
|
871
|
+
);
|
|
872
|
+
|
|
767
873
|
CREATE TABLE IF NOT EXISTS agent_settings (
|
|
768
874
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
769
875
|
user_id INTEGER NOT NULL,
|
|
@@ -1159,6 +1265,12 @@ for (const col of [
|
|
|
1159
1265
|
"ALTER TABLE desktop_companion_devices ADD COLUMN last_seen_at TEXT",
|
|
1160
1266
|
"ALTER TABLE desktop_companion_devices ADD COLUMN revoked_at TEXT",
|
|
1161
1267
|
"ALTER TABLE desktop_companion_devices ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))",
|
|
1268
|
+
"ALTER TABLE memory_facts ADD COLUMN valid_from TEXT",
|
|
1269
|
+
"ALTER TABLE memory_facts ADD COLUMN valid_to TEXT",
|
|
1270
|
+
"ALTER TABLE memory_facts ADD COLUMN learned_at TEXT",
|
|
1271
|
+
"ALTER TABLE memory_facts ADD COLUMN invalidated_at TEXT",
|
|
1272
|
+
"ALTER TABLE memory_facts ADD COLUMN status TEXT DEFAULT 'active'",
|
|
1273
|
+
"ALTER TABLE memory_facts ADD COLUMN supersedes_fact_id TEXT",
|
|
1162
1274
|
]) {
|
|
1163
1275
|
try { db.exec(col); } catch { /* column already exists */ }
|
|
1164
1276
|
}
|
package/server/http/routes.js
CHANGED
|
@@ -21,6 +21,7 @@ const routeRegistry = [
|
|
|
21
21
|
{ basePath: '/api/workspace', modulePath: '../routes/workspace' },
|
|
22
22
|
{ basePath: '/api/memory', modulePath: '../routes/memory' },
|
|
23
23
|
{ basePath: '/api/tasks', modulePath: '../routes/tasks' },
|
|
24
|
+
{ basePath: '/api/task-webhooks', modulePath: '../routes/task_webhooks' },
|
|
24
25
|
{ basePath: '/api/widgets', modulePath: '../routes/widgets' },
|
|
25
26
|
{ basePath: '/api/browser', modulePath: '../routes/browser' },
|
|
26
27
|
{ basePath: '/api/browser-extension', modulePath: '../routes/browser_extension' },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
7c0e23acbbf065a5042b91517cbe7d81
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"
|
|
1
|
+
"DQ0HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmBzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzDAENAQcFYXNzZXQHN3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC5maXh3ZWJtZHVyYXRpb24uanMHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzDAENAQcFYXNzZXQHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzBxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5nDAENAQcFYXNzZXQHFndlYi9pY29ucy9JY29uLTE5Mi5wbmc="
|