bulltrackers-module 1.0.295 → 1.0.296
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.
|
@@ -19,130 +19,129 @@ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
|
19
19
|
const router = express.Router();
|
|
20
20
|
const { db, logger } = dependencies;
|
|
21
21
|
|
|
22
|
+
// Helper to get fresh manifest
|
|
23
|
+
const getFullManifest = () => getManifest([], unifiedCalculations, dependencies);
|
|
24
|
+
|
|
22
25
|
// --- 1. TOPOLOGY VISUALIZER ---
|
|
23
|
-
// Returns nodes/edges for React Flow or Cytoscape
|
|
24
26
|
router.get('/topology', async (req, res) => {
|
|
25
27
|
try {
|
|
26
|
-
|
|
27
|
-
// Passing [] for productLines ensures we get the FULL graph
|
|
28
|
-
const manifest = getManifest([], unifiedCalculations, dependencies);
|
|
29
|
-
|
|
28
|
+
const manifest = getFullManifest();
|
|
30
29
|
const nodes = [];
|
|
31
30
|
const edges = [];
|
|
32
31
|
|
|
33
32
|
manifest.forEach(calc => {
|
|
34
|
-
// Nodes
|
|
35
33
|
nodes.push({
|
|
36
34
|
id: calc.name,
|
|
37
35
|
data: {
|
|
38
36
|
label: calc.name,
|
|
39
37
|
layer: calc.category,
|
|
40
|
-
pass: calc.pass,
|
|
38
|
+
pass: calc.pass,
|
|
41
39
|
isHistorical: calc.isHistorical,
|
|
42
40
|
type: calc.type
|
|
43
41
|
},
|
|
44
|
-
position: { x: 0, y: 0 }
|
|
42
|
+
position: { x: 0, y: 0 }
|
|
45
43
|
});
|
|
46
44
|
|
|
47
|
-
// Dependency Edges (Calc -> Calc)
|
|
48
45
|
if (calc.dependencies) {
|
|
49
46
|
calc.dependencies.forEach(dep => {
|
|
50
47
|
edges.push({
|
|
51
48
|
id: `e-${dep}-${calc.name}`,
|
|
52
49
|
source: normalizeName(dep),
|
|
53
50
|
target: calc.name,
|
|
54
|
-
type: '
|
|
55
|
-
animated: false
|
|
51
|
+
type: 'smoothstep'
|
|
56
52
|
});
|
|
57
53
|
});
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
// Root Data Edges (Data -> Calc)
|
|
61
56
|
if (calc.rootDataDependencies) {
|
|
62
57
|
calc.rootDataDependencies.forEach(root => {
|
|
63
|
-
// Ensure a node exists for the root data type
|
|
64
58
|
const rootId = `ROOT_${root.toUpperCase()}`;
|
|
65
59
|
if (!nodes.find(n => n.id === rootId)) {
|
|
66
60
|
nodes.push({
|
|
67
61
|
id: rootId,
|
|
68
|
-
type: 'input',
|
|
62
|
+
type: 'input',
|
|
69
63
|
data: { label: `${root.toUpperCase()} DB` },
|
|
70
|
-
position: { x: 0, y: 0 }
|
|
64
|
+
position: { x: 0, y: 0 },
|
|
65
|
+
style: { background: '#f0f0f0', border: '1px solid #777' }
|
|
71
66
|
});
|
|
72
67
|
}
|
|
73
|
-
|
|
74
68
|
edges.push({
|
|
75
69
|
id: `e-root-${root}-${calc.name}`,
|
|
76
70
|
source: rootId,
|
|
77
71
|
target: calc.name,
|
|
78
72
|
animated: true,
|
|
79
|
-
style: { stroke: '#ff0072' }
|
|
73
|
+
style: { stroke: '#ff0072' }
|
|
80
74
|
});
|
|
81
75
|
});
|
|
82
76
|
}
|
|
83
77
|
});
|
|
84
78
|
|
|
85
|
-
res.json({
|
|
86
|
-
summary: {
|
|
87
|
-
totalNodes: nodes.length,
|
|
88
|
-
totalEdges: edges.length
|
|
89
|
-
},
|
|
90
|
-
nodes,
|
|
91
|
-
edges
|
|
92
|
-
});
|
|
79
|
+
res.json({ summary: { totalNodes: nodes.length, totalEdges: edges.length }, nodes, edges });
|
|
93
80
|
} catch (e) {
|
|
94
81
|
logger.log('ERROR', '[AdminAPI] Topology build failed', e);
|
|
95
82
|
res.status(500).json({ error: e.message });
|
|
96
83
|
}
|
|
97
84
|
});
|
|
98
85
|
|
|
99
|
-
// --- 2. STATUS MATRIX (Calendar
|
|
100
|
-
//
|
|
86
|
+
// --- 2. STATUS MATRIX (Calendar / State UI) ---
|
|
87
|
+
// Returns status of ALL computations across a date range.
|
|
88
|
+
// ENHANCED: Cross-references Manifest to detect "PENDING" (Not run yet) vs "MISSING".
|
|
101
89
|
router.get('/matrix', async (req, res) => {
|
|
102
90
|
const { start, end } = req.query;
|
|
103
|
-
if (!start || !end) return res.status(400).json({ error: "Start
|
|
91
|
+
if (!start || !end) return res.status(400).json({ error: "Start and End dates required." });
|
|
104
92
|
|
|
105
93
|
try {
|
|
106
|
-
const startDate = new Date(start);
|
|
107
|
-
const endDate = new Date(end);
|
|
94
|
+
const startDate = new Date(String(start));
|
|
95
|
+
const endDate = new Date(String(end));
|
|
108
96
|
const dates = [];
|
|
109
|
-
|
|
110
|
-
// Generate date range
|
|
111
97
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
112
98
|
dates.push(d.toISOString().slice(0, 10));
|
|
113
99
|
}
|
|
114
100
|
|
|
115
|
-
const
|
|
101
|
+
const manifest = getFullManifest();
|
|
102
|
+
const allCalcNames = new Set(manifest.map(c => c.name));
|
|
103
|
+
|
|
104
|
+
const limit = pLimit(20);
|
|
116
105
|
const matrix = {};
|
|
117
106
|
|
|
118
107
|
await Promise.all(dates.map(date => limit(async () => {
|
|
119
|
-
// Fetch
|
|
108
|
+
// Fetch Status and Root Data Availability
|
|
120
109
|
const [statusSnap, rootSnap] = await Promise.all([
|
|
121
110
|
db.collection('computation_status').doc(date).get(),
|
|
122
111
|
db.collection('system_root_data_index').doc(date).get()
|
|
123
112
|
]);
|
|
124
113
|
|
|
125
|
-
// Flatten status for frontend (calcName -> { status: 'COMPLETED' | 'IMPOSSIBLE' })
|
|
126
114
|
const statusData = statusSnap.exists ? statusSnap.data() : {};
|
|
127
115
|
const rootData = rootSnap.exists ? rootSnap.data() : { status: { hasPortfolio: false } };
|
|
128
116
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
117
|
+
const dateStatus = {};
|
|
118
|
+
|
|
119
|
+
// Check every calculation in the Manifest
|
|
120
|
+
allCalcNames.forEach(calcName => {
|
|
121
|
+
const entry = statusData[calcName];
|
|
122
|
+
|
|
123
|
+
if (!entry) {
|
|
124
|
+
// If root data exists but calc is missing -> PENDING
|
|
125
|
+
// If no root data -> WAITING_DATA
|
|
126
|
+
dateStatus[calcName] = rootData.status?.hasPortfolio ? 'PENDING' : 'WAITING_DATA';
|
|
127
|
+
} else if (typeof entry === 'object') {
|
|
128
|
+
if (entry.hash && typeof entry.hash === 'string' && entry.hash.startsWith('IMPOSSIBLE')) {
|
|
129
|
+
dateStatus[calcName] = 'IMPOSSIBLE';
|
|
130
|
+
} else if (entry.hash === false) {
|
|
131
|
+
dateStatus[calcName] = 'BLOCKED';
|
|
132
|
+
} else {
|
|
133
|
+
dateStatus[calcName] = 'COMPLETED';
|
|
134
|
+
}
|
|
136
135
|
} else if (entry === 'IMPOSSIBLE') {
|
|
137
|
-
|
|
138
|
-
} else
|
|
139
|
-
|
|
136
|
+
dateStatus[calcName] = 'IMPOSSIBLE';
|
|
137
|
+
} else {
|
|
138
|
+
dateStatus[calcName] = 'COMPLETED';
|
|
140
139
|
}
|
|
141
140
|
});
|
|
142
141
|
|
|
143
142
|
matrix[date] = {
|
|
144
|
-
dataAvailable: rootData.status || {},
|
|
145
|
-
calculations:
|
|
143
|
+
dataAvailable: rootData.status || {},
|
|
144
|
+
calculations: dateStatus
|
|
146
145
|
};
|
|
147
146
|
})));
|
|
148
147
|
|
|
@@ -153,74 +152,227 @@ const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
|
153
152
|
}
|
|
154
153
|
});
|
|
155
154
|
|
|
156
|
-
// --- 3.
|
|
157
|
-
//
|
|
158
|
-
router.get('/
|
|
159
|
-
const { date
|
|
155
|
+
// --- 3. PIPELINE STATE (Progress Bar) ---
|
|
156
|
+
// Shows realtime status of the 5-pass system for a specific date
|
|
157
|
+
router.get('/pipeline/state', async (req, res) => {
|
|
158
|
+
const { date } = req.query;
|
|
159
|
+
if (!date) return res.status(400).json({ error: "Date required" });
|
|
160
|
+
|
|
160
161
|
try {
|
|
161
|
-
// We search across all potential passes (1-5) because we might not know which one it belongs to
|
|
162
162
|
const passes = ['1', '2', '3', '4', '5'];
|
|
163
|
-
|
|
163
|
+
const state = await Promise.all(passes.map(async (pass) => {
|
|
164
|
+
// We use the Audit Ledger which is the source of truth for execution state
|
|
165
|
+
const tasksSnap = await db.collection(`computation_audit_ledger/${date}/passes/${pass}/tasks`).get();
|
|
166
|
+
|
|
167
|
+
const stats = {
|
|
168
|
+
pending: 0,
|
|
169
|
+
inProgress: 0,
|
|
170
|
+
completed: 0,
|
|
171
|
+
failed: 0,
|
|
172
|
+
totalMemoryMB: 0,
|
|
173
|
+
avgDurationMs: 0
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const durations = [];
|
|
177
|
+
|
|
178
|
+
tasksSnap.forEach(doc => {
|
|
179
|
+
const data = doc.data();
|
|
180
|
+
const s = (data.status || 'UNKNOWN').toLowerCase();
|
|
181
|
+
if (stats[s] !== undefined) stats[s]++;
|
|
182
|
+
else stats[s] = 1;
|
|
164
183
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
184
|
+
if (data.telemetry?.lastMemory?.rssMB) {
|
|
185
|
+
stats.totalMemoryMB += data.telemetry.lastMemory.rssMB;
|
|
186
|
+
}
|
|
187
|
+
if (data.completedAt && data.startedAt) {
|
|
188
|
+
durations.push(new Date(data.completedAt).getTime() - new Date(data.startedAt).getTime());
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
stats.avgDurationMs = durations.length ?
|
|
193
|
+
Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
|
|
194
|
+
|
|
195
|
+
return { pass, stats, totalTasks: tasksSnap.size };
|
|
173
196
|
}));
|
|
174
197
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
198
|
+
res.json({ date, passes: state });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
res.status(500).json({ error: e.message });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// --- 4. DEPENDENCY TRACER (Blast Radius) ---
|
|
205
|
+
router.get('/trace/:calcName', async (req, res) => {
|
|
206
|
+
const { calcName } = req.params;
|
|
207
|
+
const mode = req.query.mode || 'downstream'; // 'upstream' or 'downstream'
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const manifest = getFullManifest();
|
|
211
|
+
const manifestMap = new Map(manifest.map(c => [c.name, c]));
|
|
212
|
+
|
|
213
|
+
if (!manifestMap.has(calcName)) return res.status(404).json({ error: 'Calculation not found' });
|
|
214
|
+
|
|
215
|
+
const trace = { root: calcName, chain: [] };
|
|
216
|
+
|
|
217
|
+
if (mode === 'upstream') {
|
|
218
|
+
// What does X depend on?
|
|
219
|
+
const visited = new Set();
|
|
220
|
+
const walk = (name, depth = 0) => {
|
|
221
|
+
if (visited.has(name) || depth > 10) return;
|
|
222
|
+
visited.add(name);
|
|
223
|
+
const calc = manifestMap.get(name);
|
|
224
|
+
if (!calc) return;
|
|
225
|
+
|
|
226
|
+
trace.chain.push({
|
|
227
|
+
name, depth, pass: calc.pass, type: calc.type
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
calc.dependencies?.forEach(dep => walk(dep, depth + 1));
|
|
231
|
+
};
|
|
232
|
+
walk(calcName);
|
|
233
|
+
} else {
|
|
234
|
+
// What depends on X? (Downstream / Impact)
|
|
235
|
+
const reverseGraph = new Map();
|
|
236
|
+
manifest.forEach(c => {
|
|
237
|
+
c.dependencies?.forEach(dep => {
|
|
238
|
+
const normDep = normalizeName(dep);
|
|
239
|
+
if (!reverseGraph.has(normDep)) reverseGraph.set(normDep, []);
|
|
240
|
+
reverseGraph.get(normDep).push(c.name);
|
|
241
|
+
});
|
|
179
242
|
});
|
|
243
|
+
|
|
244
|
+
const visited = new Set();
|
|
245
|
+
const walk = (name, depth = 0) => {
|
|
246
|
+
if (visited.has(name) || depth > 10) return;
|
|
247
|
+
visited.add(name);
|
|
248
|
+
|
|
249
|
+
const calc = manifestMap.get(name);
|
|
250
|
+
trace.chain.push({
|
|
251
|
+
name, depth, pass: calc?.pass
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
reverseGraph.get(name)?.forEach(child => walk(child, depth + 1));
|
|
255
|
+
};
|
|
256
|
+
walk(calcName);
|
|
180
257
|
}
|
|
181
258
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
259
|
+
res.json(trace);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
res.status(500).json({ error: e.message });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// --- 5. CONTRACT VIOLATIONS (Quality Gate) ---
|
|
266
|
+
router.get('/violations', async (req, res) => {
|
|
267
|
+
const days = parseInt(String(req.query.days)) || 7;
|
|
268
|
+
const cutoff = new Date();
|
|
269
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
// Check DLQ for Semantic Failures (Hard Violations)
|
|
273
|
+
const dlqSnap = await db.collection('computation_dead_letter_queue')
|
|
274
|
+
.where('finalAttemptAt', '>', cutoff)
|
|
275
|
+
.where('error.stage', '==', 'SEMANTIC_GATE')
|
|
276
|
+
.limit(50)
|
|
277
|
+
.get();
|
|
278
|
+
|
|
279
|
+
const violations = [];
|
|
280
|
+
dlqSnap.forEach(doc => {
|
|
281
|
+
const data = doc.data();
|
|
282
|
+
violations.push({
|
|
283
|
+
id: doc.id,
|
|
284
|
+
computation: data.originalData.computation,
|
|
285
|
+
date: data.originalData.date,
|
|
286
|
+
reason: data.error.message,
|
|
287
|
+
type: 'HARD_VIOLATION',
|
|
288
|
+
timestamp: data.finalAttemptAt
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Check Audit Logs for Soft Anomalies (Statistical warnings)
|
|
293
|
+
const anomalySnap = await db.collectionGroup('history')
|
|
294
|
+
.where('triggerTime', '>', cutoff.toISOString())
|
|
295
|
+
.where('anomalies', '!=', []) // Firestore != operator
|
|
296
|
+
.limit(50)
|
|
297
|
+
.get();
|
|
298
|
+
|
|
299
|
+
anomalySnap.forEach(doc => {
|
|
300
|
+
const data = doc.data();
|
|
301
|
+
data.anomalies?.forEach(anomaly => {
|
|
302
|
+
violations.push({
|
|
303
|
+
id: doc.id,
|
|
304
|
+
computation: data.computationName,
|
|
305
|
+
date: data.targetDate,
|
|
306
|
+
reason: anomaly,
|
|
307
|
+
type: 'SOFT_ANOMALY',
|
|
308
|
+
timestamp: data.triggerTime
|
|
309
|
+
});
|
|
310
|
+
});
|
|
188
311
|
});
|
|
189
312
|
|
|
313
|
+
// Sort by time desc
|
|
314
|
+
violations.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
315
|
+
|
|
316
|
+
res.json({ count: violations.length, violations });
|
|
190
317
|
} catch (e) {
|
|
191
|
-
logger.log('ERROR', `[AdminAPI] Inspect failed for ${calcName}`, e);
|
|
192
318
|
res.status(500).json({ error: e.message });
|
|
193
319
|
}
|
|
194
320
|
});
|
|
195
321
|
|
|
196
|
-
// ---
|
|
197
|
-
|
|
198
|
-
|
|
322
|
+
// --- 6. MEMORY HOTSPOTS (Forensics) ---
|
|
323
|
+
router.get('/memory/hotspots', async (req, res) => {
|
|
324
|
+
const thresholdMB = parseInt(String(req.query.threshold)) || 1000; // 1GB default
|
|
325
|
+
|
|
199
326
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
327
|
+
// Ledger tasks maintain 'telemetry.lastMemory'
|
|
328
|
+
// We use collectionGroup to search across all dates/passes
|
|
329
|
+
const snapshot = await db.collectionGroup('tasks')
|
|
330
|
+
.where('telemetry.lastMemory.rssMB', '>', thresholdMB)
|
|
331
|
+
.orderBy('telemetry.lastMemory.rssMB', 'desc')
|
|
332
|
+
.limit(20)
|
|
333
|
+
.get();
|
|
334
|
+
|
|
335
|
+
const hotspots = [];
|
|
336
|
+
snapshot.forEach(doc => {
|
|
337
|
+
const data = doc.data();
|
|
338
|
+
hotspots.push({
|
|
339
|
+
computation: data.computation,
|
|
340
|
+
rssMB: data.telemetry.lastMemory.rssMB,
|
|
341
|
+
heapMB: data.telemetry.lastMemory.heapUsedMB,
|
|
342
|
+
status: data.status,
|
|
343
|
+
worker: data.workerId,
|
|
344
|
+
date: doc.ref.parent.parent.parent.parent.id // traversing path to get date
|
|
345
|
+
});
|
|
219
346
|
});
|
|
347
|
+
|
|
348
|
+
res.json({ count: hotspots.length, hotspots });
|
|
349
|
+
} catch (e) {
|
|
350
|
+
res.status(500).json({ error: e.message });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// --- 7. FLIGHT RECORDER (Inspection) ---
|
|
355
|
+
// Existing inspection endpoint kept for drill-down
|
|
356
|
+
router.get('/inspect/:date/:calcName', async (req, res) => {
|
|
357
|
+
const { date, calcName } = req.params;
|
|
358
|
+
try {
|
|
359
|
+
const passes = ['1', '2', '3', '4', '5'];
|
|
360
|
+
let executionRecord = null;
|
|
220
361
|
|
|
362
|
+
await Promise.all(passes.map(async (pass) => {
|
|
363
|
+
if (executionRecord) return;
|
|
364
|
+
const ref = db.doc(`computation_audit_ledger/${date}/passes/${pass}/tasks/${calcName}`);
|
|
365
|
+
const snap = await ref.get();
|
|
366
|
+
if (snap.exists) executionRecord = { pass, ...snap.data() };
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
if (!executionRecord) return res.status(404).json({ status: 'NOT_FOUND' });
|
|
370
|
+
|
|
371
|
+
const contractSnap = await db.collection('system_contracts').doc(calcName).get();
|
|
372
|
+
|
|
221
373
|
res.json({
|
|
222
|
-
|
|
223
|
-
|
|
374
|
+
execution: executionRecord,
|
|
375
|
+
contract: contractSnap.exists ? contractSnap.data() : null
|
|
224
376
|
});
|
|
225
377
|
} catch (e) {
|
|
226
378
|
res.status(500).json({ error: e.message });
|