clawatch 1.0.13 → 1.0.20
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/backend/dist/alertChecker.d.ts.map +1 -1
- package/backend/dist/alertChecker.js +130 -1
- package/backend/dist/alertChecker.js.map +1 -1
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +1 -0
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/projects.d.ts +9 -0
- package/backend/dist/projects.d.ts.map +1 -1
- package/backend/dist/projects.js +87 -23
- package/backend/dist/projects.js.map +1 -1
- package/backend/dist/routes.d.ts.map +1 -1
- package/backend/dist/routes.js +687 -54
- package/backend/dist/routes.js.map +1 -1
- package/backend/dist/sessions.d.ts +14 -7
- package/backend/dist/sessions.d.ts.map +1 -1
- package/backend/dist/sessions.js +199 -43
- package/backend/dist/sessions.js.map +1 -1
- package/backend/dist/sync.d.ts.map +1 -1
- package/backend/dist/sync.js +293 -33
- package/backend/dist/sync.js.map +1 -1
- package/dist/cli.js +225 -93
- package/dist/cli.js.map +1 -1
- package/dist/collector.d.ts.map +1 -1
- package/dist/collector.js +67 -62
- package/dist/collector.js.map +1 -1
- package/dist/config.d.ts +16 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +69 -5
- package/dist/config.js.map +1 -1
- package/frontend/.next/BUILD_ID +1 -1
- package/frontend/.next/build-manifest.json +4 -4
- package/frontend/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/frontend/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/frontend/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/frontend/.next/server/app/_global-error.html +2 -2
- package/frontend/.next/server/app/_global-error.rsc +8 -8
- package/frontend/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/frontend/.next/server/app/_global-error.segments/_full.segment.rsc +8 -8
- package/frontend/.next/server/app/_global-error.segments/_head.segment.rsc +4 -4
- package/frontend/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/frontend/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/frontend/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/frontend/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend/.next/server/app/_not-found.html +1 -1
- package/frontend/.next/server/app/_not-found.rsc +9 -9
- package/frontend/.next/server/app/_not-found.segments/_full.segment.rsc +9 -9
- package/frontend/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/frontend/.next/server/app/_not-found.segments/_index.segment.rsc +4 -4
- package/frontend/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/frontend/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/frontend/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend/.next/server/app/dashboard/page/build-manifest.json +2 -2
- package/frontend/.next/server/app/dashboard/page.js.nft.json +1 -1
- package/frontend/.next/server/app/dashboard/page_client-reference-manifest.js +1 -1
- package/frontend/.next/server/app/dashboard/projects/[id]/page/build-manifest.json +2 -2
- package/frontend/.next/server/app/dashboard/projects/[id]/page.js.nft.json +1 -1
- package/frontend/.next/server/app/dashboard/projects/[id]/page_client-reference-manifest.js +1 -1
- package/frontend/.next/server/app/dashboard/sessions/[id]/page/build-manifest.json +2 -2
- package/frontend/.next/server/app/dashboard/sessions/[id]/page.js.nft.json +1 -1
- package/frontend/.next/server/app/dashboard/sessions/[id]/page_client-reference-manifest.js +1 -1
- package/frontend/.next/server/app/dashboard.html +1 -1
- package/frontend/.next/server/app/dashboard.rsc +10 -10
- package/frontend/.next/server/app/dashboard.segments/_full.segment.rsc +10 -10
- package/frontend/.next/server/app/dashboard.segments/_head.segment.rsc +4 -4
- package/frontend/.next/server/app/dashboard.segments/_index.segment.rsc +4 -4
- package/frontend/.next/server/app/dashboard.segments/_tree.segment.rsc +2 -2
- package/frontend/.next/server/app/dashboard.segments/dashboard/__PAGE__.segment.rsc +4 -4
- package/frontend/.next/server/app/dashboard.segments/dashboard.segment.rsc +3 -3
- package/frontend/.next/server/app/index.html +1 -1
- package/frontend/.next/server/app/index.rsc +10 -10
- package/frontend/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/frontend/.next/server/app/index.segments/_full.segment.rsc +10 -10
- package/frontend/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/frontend/.next/server/app/index.segments/_index.segment.rsc +4 -4
- package/frontend/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend/.next/server/app/page/build-manifest.json +2 -2
- package/frontend/.next/server/app/page.js.nft.json +1 -1
- package/frontend/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend/.next/server/chunks/ssr/[root-of-the-server]__008d27c3._.js +1 -1
- package/frontend/.next/server/chunks/ssr/{[root-of-the-server]__bdeac1dc._.js → [root-of-the-server]__32661fd1._.js} +2 -2
- package/frontend/.next/server/chunks/ssr/{[root-of-the-server]__8511f4a3._.js → [root-of-the-server]__9b38782c._.js} +2 -2
- package/frontend/.next/server/chunks/ssr/[root-of-the-server]__a64655ed._.js +2 -2
- package/frontend/.next/server/chunks/ssr/[root-of-the-server]__d2832b3e._.js +3 -0
- package/frontend/.next/server/chunks/ssr/[root-of-the-server]__f437da88._.js +1 -1
- package/frontend/.next/server/chunks/ssr/_2bfdd77b._.js +1 -1
- package/frontend/.next/server/chunks/ssr/_b0ae6d33._.js +1 -1
- package/frontend/.next/server/chunks/ssr/_e17fe96b._.js +3 -0
- package/frontend/.next/server/chunks/ssr/_f26b1aca._.js +1 -1
- package/frontend/.next/server/chunks/ssr/node_modules_next_dist_client_components_9774470f._.js +1 -1
- package/frontend/.next/server/chunks/ssr/{node_modules_next_dist_27457240._.js → node_modules_next_dist_esm_eedfc1fd._.js} +2 -2
- package/frontend/.next/server/middleware-build-manifest.js +2 -2
- package/frontend/.next/server/pages/404.html +1 -1
- package/frontend/.next/server/pages/500.html +2 -2
- package/frontend/.next/static/chunks/641e855850e79de9.css +3 -0
- package/frontend/.next/static/chunks/{d702a24e2b6c48fa.js → 6b50f8d2ee1d2bea.js} +2 -2
- package/frontend/.next/static/chunks/88faea50dcf8f778.js +1 -0
- package/frontend/.next/static/chunks/8ffedcb68f4a998f.js +1 -0
- package/frontend/.next/static/chunks/{d2be314c3ece3fbe.js → a2dfb6fc5208ab9b.js} +1 -1
- package/frontend/.next/static/chunks/a909e37955d0604e.js +1 -0
- package/frontend/.next/static/chunks/ba72b58eb8cb3f4e.js +1 -0
- package/frontend/.next/static/chunks/e975763f7a359fb5.js +1 -0
- package/frontend/.next/static/chunks/{turbopack-0df1acbb994b2a74.js → turbopack-1e12e8d4e4225e2f.js} +1 -1
- package/frontend/package.json +1 -0
- package/frontend/server-with-proxy.js +82 -0
- package/package.json +4 -3
- package/frontend/.next/server/chunks/ssr/[root-of-the-server]__48e09bc8._.js +0 -3
- package/frontend/.next/server/chunks/ssr/src_app_dashboard_page_tsx_196c74b5._.js +0 -3
- package/frontend/.next/static/chunks/284aee0eb323076c.js +0 -1
- package/frontend/.next/static/chunks/2b7d8037bb74445e.css +0 -3
- package/frontend/.next/static/chunks/520be2943f8ae266.js +0 -1
- package/frontend/.next/static/chunks/58873fd2347d1c88.js +0 -1
- package/frontend/.next/static/chunks/a308a3cc32b8bc4a.js +0 -1
- /package/frontend/.next/static/{P4g0K_y2ksljZD88vEDAA → bKa8vfUmSWQxqmsnhQLtj}/_buildManifest.js +0 -0
- /package/frontend/.next/static/{P4g0K_y2ksljZD88vEDAA → bKa8vfUmSWQxqmsnhQLtj}/_clientMiddlewareManifest.json +0 -0
- /package/frontend/.next/static/{P4g0K_y2ksljZD88vEDAA → bKa8vfUmSWQxqmsnhQLtj}/_ssgManifest.js +0 -0
package/backend/dist/routes.js
CHANGED
|
@@ -1,21 +1,95 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
5
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
39
|
const express_1 = require("express");
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
7
42
|
const db_1 = __importDefault(require("./db"));
|
|
8
43
|
const sessions_1 = require("./sessions");
|
|
9
44
|
const projects_1 = require("./projects");
|
|
10
45
|
const router = (0, express_1.Router)();
|
|
46
|
+
// ---------- Profiles ----------
|
|
47
|
+
router.get("/profiles", (_req, res) => {
|
|
48
|
+
const profiles = (0, sessions_1.discoverProfiles)();
|
|
49
|
+
res.json({ profiles });
|
|
50
|
+
});
|
|
51
|
+
// ---------- Version ----------
|
|
52
|
+
router.get("/version", (_req, res) => {
|
|
53
|
+
const candidates = [
|
|
54
|
+
path.join(__dirname, "..", "..", "cli", "package.json"), // dev/source
|
|
55
|
+
path.join(__dirname, "..", "package.json"), // bundled in CLI
|
|
56
|
+
];
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
try {
|
|
59
|
+
if (fs.existsSync(candidate)) {
|
|
60
|
+
const pkg = JSON.parse(fs.readFileSync(candidate, "utf-8"));
|
|
61
|
+
if (pkg.version) {
|
|
62
|
+
res.json({ version: pkg.version });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// try next
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
res.json({ version: "unknown" });
|
|
72
|
+
});
|
|
11
73
|
// ---------- Agents ----------
|
|
12
|
-
router.get("/agents", (_req, res) => {
|
|
74
|
+
router.get("/agents", async (_req, res) => {
|
|
13
75
|
const statusFilter = _req.query.status || "active";
|
|
76
|
+
const profileFilter = _req.query.profile;
|
|
14
77
|
const agents = db_1.default.prepare("SELECT * FROM agents ORDER BY costUsd DESC").all();
|
|
15
78
|
// Filter by status
|
|
16
|
-
|
|
79
|
+
let filtered = statusFilter === "all"
|
|
17
80
|
? agents
|
|
18
81
|
: agents.filter((a) => a.status === statusFilter);
|
|
82
|
+
// Filter by profile: only return agents that have sessions in the selected profile
|
|
83
|
+
if (profileFilter) {
|
|
84
|
+
try {
|
|
85
|
+
const sessions = await (0, sessions_1.listSessions)(profileFilter);
|
|
86
|
+
const agentIdsInProfile = new Set(sessions.map((s) => s.agentId));
|
|
87
|
+
filtered = filtered.filter((a) => agentIdsInProfile.has(a.id));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// If profile lookup fails, return unfiltered
|
|
91
|
+
}
|
|
92
|
+
}
|
|
19
93
|
res.json({ agents: filtered });
|
|
20
94
|
});
|
|
21
95
|
router.get("/agents/:id", (req, res) => {
|
|
@@ -97,53 +171,139 @@ router.post("/events", (req, res) => {
|
|
|
97
171
|
res.status(201).json({ ok: true });
|
|
98
172
|
});
|
|
99
173
|
// ---------- Costs ----------
|
|
100
|
-
router.get("/costs", (req, res) => {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
174
|
+
router.get("/costs", async (req, res) => {
|
|
175
|
+
try {
|
|
176
|
+
const { agentId, from, to, profile } = req.query;
|
|
177
|
+
// Get sessions (cached, authoritative source from JSONL files)
|
|
178
|
+
let sessions = await (0, sessions_1.listSessions)(profile);
|
|
179
|
+
// Apply filters
|
|
180
|
+
if (agentId) {
|
|
181
|
+
sessions = sessions.filter((s) => s.agentId === agentId);
|
|
182
|
+
}
|
|
183
|
+
if (from) {
|
|
184
|
+
sessions = sessions.filter((s) => s.lastActivityAt >= from);
|
|
185
|
+
}
|
|
186
|
+
if (to) {
|
|
187
|
+
sessions = sessions.filter((s) => s.startedAt <= to);
|
|
188
|
+
}
|
|
189
|
+
// Aggregate by agent
|
|
190
|
+
const agentMap = new Map();
|
|
191
|
+
// Aggregate by model
|
|
192
|
+
const modelMap = new Map();
|
|
193
|
+
for (const session of sessions) {
|
|
194
|
+
// By agent
|
|
195
|
+
const existing = agentMap.get(session.agentId);
|
|
196
|
+
if (existing) {
|
|
197
|
+
existing.costUsd += session.costUsd;
|
|
198
|
+
existing.tokenCount += session.tokenCount;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
agentMap.set(session.agentId, {
|
|
202
|
+
agentId: session.agentId,
|
|
203
|
+
name: session.agentId,
|
|
204
|
+
costUsd: session.costUsd,
|
|
205
|
+
tokenCount: session.tokenCount,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// By model — use costByModel from session summary
|
|
209
|
+
for (const mc of session.costByModel) {
|
|
210
|
+
const em = modelMap.get(mc.model);
|
|
211
|
+
if (em) {
|
|
212
|
+
em.costUsd += mc.costUsd;
|
|
213
|
+
em.tokenCount += mc.tokenCount;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
modelMap.set(mc.model, { model: mc.model, costUsd: mc.costUsd, tokenCount: mc.tokenCount });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const byAgent = Array.from(agentMap.values()).sort((a, b) => b.costUsd - a.costUsd);
|
|
221
|
+
const byModel = Array.from(modelMap.values()).sort((a, b) => b.costUsd - a.costUsd);
|
|
222
|
+
const totalUsd = byAgent.reduce((sum, a) => sum + a.costUsd, 0);
|
|
223
|
+
res.json({ totalUsd, byAgent, byModel });
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
res.status(500).json({ error: err.message || "Failed to get costs" });
|
|
227
|
+
}
|
|
139
228
|
});
|
|
140
229
|
// ---------- Alerts ----------
|
|
141
|
-
router.get("/alerts", (
|
|
142
|
-
const
|
|
230
|
+
router.get("/alerts", async (req, res) => {
|
|
231
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 5, 1), 100);
|
|
232
|
+
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
|
233
|
+
const severityParam = req.query.severity;
|
|
234
|
+
const acknowledgedParam = req.query.acknowledged;
|
|
235
|
+
const agentIdParam = req.query.agentId;
|
|
236
|
+
const profileParam = req.query.profile;
|
|
237
|
+
const conditions = [];
|
|
238
|
+
const params = [];
|
|
239
|
+
// Profile filter: restrict to agents that belong to this profile
|
|
240
|
+
if (profileParam) {
|
|
241
|
+
const sessions = await (0, sessions_1.listSessions)(profileParam);
|
|
242
|
+
const agentIds = [...new Set(sessions.map((s) => s.agentId))];
|
|
243
|
+
if (agentIds.length > 0) {
|
|
244
|
+
conditions.push(`agentId IN (${agentIds.map(() => "?").join(", ")})`);
|
|
245
|
+
params.push(...agentIds);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// No agents in this profile — return empty
|
|
249
|
+
res.json({ alerts: [], total: 0 });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (severityParam) {
|
|
254
|
+
const severities = severityParam.split(",").map((s) => s.trim());
|
|
255
|
+
conditions.push(`severity IN (${severities.map(() => "?").join(", ")})`);
|
|
256
|
+
params.push(...severities);
|
|
257
|
+
}
|
|
258
|
+
if (acknowledgedParam === "true") {
|
|
259
|
+
conditions.push("acknowledged = 1");
|
|
260
|
+
}
|
|
261
|
+
else if (acknowledgedParam === "false") {
|
|
262
|
+
conditions.push("acknowledged = 0");
|
|
263
|
+
}
|
|
264
|
+
if (agentIdParam) {
|
|
265
|
+
conditions.push("agentId = ?");
|
|
266
|
+
params.push(agentIdParam);
|
|
267
|
+
}
|
|
268
|
+
const where = conditions.length > 0 ? ` WHERE ${conditions.join(" AND ")}` : "";
|
|
269
|
+
const total = db_1.default.prepare(`SELECT COUNT(*) as cnt FROM alerts${where}`).get(...params).cnt;
|
|
270
|
+
const alerts = db_1.default.prepare(`SELECT * FROM alerts${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`).all(...params, limit, offset).map((a) => ({
|
|
143
271
|
...a,
|
|
144
272
|
acknowledged: Boolean(a.acknowledged),
|
|
145
273
|
}));
|
|
146
|
-
res.json({ alerts });
|
|
274
|
+
res.json({ alerts, total });
|
|
275
|
+
});
|
|
276
|
+
router.post("/alerts/acknowledge-all", async (req, res) => {
|
|
277
|
+
const severityParam = req.query.severity;
|
|
278
|
+
const agentIdParam = req.query.agentId;
|
|
279
|
+
const profileParam = req.query.profile;
|
|
280
|
+
const conditions = ["acknowledged = 0"];
|
|
281
|
+
const params = [];
|
|
282
|
+
// Profile filter
|
|
283
|
+
if (profileParam) {
|
|
284
|
+
const sessions = await (0, sessions_1.listSessions)(profileParam);
|
|
285
|
+
const agentIds = [...new Set(sessions.map((s) => s.agentId))];
|
|
286
|
+
if (agentIds.length > 0) {
|
|
287
|
+
conditions.push(`agentId IN (${agentIds.map(() => "?").join(", ")})`);
|
|
288
|
+
params.push(...agentIds);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
res.json({ ok: true, count: 0 });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (severityParam) {
|
|
296
|
+
const severities = severityParam.split(",").map((s) => s.trim());
|
|
297
|
+
conditions.push(`severity IN (${severities.map(() => "?").join(", ")})`);
|
|
298
|
+
params.push(...severities);
|
|
299
|
+
}
|
|
300
|
+
if (agentIdParam) {
|
|
301
|
+
conditions.push("agentId = ?");
|
|
302
|
+
params.push(agentIdParam);
|
|
303
|
+
}
|
|
304
|
+
const where = ` WHERE ${conditions.join(" AND ")}`;
|
|
305
|
+
const result = db_1.default.prepare(`UPDATE alerts SET acknowledged = 1${where}`).run(...params);
|
|
306
|
+
res.json({ ok: true, count: result.changes });
|
|
147
307
|
});
|
|
148
308
|
router.post("/alerts/:id/acknowledge", (req, res) => {
|
|
149
309
|
const result = db_1.default.prepare("UPDATE alerts SET acknowledged = 1 WHERE id = ?").run(req.params.id);
|
|
@@ -153,12 +313,309 @@ router.post("/alerts/:id/acknowledge", (req, res) => {
|
|
|
153
313
|
}
|
|
154
314
|
res.json({ ok: true });
|
|
155
315
|
});
|
|
316
|
+
// --- Alert summary generation helpers ---
|
|
317
|
+
function isMeaningfulError(error) {
|
|
318
|
+
const cleaned = stripLogPrefix(error).trim();
|
|
319
|
+
// Filter out JSON fragments, single chars, pure punctuation, etc.
|
|
320
|
+
if (cleaned.length < 5)
|
|
321
|
+
return false;
|
|
322
|
+
if (/^[{}\[\],;:."'\s]+$/.test(cleaned))
|
|
323
|
+
return false;
|
|
324
|
+
if (/^\w+:\s*\d+[,}]?$/.test(cleaned))
|
|
325
|
+
return false; // "key: 123"
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
function generateErrorSummary(relatedErrors, agentName) {
|
|
329
|
+
if (relatedErrors.length === 0) {
|
|
330
|
+
return { summary: "Errors detected", description: `${agentName} encountered errors recently.` };
|
|
331
|
+
}
|
|
332
|
+
// Group errors by message, filtering out meaningless fragments
|
|
333
|
+
const groups = new Map();
|
|
334
|
+
for (const e of relatedErrors) {
|
|
335
|
+
const key = e.error;
|
|
336
|
+
const existing = groups.get(key);
|
|
337
|
+
if (existing) {
|
|
338
|
+
existing.count++;
|
|
339
|
+
if (e.timestamp > existing.latest)
|
|
340
|
+
existing.latest = e.timestamp;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
groups.set(key, { count: 1, latest: e.timestamp });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Find the most frequent *meaningful* error
|
|
347
|
+
let topError = "";
|
|
348
|
+
let topCount = 0;
|
|
349
|
+
for (const [msg, info] of groups) {
|
|
350
|
+
if (info.count > topCount && isMeaningfulError(msg)) {
|
|
351
|
+
topError = msg;
|
|
352
|
+
topCount = info.count;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
// If no meaningful error found, fall back to any error
|
|
356
|
+
if (!topError) {
|
|
357
|
+
for (const [msg, info] of groups) {
|
|
358
|
+
if (info.count > topCount) {
|
|
359
|
+
topError = msg;
|
|
360
|
+
topCount = info.count;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Pattern-match the top error for a human-readable summary
|
|
365
|
+
const summary = humanizeError(topError, agentName);
|
|
366
|
+
// Generate a plain-English description — no raw error text, no counts (frontend shows ×N)
|
|
367
|
+
const description = generatePlainDescription(summary, agentName, topError);
|
|
368
|
+
return { summary, description };
|
|
369
|
+
}
|
|
370
|
+
function generatePlainDescription(title, agentName, _topError) {
|
|
371
|
+
// Map known titles/patterns to plain-English impact descriptions
|
|
372
|
+
if (/can't connect|connection refused/i.test(title))
|
|
373
|
+
return `${agentName} is unable to reach a service it depends on. This may prevent it from completing its tasks until the service is back online.`;
|
|
374
|
+
if (/can't reach|DNS failure/i.test(title))
|
|
375
|
+
return `${agentName} can't look up a server address. The remote service may be down or there could be a network issue.`;
|
|
376
|
+
if (/connection lost|connection reset/i.test(title))
|
|
377
|
+
return `${agentName} keeps losing its connection to an external service. This usually means the remote server is unstable or overloaded.`;
|
|
378
|
+
if (/timed out/i.test(title))
|
|
379
|
+
return `${agentName} waited too long for a response. The target service may be slow or unresponsive.`;
|
|
380
|
+
if (/rate limit/i.test(title))
|
|
381
|
+
return `${agentName} is making too many API calls and being throttled. It needs to slow down or wait before retrying.`;
|
|
382
|
+
if (/authentication failed|auth token expired/i.test(title))
|
|
383
|
+
return `${agentName} can't authenticate with an external service. Its credentials may need to be refreshed or reconfigured.`;
|
|
384
|
+
if (/access denied|permission/i.test(title))
|
|
385
|
+
return `${agentName} tried to do something it doesn't have permission for. Check its access rights.`;
|
|
386
|
+
if (/Slack credentials not configured/i.test(title))
|
|
387
|
+
return `${agentName} can't send messages to Slack because the bot token isn't set up. Configure the Slack integration to fix this.`;
|
|
388
|
+
if (/message delivery failing/i.test(title))
|
|
389
|
+
return `${agentName} is failing to deliver messages. This may be caused by missing credentials or a service outage.`;
|
|
390
|
+
if (/Slack connection/i.test(title))
|
|
391
|
+
return `${agentName} is having trouble staying connected to Slack. The connection keeps dropping or timing out.`;
|
|
392
|
+
if (/crashed|unhandled/i.test(title))
|
|
393
|
+
return `${agentName} crashed unexpectedly. It may need to be restarted or the underlying bug needs to be fixed.`;
|
|
394
|
+
if (/misconfigured tool|configuration error/i.test(title))
|
|
395
|
+
return `${agentName} has a configuration issue that may cause some features to not work correctly. Review its settings.`;
|
|
396
|
+
if (/skill path/i.test(title))
|
|
397
|
+
return `${agentName} has a skill that points to an invalid location. The skill may not load correctly.`;
|
|
398
|
+
if (/can't find a required file|missing file/i.test(title))
|
|
399
|
+
return `${agentName} is looking for a file that doesn't exist. A dependency may be missing or a path may be wrong.`;
|
|
400
|
+
if (/invalid file operation/i.test(title))
|
|
401
|
+
return `${agentName} tried to read a directory as a file. There may be a path configuration issue.`;
|
|
402
|
+
if (/missing required command/i.test(title))
|
|
403
|
+
return `${agentName} needs a system command that isn't installed. Install the missing dependency.`;
|
|
404
|
+
if (/process was killed/i.test(title))
|
|
405
|
+
return `${agentName} was forcefully stopped. This could be due to resource limits or a manual intervention.`;
|
|
406
|
+
if (/code bug|null reference|type error/i.test(title))
|
|
407
|
+
return `${agentName} hit a bug in its code. This is likely a software issue that needs a fix.`;
|
|
408
|
+
if (/malformed data|invalid data/i.test(title))
|
|
409
|
+
return `${agentName} received data it couldn't understand. The data source may have changed format.`;
|
|
410
|
+
if (/database/i.test(title))
|
|
411
|
+
return `${agentName} is having trouble accessing its database. It may be locked by another process or corrupted.`;
|
|
412
|
+
if (/hostname conflict/i.test(title))
|
|
413
|
+
return `${agentName} detected a network naming conflict. Multiple services may be competing for the same name.`;
|
|
414
|
+
if (/spending exceeded/i.test(title))
|
|
415
|
+
return `${agentName} has gone over its budget. Consider reviewing its usage or adjusting the threshold.`;
|
|
416
|
+
// Generic fallback — still meaningful
|
|
417
|
+
return `${agentName} ran into a problem that may affect its ability to work properly. Check the technical details for more information.`;
|
|
418
|
+
}
|
|
419
|
+
// Strip common log prefixes: timestamps, log levels, bracketed tags
|
|
420
|
+
function stripLogPrefix(error) {
|
|
421
|
+
let cleaned = error;
|
|
422
|
+
// Strip ISO/custom timestamps at start: "2026-03-10T11:40:54.880+02:00 " or "[2026-03-10 ...]"
|
|
423
|
+
cleaned = cleaned.replace(/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[\d.+:ZT-]*\s*/g, "");
|
|
424
|
+
// Strip bracketed tags: [tools], [ERROR], [warn], etc.
|
|
425
|
+
cleaned = cleaned.replace(/^\[[\w.-]+\]\s*/g, "");
|
|
426
|
+
// Strip again (sometimes multiple tags)
|
|
427
|
+
cleaned = cleaned.replace(/^\[[\w.-]+\]\s*/g, "");
|
|
428
|
+
// Strip log levels
|
|
429
|
+
cleaned = cleaned.replace(/^(ERROR|WARN|INFO|DEBUG|FATAL|TRACE)[:\s]+/i, "");
|
|
430
|
+
return cleaned.trim();
|
|
431
|
+
}
|
|
432
|
+
function humanizeError(error, agentName) {
|
|
433
|
+
// Preprocess: strip log timestamps/tags to get the actual error content
|
|
434
|
+
const cleanedError = stripLogPrefix(error);
|
|
435
|
+
const patterns = [
|
|
436
|
+
// Network errors
|
|
437
|
+
[/ECONNREFUSED/i, `${agentName} can't connect to a service`],
|
|
438
|
+
[/ECONNRESET/i, `${agentName} lost connection unexpectedly`],
|
|
439
|
+
[/ETIMEDOUT/i, `${agentName} connection timed out`],
|
|
440
|
+
[/ENOTFOUND/i, `${agentName} can't reach a remote server`],
|
|
441
|
+
[/EADDRINUSE/i, `${agentName} port already in use`],
|
|
442
|
+
[/EPERM|EACCES/i, `${agentName} permission denied`],
|
|
443
|
+
[/ENOMEM|out of memory/i, `${agentName} ran out of memory`],
|
|
444
|
+
// HTTP errors
|
|
445
|
+
[/rate.?limit/i, `${agentName} hit API rate limit`],
|
|
446
|
+
[/401|unauthorized/i, `${agentName} authentication failed`],
|
|
447
|
+
[/403|forbidden/i, `${agentName} access denied`],
|
|
448
|
+
[/500|internal server error/i, `Remote server error for ${agentName}`],
|
|
449
|
+
[/502|bad gateway/i, `Bad gateway error for ${agentName}`],
|
|
450
|
+
[/503|service unavailable/i, `Service unavailable for ${agentName}`],
|
|
451
|
+
[/504|gateway timeout/i, `Gateway timeout for ${agentName}`],
|
|
452
|
+
// Code errors
|
|
453
|
+
[/Cannot read propert/i, `${agentName} hit a code bug (null reference)`],
|
|
454
|
+
[/is not a function/i, `${agentName} hit a code bug (type error)`],
|
|
455
|
+
[/JSON\.parse|Unexpected token/i, `${agentName} received malformed data`],
|
|
456
|
+
[/SQLITE_BUSY/i, `${agentName} database is locked`],
|
|
457
|
+
[/SQLITE_CORRUPT/i, `${agentName} database corruption detected`],
|
|
458
|
+
// Auth/cert
|
|
459
|
+
[/token.*expir/i, `${agentName} auth token expired`],
|
|
460
|
+
[/CERT_|certificate/i, `${agentName} SSL certificate error`],
|
|
461
|
+
// File/path errors
|
|
462
|
+
[/ENOENT|no such file/i, `${agentName} can't find a required file`],
|
|
463
|
+
[/EISDIR/i, `${agentName} invalid file operation`],
|
|
464
|
+
[/spawn.*ENOENT|command not found/i, `${agentName} missing required command`],
|
|
465
|
+
[/killed|SIGKILL|SIGTERM/i, `${agentName} process was killed`],
|
|
466
|
+
// OpenClaw / gateway specific
|
|
467
|
+
[/[Ss]lack\s*bot\s*token\s*missing/i, `${agentName} Slack credentials not configured`],
|
|
468
|
+
[/[Rr]etry failed for delivery/i, `${agentName} message delivery failing`],
|
|
469
|
+
[/delivery.*failed|failed.*delivery/i, `${agentName} message delivery failing`],
|
|
470
|
+
[/socket.?mode failed/i, `${agentName} Slack connection failing`],
|
|
471
|
+
[/pong wasn't received|pong.*timeout/i, `${agentName} Slack connection timing out`],
|
|
472
|
+
[/[Uu]nhandled promise rejection/i, `${agentName} crashed (unhandled error)`],
|
|
473
|
+
[/allowlist contains unknown/i, `${agentName} has misconfigured tool settings`],
|
|
474
|
+
[/[Ss]kipping skill path/i, `${agentName} has a skill path issue`],
|
|
475
|
+
[/hostname conflict/i, `${agentName} network hostname conflict`],
|
|
476
|
+
// Generic patterns (broad — keep last)
|
|
477
|
+
[/timeout/i, `${agentName} operation timed out`],
|
|
478
|
+
[/connection refused/i, `${agentName} can't connect to a service`],
|
|
479
|
+
[/connection reset/i, `${agentName} lost connection`],
|
|
480
|
+
[/missing.*config|config.*missing/i, `${agentName} missing configuration`],
|
|
481
|
+
[/crash|fatal|panic/i, `${agentName} crashed`],
|
|
482
|
+
];
|
|
483
|
+
for (const [pattern, summary] of patterns) {
|
|
484
|
+
if (pattern.test(cleanedError))
|
|
485
|
+
return summary;
|
|
486
|
+
}
|
|
487
|
+
// Smart fallback: interpret the error instead of truncating
|
|
488
|
+
// Try "ErrorType: message" format
|
|
489
|
+
const typeMatch = cleanedError.match(/^(\w+Error):\s*(.+?)(?:\n|$)/);
|
|
490
|
+
if (typeMatch) {
|
|
491
|
+
const shortMsg = typeMatch[2].trim();
|
|
492
|
+
return shortMsg.length > 50 ? `${agentName}: ${shortMsg.slice(0, 47)}...` : `${agentName}: ${shortMsg}`;
|
|
493
|
+
}
|
|
494
|
+
// Look for a verb phrase
|
|
495
|
+
const actionMatch = cleanedError.match(/(failed to \w+|cannot \w+|unable to \w+|could not \w+)/i);
|
|
496
|
+
if (actionMatch) {
|
|
497
|
+
return `${agentName} ${actionMatch[1].toLowerCase()}`;
|
|
498
|
+
}
|
|
499
|
+
// Keyword-based categorization — produce a real summary, not a truncation
|
|
500
|
+
const lower = cleanedError.toLowerCase();
|
|
501
|
+
if (lower.includes("connect") || lower.includes("socket"))
|
|
502
|
+
return `${agentName} connection issue`;
|
|
503
|
+
if (lower.includes("timeout") || lower.includes("timed out"))
|
|
504
|
+
return `${agentName} operation timed out`;
|
|
505
|
+
if (lower.includes("permission") || lower.includes("denied") || lower.includes("access"))
|
|
506
|
+
return `${agentName} permission error`;
|
|
507
|
+
if (lower.includes("invalid") || lower.includes("unexpected") || lower.includes("unknown"))
|
|
508
|
+
return `${agentName} configuration error`;
|
|
509
|
+
if (lower.includes("missing") || lower.includes("not found"))
|
|
510
|
+
return `${agentName} missing resource`;
|
|
511
|
+
if (lower.includes("failed") || lower.includes("error") || lower.includes("crash"))
|
|
512
|
+
return `${agentName} operation failed`;
|
|
513
|
+
// Last resort: first clause only, very short
|
|
514
|
+
const clean = cleanedError.replace(/\n.*/s, "").trim();
|
|
515
|
+
if (!clean || clean.length < 5) {
|
|
516
|
+
return `${agentName} encountered errors`;
|
|
517
|
+
}
|
|
518
|
+
const clause = clean.split(/[,;(]/)[0].trim();
|
|
519
|
+
return clause.length > 40 ? `${agentName} error` : `${agentName}: ${clause}`;
|
|
520
|
+
}
|
|
521
|
+
function cleanErrorForDisplay(error) {
|
|
522
|
+
// Strip log prefixes and stack trace, keep just the meaningful error
|
|
523
|
+
const cleaned = stripLogPrefix(error.split("\n")[0].trim());
|
|
524
|
+
return cleaned.length > 120 ? cleaned.slice(0, 117) + "..." : cleaned;
|
|
525
|
+
}
|
|
526
|
+
function generateStuckSummary(agentName, durationMinutes) {
|
|
527
|
+
return {
|
|
528
|
+
summary: `${agentName} stopped responding`,
|
|
529
|
+
description: `${agentName} hasn't sent a heartbeat in ${durationMinutes} minutes. The agent may have crashed, frozen, or lost its connection. It needs to be restarted or investigated.`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function generateCostSummary(agentName, currentCost, threshold) {
|
|
533
|
+
const overage = currentCost - threshold;
|
|
534
|
+
return {
|
|
535
|
+
summary: `${agentName} spending exceeded $${threshold}`,
|
|
536
|
+
description: `${agentName} has spent $${currentCost.toFixed(2)}, which is $${overage.toFixed(2)} over the $${threshold.toFixed(2)} threshold. This could mean the agent is running longer than expected or processing more data than usual.`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
router.get("/alerts/:id/details", (req, res) => {
|
|
540
|
+
const alert = db_1.default.prepare("SELECT * FROM alerts WHERE id = ?").get(req.params.id);
|
|
541
|
+
if (!alert) {
|
|
542
|
+
res.status(404).json({ error: "Alert not found" });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
alert.acknowledged = Boolean(alert.acknowledged);
|
|
546
|
+
// Get agent info
|
|
547
|
+
const agent = db_1.default.prepare("SELECT id, name, status, lastHeartbeat, costUsd FROM agents WHERE id = ?")
|
|
548
|
+
.get(alert.agentId);
|
|
549
|
+
const agentName = agent?.name || alert.agentId;
|
|
550
|
+
let relatedErrors = [];
|
|
551
|
+
let context = {};
|
|
552
|
+
let summary = "";
|
|
553
|
+
let description = "";
|
|
554
|
+
if (alert.type === "error") {
|
|
555
|
+
// Error spike: get the actual error events within the spike window before the alert
|
|
556
|
+
const ERROR_SPIKE_WINDOW_MS = parseInt(process.env.ERROR_SPIKE_WINDOW_MS || "60000", 10);
|
|
557
|
+
const windowStart = new Date(new Date(alert.timestamp).getTime() - ERROR_SPIKE_WINDOW_MS).toISOString();
|
|
558
|
+
relatedErrors = db_1.default.prepare(`
|
|
559
|
+
SELECT type, timestamp, data FROM events
|
|
560
|
+
WHERE agentId = ? AND type = 'error' AND timestamp > ? AND timestamp <= ?
|
|
561
|
+
ORDER BY timestamp DESC
|
|
562
|
+
`).all(alert.agentId, windowStart, alert.timestamp).map((e) => {
|
|
563
|
+
const parsed = JSON.parse(e.data);
|
|
564
|
+
return {
|
|
565
|
+
timestamp: e.timestamp,
|
|
566
|
+
error: parsed.error || parsed.message || "Unknown error",
|
|
567
|
+
raw: parsed,
|
|
568
|
+
};
|
|
569
|
+
});
|
|
570
|
+
const gen = generateErrorSummary(relatedErrors, agentName);
|
|
571
|
+
summary = gen.summary;
|
|
572
|
+
description = gen.description;
|
|
573
|
+
}
|
|
574
|
+
else if (alert.type === "stuck") {
|
|
575
|
+
// Stuck agent: show how long it's been stuck and last heartbeat
|
|
576
|
+
if (agent) {
|
|
577
|
+
const stuckSince = new Date(agent.lastHeartbeat);
|
|
578
|
+
const stuckDurationMs = new Date(alert.timestamp).getTime() - stuckSince.getTime();
|
|
579
|
+
const stuckMinutes = Math.round(stuckDurationMs / 60000);
|
|
580
|
+
context = {
|
|
581
|
+
lastHeartbeat: agent.lastHeartbeat,
|
|
582
|
+
stuckDurationMs,
|
|
583
|
+
stuckDurationMinutes: stuckMinutes,
|
|
584
|
+
agentStatus: agent.status,
|
|
585
|
+
};
|
|
586
|
+
const gen = generateStuckSummary(agentName, stuckMinutes);
|
|
587
|
+
summary = gen.summary;
|
|
588
|
+
description = gen.description;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
else if (alert.type === "cost_spike") {
|
|
592
|
+
// Cost threshold: show current spend and threshold
|
|
593
|
+
const COST_THRESHOLD_USD = parseFloat(process.env.COST_THRESHOLD_USD || "10");
|
|
594
|
+
context = {
|
|
595
|
+
currentCostUsd: agent?.costUsd || 0,
|
|
596
|
+
thresholdUsd: COST_THRESHOLD_USD,
|
|
597
|
+
overage: (agent?.costUsd || 0) - COST_THRESHOLD_USD,
|
|
598
|
+
};
|
|
599
|
+
const gen = generateCostSummary(agentName, agent?.costUsd || 0, COST_THRESHOLD_USD);
|
|
600
|
+
summary = gen.summary;
|
|
601
|
+
description = gen.description;
|
|
602
|
+
}
|
|
603
|
+
res.json({
|
|
604
|
+
alert,
|
|
605
|
+
agent: agent ? { id: agent.id, name: agent.name, status: agent.status } : null,
|
|
606
|
+
relatedErrors,
|
|
607
|
+
context,
|
|
608
|
+
title: summary,
|
|
609
|
+
description,
|
|
610
|
+
});
|
|
611
|
+
});
|
|
156
612
|
// ---------- Sessions (from JSONL files) ----------
|
|
157
613
|
router.get("/sessions", async (req, res) => {
|
|
158
614
|
try {
|
|
159
|
-
|
|
615
|
+
const profileFilter = req.query.profile;
|
|
616
|
+
let sessions = await (0, sessions_1.listSessions)(profileFilter);
|
|
160
617
|
// Filter by agentId
|
|
161
|
-
const { agentId, status, sort, limit } = req.query;
|
|
618
|
+
const { agentId, status, sort, limit, offset } = req.query;
|
|
162
619
|
if (agentId) {
|
|
163
620
|
sessions = sessions.filter((s) => s.agentId === agentId);
|
|
164
621
|
}
|
|
@@ -176,11 +633,20 @@ router.get("/sessions", async (req, res) => {
|
|
|
176
633
|
sessions.sort((a, b) => b.tokenCount - a.tokenCount);
|
|
177
634
|
}
|
|
178
635
|
// default: already sorted by lastActivityAt DESC
|
|
179
|
-
//
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
636
|
+
// Total count (after filtering, before pagination)
|
|
637
|
+
const total = sessions.length;
|
|
638
|
+
// Pagination
|
|
639
|
+
const limitVal = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 500);
|
|
640
|
+
const offsetVal = Math.max(parseInt(offset, 10) || 0, 0);
|
|
641
|
+
sessions = sessions.slice(offsetVal, offsetVal + limitVal);
|
|
642
|
+
// Attach project tags to each session
|
|
643
|
+
const sessionIds = sessions.map((s) => s.id);
|
|
644
|
+
const projectTags = (0, projects_1.bulkGetSessionProjects)(sessionIds);
|
|
645
|
+
const sessionsWithProjects = sessions.map((s) => ({
|
|
646
|
+
...s,
|
|
647
|
+
projects: projectTags.get(s.id) || [],
|
|
648
|
+
}));
|
|
649
|
+
res.json({ sessions: sessionsWithProjects, total });
|
|
184
650
|
}
|
|
185
651
|
catch (err) {
|
|
186
652
|
res.status(500).json({ error: err.message || "Failed to list sessions" });
|
|
@@ -188,7 +654,8 @@ router.get("/sessions", async (req, res) => {
|
|
|
188
654
|
});
|
|
189
655
|
router.get("/sessions/:id", async (req, res) => {
|
|
190
656
|
try {
|
|
191
|
-
const
|
|
657
|
+
const profileFilter = req.query.profile;
|
|
658
|
+
const detail = await (0, sessions_1.getSessionDetail)(req.params.id, profileFilter);
|
|
192
659
|
if (!detail) {
|
|
193
660
|
res.status(404).json({ error: "Session not found" });
|
|
194
661
|
return;
|
|
@@ -209,6 +676,162 @@ router.get("/sessions/:id/suggestions", async (req, res) => {
|
|
|
209
676
|
res.status(500).json({ error: err.message || "Failed to get suggestions" });
|
|
210
677
|
}
|
|
211
678
|
});
|
|
679
|
+
// ---------- Session Project Tags ----------
|
|
680
|
+
router.put("/sessions/:id/projects", (req, res) => {
|
|
681
|
+
const { projectIds } = req.body;
|
|
682
|
+
if (!Array.isArray(projectIds)) {
|
|
683
|
+
res.status(400).json({ error: "projectIds must be an array" });
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
(0, projects_1.setSessionProjects)(req.params.id, projectIds);
|
|
687
|
+
res.json({ ok: true });
|
|
688
|
+
});
|
|
689
|
+
router.delete("/sessions/:id/projects/:projectId", (req, res) => {
|
|
690
|
+
const ok = (0, projects_1.removeSessionFromProject)(req.params.projectId, req.params.id);
|
|
691
|
+
if (!ok) {
|
|
692
|
+
res.status(404).json({ error: "Project tag not found on session" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
res.json({ ok: true });
|
|
696
|
+
});
|
|
697
|
+
router.get("/sessions/:id/projects", (req, res) => {
|
|
698
|
+
const projects = (0, projects_1.getSessionProjects)(req.params.id);
|
|
699
|
+
res.json({ projects });
|
|
700
|
+
});
|
|
701
|
+
// ---------- Analytics ----------
|
|
702
|
+
router.get("/analytics", async (req, res) => {
|
|
703
|
+
try {
|
|
704
|
+
const profile = req.query.profile;
|
|
705
|
+
const groupBy = req.query.groupBy || "day";
|
|
706
|
+
const from = req.query.from;
|
|
707
|
+
const to = req.query.to;
|
|
708
|
+
let sessions = await (0, sessions_1.listSessions)(profile);
|
|
709
|
+
// For hourly view, default to last 3 days if no from specified
|
|
710
|
+
let effectiveFrom = from;
|
|
711
|
+
if (groupBy === "hour" && !from) {
|
|
712
|
+
const threeDaysAgo = new Date();
|
|
713
|
+
threeDaysAgo.setUTCDate(threeDaysAgo.getUTCDate() - 3);
|
|
714
|
+
effectiveFrom = threeDaysAgo.toISOString();
|
|
715
|
+
}
|
|
716
|
+
// Filter by date range using startedAt
|
|
717
|
+
if (effectiveFrom)
|
|
718
|
+
sessions = sessions.filter((s) => s.startedAt >= effectiveFrom);
|
|
719
|
+
if (to)
|
|
720
|
+
sessions = sessions.filter((s) => s.startedAt <= to);
|
|
721
|
+
// Get project tags for all sessions
|
|
722
|
+
const sessionIds = sessions.map((s) => s.id);
|
|
723
|
+
const projectTags = (0, projects_1.bulkGetSessionProjects)(sessionIds);
|
|
724
|
+
// Helper: get bucket date key for a session
|
|
725
|
+
function getBucketKey(dateStr) {
|
|
726
|
+
const d = new Date(dateStr);
|
|
727
|
+
if (groupBy === "hour") {
|
|
728
|
+
return d.toISOString().slice(0, 13) + ":00"; // "2026-03-10T14:00"
|
|
729
|
+
}
|
|
730
|
+
if (groupBy === "week") {
|
|
731
|
+
// ISO week starts on Monday
|
|
732
|
+
const day = d.getUTCDay();
|
|
733
|
+
const diff = (day === 0 ? -6 : 1) - day; // adjust to Monday
|
|
734
|
+
const monday = new Date(d);
|
|
735
|
+
monday.setUTCDate(monday.getUTCDate() + diff);
|
|
736
|
+
return monday.toISOString().slice(0, 10);
|
|
737
|
+
}
|
|
738
|
+
return d.toISOString().slice(0, 10);
|
|
739
|
+
}
|
|
740
|
+
// Helper: generate all date keys between min and max
|
|
741
|
+
function generateAllKeys(minDate, maxDate) {
|
|
742
|
+
const keys = [];
|
|
743
|
+
const current = new Date(minDate);
|
|
744
|
+
const end = new Date(maxDate);
|
|
745
|
+
while (current <= end) {
|
|
746
|
+
if (groupBy === "hour") {
|
|
747
|
+
keys.push(current.toISOString().slice(0, 13) + ":00");
|
|
748
|
+
current.setUTCHours(current.getUTCHours() + 1);
|
|
749
|
+
}
|
|
750
|
+
else if (groupBy === "week") {
|
|
751
|
+
keys.push(current.toISOString().slice(0, 10));
|
|
752
|
+
current.setUTCDate(current.getUTCDate() + 7);
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
keys.push(current.toISOString().slice(0, 10));
|
|
756
|
+
current.setUTCDate(current.getUTCDate() + 1);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return keys;
|
|
760
|
+
}
|
|
761
|
+
const totalMap = new Map();
|
|
762
|
+
const agentMap = new Map();
|
|
763
|
+
const projectMap = new Map();
|
|
764
|
+
for (const s of sessions) {
|
|
765
|
+
const key = getBucketKey(s.startedAt);
|
|
766
|
+
// Total
|
|
767
|
+
const tb = totalMap.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 };
|
|
768
|
+
tb.costUsd += s.costUsd;
|
|
769
|
+
tb.tokenCount += s.tokenCount;
|
|
770
|
+
tb.sessionCount += 1;
|
|
771
|
+
totalMap.set(key, tb);
|
|
772
|
+
// By agent
|
|
773
|
+
if (!agentMap.has(s.agentId))
|
|
774
|
+
agentMap.set(s.agentId, new Map());
|
|
775
|
+
const ab = agentMap.get(s.agentId).get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 };
|
|
776
|
+
ab.costUsd += s.costUsd;
|
|
777
|
+
ab.tokenCount += s.tokenCount;
|
|
778
|
+
ab.sessionCount += 1;
|
|
779
|
+
agentMap.get(s.agentId).set(key, ab);
|
|
780
|
+
// By project (session can belong to multiple projects)
|
|
781
|
+
const projects = projectTags.get(s.id) || [];
|
|
782
|
+
for (const p of projects) {
|
|
783
|
+
if (!projectMap.has(p.id))
|
|
784
|
+
projectMap.set(p.id, { name: p.name, buckets: new Map() });
|
|
785
|
+
const pb = projectMap.get(p.id).buckets.get(key) || { costUsd: 0, tokenCount: 0, sessionCount: 0 };
|
|
786
|
+
pb.costUsd += s.costUsd;
|
|
787
|
+
pb.tokenCount += s.tokenCount;
|
|
788
|
+
pb.sessionCount += 1;
|
|
789
|
+
projectMap.get(p.id).buckets.set(key, pb);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// Determine date range for zero-filling
|
|
793
|
+
const allKeys = [...totalMap.keys()].sort();
|
|
794
|
+
let rangeStart = effectiveFrom ? getBucketKey(effectiveFrom) : (from ? getBucketKey(from) : allKeys[0]);
|
|
795
|
+
let rangeEnd = to ? getBucketKey(to) : allKeys[allKeys.length - 1];
|
|
796
|
+
// For hourly view, always extend to current time so chart isn't cut off
|
|
797
|
+
if (groupBy === "hour" && !to) {
|
|
798
|
+
const nowKey = getBucketKey(new Date().toISOString());
|
|
799
|
+
if (!rangeEnd || nowKey > rangeEnd)
|
|
800
|
+
rangeEnd = nowKey;
|
|
801
|
+
}
|
|
802
|
+
if (!rangeStart || !rangeEnd) {
|
|
803
|
+
res.json({ buckets: [], byAgent: [], byProject: [] });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const allDates = generateAllKeys(rangeStart, rangeEnd);
|
|
807
|
+
const zeroBucket = { costUsd: 0, tokenCount: 0, sessionCount: 0 };
|
|
808
|
+
// Build response with zero-filled buckets
|
|
809
|
+
const buckets = allDates.map((date) => ({
|
|
810
|
+
date,
|
|
811
|
+
...(totalMap.get(date) || zeroBucket),
|
|
812
|
+
}));
|
|
813
|
+
const byAgent = [...agentMap.entries()].map(([agentId, bMap]) => ({
|
|
814
|
+
agentId,
|
|
815
|
+
name: agentId,
|
|
816
|
+
buckets: allDates.map((date) => ({
|
|
817
|
+
date,
|
|
818
|
+
...(bMap.get(date) || zeroBucket),
|
|
819
|
+
})),
|
|
820
|
+
}));
|
|
821
|
+
const byProject = [...projectMap.entries()].map(([projectId, { name, buckets: bMap }]) => ({
|
|
822
|
+
projectId,
|
|
823
|
+
name,
|
|
824
|
+
buckets: allDates.map((date) => ({
|
|
825
|
+
date,
|
|
826
|
+
...(bMap.get(date) || zeroBucket),
|
|
827
|
+
})),
|
|
828
|
+
}));
|
|
829
|
+
res.json({ buckets, byAgent, byProject });
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
res.status(500).json({ error: err.message || "Failed to get analytics" });
|
|
833
|
+
}
|
|
834
|
+
});
|
|
212
835
|
// ---------- Projects ----------
|
|
213
836
|
router.post("/projects", (req, res) => {
|
|
214
837
|
const { name, description } = req.body;
|
|
@@ -219,8 +842,18 @@ router.post("/projects", (req, res) => {
|
|
|
219
842
|
const project = (0, projects_1.createProject)(name, description);
|
|
220
843
|
res.status(201).json(project);
|
|
221
844
|
});
|
|
222
|
-
router.get("/projects", (_req, res) => {
|
|
223
|
-
const
|
|
845
|
+
router.get("/projects", async (_req, res) => {
|
|
846
|
+
const profileParam = _req.query.profile;
|
|
847
|
+
let projects = (0, projects_1.listProjects)();
|
|
848
|
+
// Filter projects to only include those with sessions in the selected profile
|
|
849
|
+
if (profileParam) {
|
|
850
|
+
const sessions = await (0, sessions_1.listSessions)(profileParam);
|
|
851
|
+
const profileSessionIds = new Set(sessions.map((s) => s.id));
|
|
852
|
+
projects = projects.filter((p) => {
|
|
853
|
+
const projectSessionIds = db_1.default.prepare("SELECT sessionId FROM project_sessions WHERE projectId = ?").all(p.id);
|
|
854
|
+
return projectSessionIds.some((ps) => profileSessionIds.has(ps.sessionId));
|
|
855
|
+
});
|
|
856
|
+
}
|
|
224
857
|
res.json({ projects });
|
|
225
858
|
});
|
|
226
859
|
router.get("/projects/:id", async (req, res) => {
|