agentboss 0.1.0
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 +34 -0
- package/bin/aboss.js +288 -0
- package/client/dist/assets/index-C1wFD_Vo.css +1 -0
- package/client/dist/assets/index-DBj1Ujlx.js +137 -0
- package/client/dist/index.html +34 -0
- package/package.json +64 -0
- package/server/analysis/daily-aggregator.js +258 -0
- package/server/analysis/difficulty.js +129 -0
- package/server/analysis/dimensions/ai-knowledge.js +172 -0
- package/server/analysis/dimensions/ai-tools.js +161 -0
- package/server/analysis/dimensions/judgement.js +107 -0
- package/server/analysis/dimensions/llm-merge.js +57 -0
- package/server/analysis/dimensions/output-quality.js +167 -0
- package/server/analysis/dimensions/problem-definition.js +104 -0
- package/server/analysis/dimensions/system-thinking.js +225 -0
- package/server/analysis/evidence-builder.js +104 -0
- package/server/analysis/job.js +273 -0
- package/server/analysis/report-builder.js +581 -0
- package/server/analysis/scoring-v2.js +72 -0
- package/server/analysis/text-signals.js +179 -0
- package/server/analysis/thresholds-v2.js +358 -0
- package/server/api/advice.js +124 -0
- package/server/api/analysis.js +141 -0
- package/server/api/execution.js +330 -0
- package/server/api/metrics.js +277 -0
- package/server/api/overview.js +308 -0
- package/server/api/project.js +255 -0
- package/server/api/reports.js +125 -0
- package/server/api/sessions.js +118 -0
- package/server/api/settings.js +119 -0
- package/server/db/connection.js +175 -0
- package/server/db/queries.js +1051 -0
- package/server/db/schema.js +487 -0
- package/server/etl/active-time.js +150 -0
- package/server/etl/backfill-subagents.js +178 -0
- package/server/etl/claude-code.js +826 -0
- package/server/etl/detect.js +341 -0
- package/server/etl/judge-filter.js +117 -0
- package/server/etl/opencode.js +606 -0
- package/server/execution/job.js +662 -0
- package/server/execution/prompt.js +227 -0
- package/server/execution/runner.js +218 -0
- package/server/index.js +94 -0
- package/server/llm/advice-prompt.js +339 -0
- package/server/llm/advice.js +384 -0
- package/server/llm/analysis-prompt.js +162 -0
- package/server/llm/cli-runner.js +249 -0
- package/server/llm/judge-prompts.js +179 -0
- package/server/llm/judge.js +118 -0
- package/server/llm/project-advice-prompt.js +332 -0
- package/server/llm/project-advice.js +491 -0
- package/server/llm/session-analyzer.js +122 -0
- package/server/utils/project.js +80 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics API routes for Agent Boss.
|
|
3
|
+
*
|
|
4
|
+
* Provides endpoints for dimension scores, daily/hourly summaries, session
|
|
5
|
+
* listings with pagination, trend computation, and cross-tool comparisons.
|
|
6
|
+
*
|
|
7
|
+
* @author Felix
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const router = require('express').Router();
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
getDailySummaries,
|
|
14
|
+
getHourlyActivity,
|
|
15
|
+
getSessionsByDateRange,
|
|
16
|
+
countSessionsByDateRange,
|
|
17
|
+
} = require('../db/queries');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Date helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Format a Date as YYYY-MM-DD.
|
|
25
|
+
* @param {Date} d
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function fmt(d) {
|
|
29
|
+
const y = d.getFullYear();
|
|
30
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
31
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
32
|
+
return `${y}-${m}-${dd}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the date N days ago as YYYY-MM-DD.
|
|
37
|
+
* @param {number} n
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function daysAgo(n) {
|
|
41
|
+
const d = new Date();
|
|
42
|
+
d.setDate(d.getDate() - n);
|
|
43
|
+
return fmt(d);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get yesterday as YYYY-MM-DD.
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function yesterday() {
|
|
51
|
+
return daysAgo(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get today as YYYY-MM-DD.
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
function today() {
|
|
59
|
+
return fmt(new Date());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compute period boundaries for trend comparison.
|
|
64
|
+
* @param {string} period 'week' or 'month'
|
|
65
|
+
* @returns {{ current: {from: string, to: string}, previous: {from: string, to: string} }}
|
|
66
|
+
*/
|
|
67
|
+
function trendPeriods(period) {
|
|
68
|
+
const now = new Date();
|
|
69
|
+
if (period === 'month') {
|
|
70
|
+
// Current: last 30 days, Previous: 30 days before that
|
|
71
|
+
return {
|
|
72
|
+
current: { from: daysAgo(30), to: fmt(now) },
|
|
73
|
+
previous: { from: daysAgo(60), to: daysAgo(31) },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Default: week
|
|
77
|
+
return {
|
|
78
|
+
current: { from: daysAgo(7), to: fmt(now) },
|
|
79
|
+
previous: { from: daysAgo(14), to: daysAgo(8) },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sum key metrics from daily summary rows.
|
|
85
|
+
* @param {object[]} summaries
|
|
86
|
+
* @returns {object}
|
|
87
|
+
*/
|
|
88
|
+
function sumSummaries(summaries) {
|
|
89
|
+
let sessions = 0;
|
|
90
|
+
let cost = 0;
|
|
91
|
+
let activeMinutes = 0;
|
|
92
|
+
let errors = 0;
|
|
93
|
+
let tokensInput = 0;
|
|
94
|
+
let tokensOutput = 0;
|
|
95
|
+
|
|
96
|
+
for (const s of summaries) {
|
|
97
|
+
sessions += s.session_count || 0;
|
|
98
|
+
cost += s.cost_usd || 0;
|
|
99
|
+
activeMinutes += s.active_minutes || 0;
|
|
100
|
+
errors += s.error_count || 0;
|
|
101
|
+
tokensInput += s.tokens_input || 0;
|
|
102
|
+
tokensOutput += s.tokens_output || 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
sessions,
|
|
107
|
+
cost: Math.round(cost * 100) / 100,
|
|
108
|
+
activeMinutes,
|
|
109
|
+
errors,
|
|
110
|
+
tokensInput,
|
|
111
|
+
tokensOutput,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Routes
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create the metrics router with database access.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} db sql.js Database instance
|
|
123
|
+
* @returns {import('express').Router}
|
|
124
|
+
*/
|
|
125
|
+
module.exports = function (db) {
|
|
126
|
+
// GET /api/metrics/daily?from=&to=
|
|
127
|
+
router.get('/daily', (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const from = req.query.from || daysAgo(7);
|
|
130
|
+
const to = req.query.to || today();
|
|
131
|
+
const data = getDailySummaries(db, from, to);
|
|
132
|
+
res.json({
|
|
133
|
+
ok: true,
|
|
134
|
+
data,
|
|
135
|
+
meta: {
|
|
136
|
+
generated_at: new Date().toISOString(),
|
|
137
|
+
period: { from, to },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
res.status(500).json({
|
|
142
|
+
ok: false,
|
|
143
|
+
error: { code: 'METRICS_ERROR', message: err.message },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// GET /api/metrics/hourly?date=
|
|
149
|
+
router.get('/hourly', (req, res) => {
|
|
150
|
+
try {
|
|
151
|
+
const date = req.query.date || yesterday();
|
|
152
|
+
const data = getHourlyActivity(db, date);
|
|
153
|
+
res.json({
|
|
154
|
+
ok: true,
|
|
155
|
+
data,
|
|
156
|
+
meta: {
|
|
157
|
+
generated_at: new Date().toISOString(),
|
|
158
|
+
period: { from: date, to: date },
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
res.status(500).json({
|
|
163
|
+
ok: false,
|
|
164
|
+
error: { code: 'METRICS_ERROR', message: err.message },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// GET /api/metrics/sessions?from=&to=&source=&limit=50&offset=0
|
|
170
|
+
router.get('/sessions', (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const from = req.query.from || daysAgo(7);
|
|
173
|
+
const to = req.query.to || today();
|
|
174
|
+
const source = req.query.source || undefined;
|
|
175
|
+
const limit = parseInt(req.query.limit, 10) || 50;
|
|
176
|
+
const offset = parseInt(req.query.offset, 10) || 0;
|
|
177
|
+
|
|
178
|
+
const data = getSessionsByDateRange(db, from, to, source, limit, offset);
|
|
179
|
+
const total = countSessionsByDateRange(db, from, to, source);
|
|
180
|
+
|
|
181
|
+
res.json({
|
|
182
|
+
ok: true,
|
|
183
|
+
data,
|
|
184
|
+
total,
|
|
185
|
+
meta: {
|
|
186
|
+
generated_at: new Date().toISOString(),
|
|
187
|
+
period: { from, to },
|
|
188
|
+
pagination: { limit, offset, total },
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
} catch (err) {
|
|
192
|
+
res.status(500).json({
|
|
193
|
+
ok: false,
|
|
194
|
+
error: { code: 'METRICS_ERROR', message: err.message },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// GET /api/metrics/trends?period=week|month
|
|
200
|
+
router.get('/trends', (req, res) => {
|
|
201
|
+
try {
|
|
202
|
+
const period = req.query.period === 'month' ? 'month' : 'week';
|
|
203
|
+
const { current, previous } = trendPeriods(period);
|
|
204
|
+
|
|
205
|
+
const currentSummaries = getDailySummaries(db, current.from, current.to);
|
|
206
|
+
const previousSummaries = getDailySummaries(db, previous.from, previous.to);
|
|
207
|
+
|
|
208
|
+
const cur = sumSummaries(currentSummaries);
|
|
209
|
+
const prev = sumSummaries(previousSummaries);
|
|
210
|
+
|
|
211
|
+
// Compute percentage change (null if previous is zero)
|
|
212
|
+
const pct = (c, p) => (p > 0 ? Math.round(((c - p) / p) * 1000) / 10 : null);
|
|
213
|
+
|
|
214
|
+
const data = {
|
|
215
|
+
period,
|
|
216
|
+
current: cur,
|
|
217
|
+
previous: prev,
|
|
218
|
+
change: {
|
|
219
|
+
sessions: pct(cur.sessions, prev.sessions),
|
|
220
|
+
cost: pct(cur.cost, prev.cost),
|
|
221
|
+
activeMinutes: pct(cur.activeMinutes, prev.activeMinutes),
|
|
222
|
+
errors: pct(cur.errors, prev.errors),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
res.json({
|
|
227
|
+
ok: true,
|
|
228
|
+
data,
|
|
229
|
+
meta: {
|
|
230
|
+
generated_at: new Date().toISOString(),
|
|
231
|
+
period: { from: current.from, to: current.to },
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
res.status(500).json({
|
|
236
|
+
ok: false,
|
|
237
|
+
error: { code: 'METRICS_ERROR', message: err.message },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// GET /api/metrics/cross-tool
|
|
243
|
+
router.get('/cross-tool', (req, res) => {
|
|
244
|
+
try {
|
|
245
|
+
const from = req.query.from || daysAgo(30);
|
|
246
|
+
const to = req.query.to || today();
|
|
247
|
+
|
|
248
|
+
const opencodeSummaries = getDailySummaries(db, from, to, 'opencode');
|
|
249
|
+
const claudeCodeSummaries = getDailySummaries(db, from, to, 'claude-code');
|
|
250
|
+
|
|
251
|
+
const data = {
|
|
252
|
+
opencode: {
|
|
253
|
+
stats: sumSummaries(opencodeSummaries),
|
|
254
|
+
},
|
|
255
|
+
claudeCode: {
|
|
256
|
+
stats: sumSummaries(claudeCodeSummaries),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
res.json({
|
|
261
|
+
ok: true,
|
|
262
|
+
data,
|
|
263
|
+
meta: {
|
|
264
|
+
generated_at: new Date().toISOString(),
|
|
265
|
+
period: { from, to },
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
} catch (err) {
|
|
269
|
+
res.status(500).json({
|
|
270
|
+
ok: false,
|
|
271
|
+
error: { code: 'METRICS_ERROR', message: err.message },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return router;
|
|
277
|
+
};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overview (home page) API for Agent Boss.
|
|
3
|
+
*
|
|
4
|
+
* Single endpoint `GET /api/overview?days=N` returns ETL-derived datasets
|
|
5
|
+
* — no dependency on the analysis layer, so the page renders the moment
|
|
6
|
+
* ETL sync completes:
|
|
7
|
+
*
|
|
8
|
+
* range — resolved window { from, to, days, label }
|
|
9
|
+
* trend — daily rows broken out by source (sessions / cost)
|
|
10
|
+
* topProjects — top 8 projects in window, by cost
|
|
11
|
+
* recentSessions — newest 10 sessions across all sources / dates
|
|
12
|
+
* cacheRate — per-day cache hit rate (%)
|
|
13
|
+
* errorRate — per-day error rate (%)
|
|
14
|
+
* codeNet — per-day additions / deletions / files
|
|
15
|
+
* topTools — top 10 tools by call count, with error rate
|
|
16
|
+
* capabilityRadar — v2 5-axis radar averaged across analysed sessions in
|
|
17
|
+
* the window (H1/H2/H3/ENV/O1), or null if analysis
|
|
18
|
+
* hasn't produced any rows yet
|
|
19
|
+
*
|
|
20
|
+
* `days` may be 7 / 14 / 30 / 90 or `all`. Unknown values fall back to 14.
|
|
21
|
+
*
|
|
22
|
+
* @author Felix
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const router = require('express').Router();
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
getOverviewTrend,
|
|
29
|
+
getOverviewTopProjects,
|
|
30
|
+
getOverviewRecentSessions,
|
|
31
|
+
getOverviewCacheRate,
|
|
32
|
+
getOverviewErrorRate,
|
|
33
|
+
getOverviewTopTools,
|
|
34
|
+
getEarliestSessionDate,
|
|
35
|
+
} = require('../db/queries');
|
|
36
|
+
const { getCurrentDimensionsV2 } = require('../analysis/report-builder');
|
|
37
|
+
const { canonicalProject, mapTopProjects } = require('../utils/project');
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Date helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** YYYY-MM-DD in local time. */
|
|
44
|
+
function fmt(d) {
|
|
45
|
+
const y = d.getFullYear();
|
|
46
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
47
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
48
|
+
return `${y}-${m}-${dd}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function daysAgo(n) {
|
|
52
|
+
const d = new Date();
|
|
53
|
+
d.setDate(d.getDate() - n);
|
|
54
|
+
return fmt(d);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Shape mappers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reshape raw recent-session rows into the camelCase shape the home page
|
|
63
|
+
* table consumes.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object[]} rows
|
|
66
|
+
* @returns {Object[]}
|
|
67
|
+
*/
|
|
68
|
+
function mapRecentSessions(rows) {
|
|
69
|
+
return rows.map((r) => ({
|
|
70
|
+
id: r.id,
|
|
71
|
+
source: r.source,
|
|
72
|
+
startedAt: r.started_at,
|
|
73
|
+
project: r.project,
|
|
74
|
+
title: r.title,
|
|
75
|
+
model: r.model,
|
|
76
|
+
messageCount: r.message_count || 0,
|
|
77
|
+
cost: Math.round((r.cost_usd || 0) * 10000) / 10000,
|
|
78
|
+
errorCount: r.error_count || 0,
|
|
79
|
+
reverted: !!r.reverted,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pivot the (date, source) trend rows into one row per date with one column
|
|
85
|
+
* per source, so the recharts <TrendLine> can render overlaid lines.
|
|
86
|
+
*
|
|
87
|
+
* Output columns:
|
|
88
|
+
* date, opencode_cost, claude-code_cost, opencode_sessions, ...
|
|
89
|
+
* The active sources list is returned alongside so the UI can build the
|
|
90
|
+
* right Line definitions without re-scanning the rows.
|
|
91
|
+
*
|
|
92
|
+
* @param {Object[]} rows raw GROUP BY date, source rows
|
|
93
|
+
* @param {string} fromDate
|
|
94
|
+
* @param {string} toDate
|
|
95
|
+
* @returns {{ rows: Object[], sources: string[] }}
|
|
96
|
+
*/
|
|
97
|
+
function pivotTrend(rows, fromDate, toDate) {
|
|
98
|
+
const byDate = new Map();
|
|
99
|
+
const sourceSet = new Set();
|
|
100
|
+
|
|
101
|
+
for (const r of rows) {
|
|
102
|
+
sourceSet.add(r.source);
|
|
103
|
+
if (!byDate.has(r.date)) {
|
|
104
|
+
byDate.set(r.date, { date: r.date });
|
|
105
|
+
}
|
|
106
|
+
const bucket = byDate.get(r.date);
|
|
107
|
+
bucket[`${r.source}_sessions`] = r.sessions || 0;
|
|
108
|
+
bucket[`${r.source}_cost`] = Math.round((r.cost || 0) * 100) / 100;
|
|
109
|
+
bucket[`${r.source}_activeMinutes`] = r.active_minutes || 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sources = Array.from(sourceSet);
|
|
113
|
+
|
|
114
|
+
// For very large windows (e.g. "all") we skip the dense fill — emitting
|
|
115
|
+
// one row per day for 6 months gets noisy on the x-axis. Instead we
|
|
116
|
+
// just return the sparse rows ordered by date, with zeros injected for
|
|
117
|
+
// any source missing on a given day.
|
|
118
|
+
const start = new Date(fromDate + 'T00:00:00');
|
|
119
|
+
const end = new Date(toDate + 'T00:00:00');
|
|
120
|
+
const spanDays = Math.round((end - start) / 86400000) + 1;
|
|
121
|
+
const DENSE_FILL_MAX_DAYS = 120;
|
|
122
|
+
|
|
123
|
+
if (spanDays > DENSE_FILL_MAX_DAYS) {
|
|
124
|
+
const rows = Array.from(byDate.values()).sort((a, b) =>
|
|
125
|
+
a.date < b.date ? -1 : a.date > b.date ? 1 : 0
|
|
126
|
+
);
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
for (const s of sources) {
|
|
129
|
+
if (row[`${s}_sessions`] == null) row[`${s}_sessions`] = 0;
|
|
130
|
+
if (row[`${s}_cost`] == null) row[`${s}_cost`] = 0;
|
|
131
|
+
if (row[`${s}_activeMinutes`] == null) row[`${s}_activeMinutes`] = 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { rows, sources };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Fill the full date range so the x-axis is continuous (no gaps when a
|
|
138
|
+
// day had zero activity).
|
|
139
|
+
const filled = [];
|
|
140
|
+
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
141
|
+
const key = fmt(d);
|
|
142
|
+
const existing = byDate.get(key) || { date: key };
|
|
143
|
+
for (const s of sources) {
|
|
144
|
+
if (existing[`${s}_sessions`] == null) existing[`${s}_sessions`] = 0;
|
|
145
|
+
if (existing[`${s}_cost`] == null) existing[`${s}_cost`] = 0;
|
|
146
|
+
if (existing[`${s}_activeMinutes`] == null) existing[`${s}_activeMinutes`] = 0;
|
|
147
|
+
}
|
|
148
|
+
filled.push(existing);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { rows: filled, sources };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Router factory
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {object} db sql.js Database instance
|
|
160
|
+
* @returns {import('express').Router}
|
|
161
|
+
*/
|
|
162
|
+
/**
|
|
163
|
+
* Merge per-day rate rows into the pivoted trend rows in-place.
|
|
164
|
+
* This lets the front-end render any time-series metric (cost, sessions,
|
|
165
|
+
* tokens, hitRate, errorRate) from one flat array.
|
|
166
|
+
*
|
|
167
|
+
* Extra columns added per date row:
|
|
168
|
+
* hitRate, cacheRead, cacheWrite
|
|
169
|
+
* errorRate, errors, messages
|
|
170
|
+
*
|
|
171
|
+
* @param {Object[]} trendRows output of pivotTrend (one row per date)
|
|
172
|
+
* @param {{cache: Object[], error: Object[]}} extras
|
|
173
|
+
* @returns {Object[]} same trendRows array, mutated and returned for chaining
|
|
174
|
+
*/
|
|
175
|
+
function mergeTimeseries(trendRows, extras) {
|
|
176
|
+
const byDate = new Map(trendRows.map((r) => [r.date, r]));
|
|
177
|
+
|
|
178
|
+
for (const c of extras.cache || []) {
|
|
179
|
+
const row = byDate.get(c.date);
|
|
180
|
+
if (!row) continue;
|
|
181
|
+
row.hitRate = c.hitRate;
|
|
182
|
+
row.cacheRead = c.cacheRead;
|
|
183
|
+
row.cacheWrite = c.cacheWrite;
|
|
184
|
+
}
|
|
185
|
+
for (const e of extras.error || []) {
|
|
186
|
+
const row = byDate.get(e.date);
|
|
187
|
+
if (!row) continue;
|
|
188
|
+
row.errorRate = e.rate;
|
|
189
|
+
row.errors = e.errors;
|
|
190
|
+
row.messages = e.messages;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ensure every column has a numeric default (so recharts doesn't drop
|
|
194
|
+
// points and tooltips don't render `undefined`).
|
|
195
|
+
for (const row of trendRows) {
|
|
196
|
+
if (row.hitRate == null) row.hitRate = 0;
|
|
197
|
+
if (row.errorRate == null) row.errorRate = 0;
|
|
198
|
+
}
|
|
199
|
+
return trendRows;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Date-range resolution
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
const PRESETS = { '7': 7, '14': 14, '30': 30, '90': 90 };
|
|
207
|
+
const PRESET_LABEL = {
|
|
208
|
+
'7': '本周',
|
|
209
|
+
'14': '双周',
|
|
210
|
+
'30': '本月',
|
|
211
|
+
'90': '季度',
|
|
212
|
+
'all': '全部',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Translate the `days` query parameter into a concrete window. Falls back
|
|
217
|
+
* to 14 days for unknown values. For `all`, the lower bound is the
|
|
218
|
+
* earliest session date in boss.db (or today if the DB is empty).
|
|
219
|
+
*
|
|
220
|
+
* @param {string|undefined} raw query string value
|
|
221
|
+
* @param {Object} db sql.js Database
|
|
222
|
+
* @returns {{ from: string, to: string, days: number|null, label: string }}
|
|
223
|
+
*/
|
|
224
|
+
function resolveRange(raw, db) {
|
|
225
|
+
const to = fmt(new Date());
|
|
226
|
+
if (raw === 'all') {
|
|
227
|
+
const from = getEarliestSessionDate(db) || to;
|
|
228
|
+
return { from, to, days: null, label: PRESET_LABEL.all };
|
|
229
|
+
}
|
|
230
|
+
const key = String(raw || '14');
|
|
231
|
+
const n = PRESETS[key] != null ? PRESETS[key] : 14;
|
|
232
|
+
return {
|
|
233
|
+
from: daysAgo(n - 1),
|
|
234
|
+
to,
|
|
235
|
+
days: n,
|
|
236
|
+
label: PRESET_LABEL[String(n)] || `${n} 天`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Router
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
module.exports = function (db) {
|
|
245
|
+
// GET /api/overview?days=7|14|30|90|all
|
|
246
|
+
router.get('/', (req, res) => {
|
|
247
|
+
try {
|
|
248
|
+
const range = resolveRange(req.query.days, db);
|
|
249
|
+
const { from, to } = range;
|
|
250
|
+
|
|
251
|
+
const trendRows = getOverviewTrend(db, from, to);
|
|
252
|
+
// Pull a larger candidate pool than the final top-N so that
|
|
253
|
+
// collapsing duplicate paths (see canonicalProject) still leaves
|
|
254
|
+
// enough rows to fill the requested limit.
|
|
255
|
+
const TOP_N = 8;
|
|
256
|
+
const topProjectsRaw = getOverviewTopProjects(db, from, to, 40);
|
|
257
|
+
const recentSessions = getOverviewRecentSessions(db, 10);
|
|
258
|
+
const cacheRateRows = getOverviewCacheRate(db, from, to);
|
|
259
|
+
const errorRateRows = getOverviewErrorRate(db, from, to);
|
|
260
|
+
const topTools = getOverviewTopTools(db, from, to, 10);
|
|
261
|
+
|
|
262
|
+
// v2 capability radar — averaged across all analysed sessions in the
|
|
263
|
+
// window (H1/H2/E1/E2/O1) plus H3 from the rolling aggregator.
|
|
264
|
+
// Returns nulls when no sessions in the window have been analysed yet;
|
|
265
|
+
// the front-end shows an empty-state in that case.
|
|
266
|
+
let capabilityRadar = null;
|
|
267
|
+
try {
|
|
268
|
+
capabilityRadar = getCurrentDimensionsV2(db, from, to);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
// Don't fail the whole overview if analysis tables are missing.
|
|
271
|
+
capabilityRadar = null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const trend = pivotTrend(trendRows, from, to);
|
|
275
|
+
const timeseries = mergeTimeseries(trend.rows, {
|
|
276
|
+
cache: cacheRateRows,
|
|
277
|
+
error: errorRateRows,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
res.json({
|
|
281
|
+
ok: true,
|
|
282
|
+
data: {
|
|
283
|
+
range,
|
|
284
|
+
trend: {
|
|
285
|
+
from,
|
|
286
|
+
to,
|
|
287
|
+
sources: trend.sources,
|
|
288
|
+
rows: timeseries,
|
|
289
|
+
},
|
|
290
|
+
topProjects: mapTopProjects(topProjectsRaw, TOP_N),
|
|
291
|
+
recentSessions: mapRecentSessions(recentSessions),
|
|
292
|
+
topTools,
|
|
293
|
+
capabilityRadar,
|
|
294
|
+
},
|
|
295
|
+
meta: {
|
|
296
|
+
generated_at: new Date().toISOString(),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
} catch (err) {
|
|
300
|
+
res.status(500).json({
|
|
301
|
+
ok: false,
|
|
302
|
+
error: { code: 'OVERVIEW_ERROR', message: err.message },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return router;
|
|
308
|
+
};
|