bulltrackers-module 1.0.592 → 1.0.593
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/functions/old-generic-api/admin-api/index.js +895 -0
- package/functions/old-generic-api/helpers/api_helpers.js +457 -0
- package/functions/old-generic-api/index.js +204 -0
- package/functions/old-generic-api/user-api/helpers/alerts/alert_helpers.js +355 -0
- package/functions/old-generic-api/user-api/helpers/alerts/subscription_helpers.js +327 -0
- package/functions/old-generic-api/user-api/helpers/alerts/test_alert_helpers.js +212 -0
- package/functions/old-generic-api/user-api/helpers/collection_helpers.js +193 -0
- package/functions/old-generic-api/user-api/helpers/core/compression_helpers.js +68 -0
- package/functions/old-generic-api/user-api/helpers/core/data_lookup_helpers.js +256 -0
- package/functions/old-generic-api/user-api/helpers/core/path_resolution_helpers.js +640 -0
- package/functions/old-generic-api/user-api/helpers/core/user_status_helpers.js +195 -0
- package/functions/old-generic-api/user-api/helpers/data/computation_helpers.js +503 -0
- package/functions/old-generic-api/user-api/helpers/data/instrument_helpers.js +55 -0
- package/functions/old-generic-api/user-api/helpers/data/portfolio_helpers.js +245 -0
- package/functions/old-generic-api/user-api/helpers/data/social_helpers.js +174 -0
- package/functions/old-generic-api/user-api/helpers/data_helpers.js +87 -0
- package/functions/old-generic-api/user-api/helpers/dev/dev_helpers.js +336 -0
- package/functions/old-generic-api/user-api/helpers/fetch/on_demand_fetch_helpers.js +615 -0
- package/functions/old-generic-api/user-api/helpers/metrics/personalized_metrics_helpers.js +231 -0
- package/functions/old-generic-api/user-api/helpers/notifications/notification_helpers.js +641 -0
- package/functions/old-generic-api/user-api/helpers/profile/pi_profile_helpers.js +182 -0
- package/functions/old-generic-api/user-api/helpers/profile/profile_view_helpers.js +137 -0
- package/functions/old-generic-api/user-api/helpers/profile/user_profile_helpers.js +190 -0
- package/functions/old-generic-api/user-api/helpers/recommendations/recommendation_helpers.js +66 -0
- package/functions/old-generic-api/user-api/helpers/reviews/review_helpers.js +550 -0
- package/functions/old-generic-api/user-api/helpers/rootdata/rootdata_aggregation_helpers.js +378 -0
- package/functions/old-generic-api/user-api/helpers/search/pi_request_helpers.js +295 -0
- package/functions/old-generic-api/user-api/helpers/search/pi_search_helpers.js +162 -0
- package/functions/old-generic-api/user-api/helpers/sync/user_sync_helpers.js +677 -0
- package/functions/old-generic-api/user-api/helpers/verification/verification_helpers.js +323 -0
- package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_analytics_helpers.js +96 -0
- package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_data_helpers.js +141 -0
- package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_generation_helpers.js +310 -0
- package/functions/old-generic-api/user-api/helpers/watchlist/watchlist_management_helpers.js +829 -0
- package/functions/old-generic-api/user-api/index.js +109 -0
- package/package.json +2 -2
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Admin API Router
|
|
3
|
+
* Sub-module for system observability, debugging, and visualization.
|
|
4
|
+
* Mounted at /admin within the Generic API.
|
|
5
|
+
* UPDATED: Added Stale Task Detection, Root Data Health, and SimHash Inspection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const pLimit = require('p-limit');
|
|
10
|
+
const { getManifest } = require('../../computation-system/topology/ManifestLoader');
|
|
11
|
+
const { normalizeName } = require('../../computation-system/utils/utils');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Factory to create the Admin Router.
|
|
15
|
+
* @param {object} config - System configuration.
|
|
16
|
+
* @param {object} dependencies - { db, logger, ... }
|
|
17
|
+
* @param {object} unifiedCalculations - The injected calculations package.
|
|
18
|
+
*/
|
|
19
|
+
const createAdminRouter = (config, dependencies, unifiedCalculations) => {
|
|
20
|
+
const router = express.Router();
|
|
21
|
+
const { db, logger } = dependencies;
|
|
22
|
+
|
|
23
|
+
// Helper to get fresh manifest
|
|
24
|
+
const getFullManifest = () => getManifest([], unifiedCalculations, dependencies);
|
|
25
|
+
|
|
26
|
+
// --- 1. TOPOLOGY VISUALIZER ---
|
|
27
|
+
router.get('/topology', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const manifest = getFullManifest();
|
|
30
|
+
const nodes = [];
|
|
31
|
+
const edges = [];
|
|
32
|
+
|
|
33
|
+
manifest.forEach(calc => {
|
|
34
|
+
nodes.push({
|
|
35
|
+
id: calc.name,
|
|
36
|
+
data: {
|
|
37
|
+
label: calc.name,
|
|
38
|
+
layer: calc.category,
|
|
39
|
+
pass: calc.pass,
|
|
40
|
+
isHistorical: calc.isHistorical,
|
|
41
|
+
type: calc.type
|
|
42
|
+
},
|
|
43
|
+
position: { x: 0, y: 0 }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (calc.dependencies) {
|
|
47
|
+
calc.dependencies.forEach(dep => {
|
|
48
|
+
edges.push({
|
|
49
|
+
id: `e-${dep}-${calc.name}`,
|
|
50
|
+
source: normalizeName(dep),
|
|
51
|
+
target: calc.name,
|
|
52
|
+
type: 'smoothstep'
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (calc.rootDataDependencies) {
|
|
58
|
+
calc.rootDataDependencies.forEach(root => {
|
|
59
|
+
const rootId = `ROOT_${root.toUpperCase()}`;
|
|
60
|
+
if (!nodes.find(n => n.id === rootId)) {
|
|
61
|
+
nodes.push({
|
|
62
|
+
id: rootId,
|
|
63
|
+
type: 'input',
|
|
64
|
+
data: { label: `${root.toUpperCase()} DB` },
|
|
65
|
+
position: { x: 0, y: 0 },
|
|
66
|
+
style: { background: '#f0f0f0', border: '1px solid #777' }
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
edges.push({
|
|
70
|
+
id: `e-root-${root}-${calc.name}`,
|
|
71
|
+
source: rootId,
|
|
72
|
+
target: calc.name,
|
|
73
|
+
animated: true,
|
|
74
|
+
style: { stroke: '#ff0072' }
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
res.json({ summary: { totalNodes: nodes.length, totalEdges: edges.length }, nodes, edges });
|
|
81
|
+
} catch (e) {
|
|
82
|
+
logger.log('ERROR', '[AdminAPI] Topology build failed', e);
|
|
83
|
+
res.status(500).json({ error: e.message });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// --- 2. STATUS MATRIX (Calendar / State UI) ---
|
|
88
|
+
router.get('/matrix', async (req, res) => {
|
|
89
|
+
const { start, end } = req.query;
|
|
90
|
+
if (!start || !end) return res.status(400).json({ error: "Start and End dates required." });
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const startDate = new Date(String(start));
|
|
94
|
+
const endDate = new Date(String(end));
|
|
95
|
+
const dates = [];
|
|
96
|
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
97
|
+
dates.push(d.toISOString().slice(0, 10));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const manifest = getFullManifest();
|
|
101
|
+
const allCalcNames = new Set(manifest.map(c => c.name));
|
|
102
|
+
|
|
103
|
+
const limit = pLimit(20);
|
|
104
|
+
const matrix = {};
|
|
105
|
+
|
|
106
|
+
await Promise.all(dates.map(date => limit(async () => {
|
|
107
|
+
// Fetch Status and Root Data Availability
|
|
108
|
+
const [statusSnap, rootSnap] = await Promise.all([
|
|
109
|
+
db.collection('computation_status').doc(date).get(),
|
|
110
|
+
db.collection('system_root_data_index').doc(date).get()
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const statusData = statusSnap.exists ? statusSnap.data() : {};
|
|
114
|
+
const rootData = rootSnap.exists ? rootSnap.data() : { status: { hasPortfolio: false } };
|
|
115
|
+
|
|
116
|
+
const dateStatus = {};
|
|
117
|
+
|
|
118
|
+
// Check every calculation in the Manifest
|
|
119
|
+
allCalcNames.forEach(calcName => {
|
|
120
|
+
const entry = statusData[calcName];
|
|
121
|
+
|
|
122
|
+
if (!entry) {
|
|
123
|
+
// If root data exists but calc is missing -> PENDING
|
|
124
|
+
// If no root data -> WAITING_DATA
|
|
125
|
+
dateStatus[calcName] = rootData.status?.hasPortfolio ? 'PENDING' : 'WAITING_DATA';
|
|
126
|
+
} else if (typeof entry === 'object') {
|
|
127
|
+
if (entry.hash && typeof entry.hash === 'string' && entry.hash.startsWith('IMPOSSIBLE')) {
|
|
128
|
+
dateStatus[calcName] = 'IMPOSSIBLE';
|
|
129
|
+
} else if (entry.hash === false) {
|
|
130
|
+
dateStatus[calcName] = 'BLOCKED';
|
|
131
|
+
} else {
|
|
132
|
+
dateStatus[calcName] = 'COMPLETED';
|
|
133
|
+
}
|
|
134
|
+
} else if (entry === 'IMPOSSIBLE') {
|
|
135
|
+
dateStatus[calcName] = 'IMPOSSIBLE';
|
|
136
|
+
} else {
|
|
137
|
+
dateStatus[calcName] = 'COMPLETED';
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
matrix[date] = {
|
|
142
|
+
dataAvailable: rootData.status || {},
|
|
143
|
+
calculations: dateStatus
|
|
144
|
+
};
|
|
145
|
+
})));
|
|
146
|
+
|
|
147
|
+
res.json(matrix);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
logger.log('ERROR', '[AdminAPI] Matrix fetch failed', e);
|
|
150
|
+
res.status(500).json({ error: e.message });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// --- 3. PIPELINE STATE (Progress Bar) ---
|
|
155
|
+
router.get('/pipeline/state', async (req, res) => {
|
|
156
|
+
const { date } = req.query;
|
|
157
|
+
if (!date) return res.status(400).json({ error: "Date required" });
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const passes = ['1', '2', '3', '4', '5'];
|
|
161
|
+
const state = await Promise.all(passes.map(async (pass) => {
|
|
162
|
+
// We use the Audit Ledger which is the source of truth for execution state
|
|
163
|
+
const tasksSnap = await db.collection(`computation_audit_ledger/${date}/passes/${pass}/tasks`).get();
|
|
164
|
+
|
|
165
|
+
const stats = {
|
|
166
|
+
pending: 0,
|
|
167
|
+
inProgress: 0,
|
|
168
|
+
completed: 0,
|
|
169
|
+
failed: 0,
|
|
170
|
+
totalMemoryMB: 0,
|
|
171
|
+
avgDurationMs: 0
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const durations = [];
|
|
175
|
+
|
|
176
|
+
tasksSnap.forEach(doc => {
|
|
177
|
+
const data = doc.data();
|
|
178
|
+
const s = (data.status || 'UNKNOWN').toLowerCase();
|
|
179
|
+
if (stats[s] !== undefined) stats[s]++;
|
|
180
|
+
else stats[s] = 1;
|
|
181
|
+
|
|
182
|
+
if (data.telemetry?.lastMemory?.rssMB) {
|
|
183
|
+
stats.totalMemoryMB += data.telemetry.lastMemory.rssMB;
|
|
184
|
+
}
|
|
185
|
+
if (data.completedAt && data.startedAt) {
|
|
186
|
+
durations.push(new Date(data.completedAt).getTime() - new Date(data.startedAt).getTime());
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
stats.avgDurationMs = durations.length ?
|
|
191
|
+
Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
|
|
192
|
+
|
|
193
|
+
return { pass, stats, totalTasks: tasksSnap.size };
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
res.json({ date, passes: state });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
res.status(500).json({ error: e.message });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// --- 4. DEPENDENCY TRACER (Blast Radius) ---
|
|
203
|
+
router.get('/trace/:calcName', async (req, res) => {
|
|
204
|
+
const { calcName } = req.params;
|
|
205
|
+
const mode = req.query.mode || 'downstream'; // 'upstream' or 'downstream'
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const manifest = getFullManifest();
|
|
209
|
+
const manifestMap = new Map(manifest.map(c => [c.name, c]));
|
|
210
|
+
|
|
211
|
+
if (!manifestMap.has(calcName)) return res.status(404).json({ error: 'Calculation not found' });
|
|
212
|
+
|
|
213
|
+
const trace = { root: calcName, chain: [] };
|
|
214
|
+
|
|
215
|
+
if (mode === 'upstream') {
|
|
216
|
+
// What does X depend on?
|
|
217
|
+
const visited = new Set();
|
|
218
|
+
const walk = (name, depth = 0) => {
|
|
219
|
+
if (visited.has(name) || depth > 10) return;
|
|
220
|
+
visited.add(name);
|
|
221
|
+
const calc = manifestMap.get(name);
|
|
222
|
+
if (!calc) return;
|
|
223
|
+
|
|
224
|
+
trace.chain.push({
|
|
225
|
+
name, depth, pass: calc.pass, type: calc.type
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
calc.dependencies?.forEach(dep => walk(dep, depth + 1));
|
|
229
|
+
};
|
|
230
|
+
walk(calcName);
|
|
231
|
+
} else {
|
|
232
|
+
// What depends on X? (Downstream / Impact)
|
|
233
|
+
const reverseGraph = new Map();
|
|
234
|
+
manifest.forEach(c => {
|
|
235
|
+
c.dependencies?.forEach(dep => {
|
|
236
|
+
const normDep = normalizeName(dep);
|
|
237
|
+
if (!reverseGraph.has(normDep)) reverseGraph.set(normDep, []);
|
|
238
|
+
reverseGraph.get(normDep).push(c.name);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const visited = new Set();
|
|
243
|
+
const walk = (name, depth = 0) => {
|
|
244
|
+
if (visited.has(name) || depth > 10) return;
|
|
245
|
+
visited.add(name);
|
|
246
|
+
|
|
247
|
+
const calc = manifestMap.get(name);
|
|
248
|
+
trace.chain.push({
|
|
249
|
+
name, depth, pass: calc?.pass
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
reverseGraph.get(name)?.forEach(child => walk(child, depth + 1));
|
|
253
|
+
};
|
|
254
|
+
walk(calcName);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
res.json(trace);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
res.status(500).json({ error: e.message });
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// --- 5. CONTRACT VIOLATIONS (Quality Gate) ---
|
|
264
|
+
router.get('/violations', async (req, res) => {
|
|
265
|
+
const days = parseInt(String(req.query.days)) || 7;
|
|
266
|
+
const cutoff = new Date();
|
|
267
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
// Check DLQ for Semantic Failures (Hard Violations)
|
|
271
|
+
const dlqSnap = await db.collection('computation_dead_letter_queue')
|
|
272
|
+
.where('finalAttemptAt', '>', cutoff)
|
|
273
|
+
.where('error.stage', '==', 'SEMANTIC_GATE')
|
|
274
|
+
.limit(50)
|
|
275
|
+
.get();
|
|
276
|
+
|
|
277
|
+
const violations = [];
|
|
278
|
+
dlqSnap.forEach(doc => {
|
|
279
|
+
const data = doc.data();
|
|
280
|
+
violations.push({
|
|
281
|
+
id: doc.id,
|
|
282
|
+
computation: data.originalData.computation,
|
|
283
|
+
date: data.originalData.date,
|
|
284
|
+
reason: data.error.message,
|
|
285
|
+
type: 'HARD_VIOLATION',
|
|
286
|
+
timestamp: data.finalAttemptAt
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Check Audit Logs for Soft Anomalies (Statistical warnings)
|
|
291
|
+
const anomalySnap = await db.collectionGroup('history')
|
|
292
|
+
.where('triggerTime', '>', cutoff.toISOString())
|
|
293
|
+
.where('anomalies', '!=', []) // Firestore != operator
|
|
294
|
+
.limit(50)
|
|
295
|
+
.get();
|
|
296
|
+
|
|
297
|
+
anomalySnap.forEach(doc => {
|
|
298
|
+
const data = doc.data();
|
|
299
|
+
data.anomalies?.forEach(anomaly => {
|
|
300
|
+
violations.push({
|
|
301
|
+
id: doc.id,
|
|
302
|
+
computation: data.computationName,
|
|
303
|
+
date: data.targetDate,
|
|
304
|
+
reason: anomaly,
|
|
305
|
+
type: 'SOFT_ANOMALY',
|
|
306
|
+
timestamp: data.triggerTime
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Sort by time desc
|
|
312
|
+
violations.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
313
|
+
|
|
314
|
+
res.json({ count: violations.length, violations });
|
|
315
|
+
} catch (e) {
|
|
316
|
+
res.status(500).json({ error: e.message });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// --- 6. MEMORY HOTSPOTS (Forensics) ---
|
|
321
|
+
router.get('/memory/hotspots', async (req, res) => {
|
|
322
|
+
const thresholdMB = parseInt(String(req.query.threshold)) || 1000; // 1GB default
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
// Ledger tasks maintain 'telemetry.lastMemory'
|
|
326
|
+
// We use collectionGroup to search across all dates/passes
|
|
327
|
+
const snapshot = await db.collectionGroup('tasks')
|
|
328
|
+
.where('telemetry.lastMemory.rssMB', '>', thresholdMB)
|
|
329
|
+
.orderBy('telemetry.lastMemory.rssMB', 'desc')
|
|
330
|
+
.limit(20)
|
|
331
|
+
.get();
|
|
332
|
+
|
|
333
|
+
const hotspots = [];
|
|
334
|
+
snapshot.forEach(doc => {
|
|
335
|
+
const data = doc.data();
|
|
336
|
+
hotspots.push({
|
|
337
|
+
computation: data.computation,
|
|
338
|
+
rssMB: data.telemetry.lastMemory.rssMB,
|
|
339
|
+
heapMB: data.telemetry.lastMemory.heapUsedMB,
|
|
340
|
+
status: data.status,
|
|
341
|
+
worker: data.workerId,
|
|
342
|
+
date: doc.ref.parent.parent.parent.parent.id // traversing path to get date
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
res.json({ count: hotspots.length, hotspots });
|
|
347
|
+
} catch (e) {
|
|
348
|
+
res.status(500).json({ error: e.message });
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// --- 7. FLIGHT RECORDER (Inspection) ---
|
|
353
|
+
router.get('/inspect/:date/:calcName', async (req, res) => {
|
|
354
|
+
const { date, calcName } = req.params;
|
|
355
|
+
try {
|
|
356
|
+
const passes = ['1', '2', '3', '4', '5'];
|
|
357
|
+
let executionRecord = null;
|
|
358
|
+
|
|
359
|
+
await Promise.all(passes.map(async (pass) => {
|
|
360
|
+
if (executionRecord) return;
|
|
361
|
+
const ref = db.doc(`computation_audit_ledger/${date}/passes/${pass}/tasks/${calcName}`);
|
|
362
|
+
const snap = await ref.get();
|
|
363
|
+
if (snap.exists) executionRecord = { pass, ...snap.data() };
|
|
364
|
+
}));
|
|
365
|
+
|
|
366
|
+
if (!executionRecord) return res.status(404).json({ status: 'NOT_FOUND' });
|
|
367
|
+
|
|
368
|
+
const contractSnap = await db.collection('system_contracts').doc(calcName).get();
|
|
369
|
+
|
|
370
|
+
res.json({
|
|
371
|
+
execution: executionRecord,
|
|
372
|
+
contract: contractSnap.exists ? contractSnap.data() : null
|
|
373
|
+
});
|
|
374
|
+
} catch (e) {
|
|
375
|
+
res.status(500).json({ error: e.message });
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// --- 8. COST & RESOURCE ANALYSIS ---
|
|
380
|
+
router.get('/analytics/costs', async (req, res) => {
|
|
381
|
+
const { date, days } = req.query;
|
|
382
|
+
// Default to today if no date, or range if days provided
|
|
383
|
+
const targetDate = date || new Date().toISOString().slice(0, 10);
|
|
384
|
+
|
|
385
|
+
// Simple Cost Model (Estimates)
|
|
386
|
+
const COSTS = {
|
|
387
|
+
write: 0.18 / 100000,
|
|
388
|
+
read: 0.06 / 100000,
|
|
389
|
+
delete: 0.02 / 100000,
|
|
390
|
+
compute_std_sec: 0.000023, // 1vCPU 2GB (approx)
|
|
391
|
+
compute_high_sec: 0.000092 // 2vCPU 8GB (approx)
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const auditRef = db.collection('computation_audit_logs');
|
|
396
|
+
// We scan the 'history' subcollectionGroup for the given date(s)
|
|
397
|
+
// Note: This can be expensive. In prod, you'd want aggregate counters.
|
|
398
|
+
const query = db.collectionGroup('history').where('targetDate', '==', targetDate);
|
|
399
|
+
const snap = await query.get();
|
|
400
|
+
|
|
401
|
+
let totalCost = 0;
|
|
402
|
+
const byPass = {};
|
|
403
|
+
const byCalc = {};
|
|
404
|
+
|
|
405
|
+
snap.forEach(doc => {
|
|
406
|
+
const data = doc.data();
|
|
407
|
+
const ops = data.firestoreOps || { reads: 0, writes: 0, deletes: 0 };
|
|
408
|
+
const durationSec = (data.durationMs || 0) / 1000;
|
|
409
|
+
const tier = data.resourceTier || 'standard';
|
|
410
|
+
|
|
411
|
+
const ioCost = (ops.writes * COSTS.write) + (ops.reads * COSTS.read) + (ops.deletes * COSTS.delete);
|
|
412
|
+
const computeCost = durationSec * (tier === 'high-mem' ? COSTS.compute_high_sec : COSTS.compute_std_sec);
|
|
413
|
+
const itemCost = ioCost + computeCost;
|
|
414
|
+
|
|
415
|
+
totalCost += itemCost;
|
|
416
|
+
|
|
417
|
+
// Aggregations
|
|
418
|
+
const pass = data.pass || 'unknown';
|
|
419
|
+
if (!byPass[pass]) byPass[pass] = { cost: 0, runs: 0, duration: 0 };
|
|
420
|
+
byPass[pass].cost += itemCost;
|
|
421
|
+
byPass[pass].runs++;
|
|
422
|
+
byPass[pass].duration += durationSec;
|
|
423
|
+
|
|
424
|
+
const calc = data.computationName;
|
|
425
|
+
if (!byCalc[calc]) byCalc[calc] = { cost: 0, runs: 0, ops: { r:0, w:0 } };
|
|
426
|
+
byCalc[calc].cost += itemCost;
|
|
427
|
+
byCalc[calc].runs++;
|
|
428
|
+
byCalc[calc].ops.r += ops.reads;
|
|
429
|
+
byCalc[calc].ops.w += ops.writes;
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// Top 10 Expensive Calcs
|
|
433
|
+
const topCalcs = Object.entries(byCalc)
|
|
434
|
+
.sort((a, b) => b[1].cost - a[1].cost)
|
|
435
|
+
.slice(0, 10)
|
|
436
|
+
.map(([name, stats]) => ({ name, ...stats }));
|
|
437
|
+
|
|
438
|
+
res.json({
|
|
439
|
+
date: targetDate,
|
|
440
|
+
totalCostUSD: totalCost,
|
|
441
|
+
breakdown: {
|
|
442
|
+
byPass,
|
|
443
|
+
topCalculations: topCalcs
|
|
444
|
+
},
|
|
445
|
+
meta: { model: COSTS }
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// --- 9. REROUTE (OOM) ANALYSIS ---
|
|
452
|
+
router.get('/analytics/reroutes', async (req, res) => {
|
|
453
|
+
const { date } = req.query;
|
|
454
|
+
if (!date) return res.status(400).json({ error: "Date required" });
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// Find all runs that used high-mem
|
|
458
|
+
const query = db.collectionGroup('history')
|
|
459
|
+
.where('targetDate', '==', date)
|
|
460
|
+
.where('resourceTier', '==', 'high-mem');
|
|
461
|
+
|
|
462
|
+
const snap = await query.get();
|
|
463
|
+
const reroutes = [];
|
|
464
|
+
|
|
465
|
+
snap.forEach(doc => {
|
|
466
|
+
const data = doc.data();
|
|
467
|
+
reroutes.push({
|
|
468
|
+
computation: data.computationName,
|
|
469
|
+
pass: data.pass,
|
|
470
|
+
trigger: data.trigger?.reason,
|
|
471
|
+
peakMemoryMB: data.peakMemoryMB,
|
|
472
|
+
durationMs: data.durationMs,
|
|
473
|
+
runId: data.runId
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
res.json({ count: reroutes.length, reroutes });
|
|
478
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// --- 10. LIVE DASHBOARD (Snapshot) ---
|
|
482
|
+
// Poll this endpoint to simulate a WebSocket feed
|
|
483
|
+
router.get('/live/dashboard', async (req, res) => {
|
|
484
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
485
|
+
try {
|
|
486
|
+
// Query the Ledger for Active Tasks
|
|
487
|
+
// We look at all passes for today
|
|
488
|
+
const passes = ['1', '2', '3', '4', '5'];
|
|
489
|
+
const activeTasks = [];
|
|
490
|
+
const recentFailures = [];
|
|
491
|
+
|
|
492
|
+
await Promise.all(passes.map(async (pass) => {
|
|
493
|
+
const colRef = db.collection(`computation_audit_ledger/${today}/passes/${pass}/tasks`);
|
|
494
|
+
|
|
495
|
+
// Get Running
|
|
496
|
+
const runningSnap = await colRef.where('status', 'in', ['PENDING', 'IN_PROGRESS']).get();
|
|
497
|
+
runningSnap.forEach(doc => {
|
|
498
|
+
activeTasks.push({ pass, ...doc.data() });
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Get Recent Failures (last 10 mins?? hard to query without index, just grab failures)
|
|
502
|
+
const failSnap = await colRef.where('status', '==', 'FAILED').get();
|
|
503
|
+
failSnap.forEach(doc => {
|
|
504
|
+
recentFailures.push({ pass, ...doc.data() });
|
|
505
|
+
});
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
// Get Pipeline Stage (which pass is active?)
|
|
509
|
+
// We infer this by seeing which pass has pending tasks
|
|
510
|
+
let currentStage = 'IDLE';
|
|
511
|
+
for (const p of passes) {
|
|
512
|
+
const hasActive = activeTasks.some(t => t.pass === p);
|
|
513
|
+
if (hasActive) { currentStage = `PASS_${p}`; break; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
res.json({
|
|
517
|
+
status: 'success',
|
|
518
|
+
timestamp: new Date(),
|
|
519
|
+
pipelineState: currentStage,
|
|
520
|
+
activeCount: activeTasks.length,
|
|
521
|
+
failureCount: recentFailures.length,
|
|
522
|
+
tasks: activeTasks,
|
|
523
|
+
failures: recentFailures
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// =========================================================================
|
|
530
|
+
// NEW DEBUGGING ROUTES (Added 2025-12-19)
|
|
531
|
+
// =========================================================================
|
|
532
|
+
|
|
533
|
+
// --- 11. STALE COMPUTATION DETECTOR ("Zombie Hunter") ---
|
|
534
|
+
router.get('/debug/stale', async (req, res) => {
|
|
535
|
+
const thresholdMins = parseInt(String(req.query.threshold)) || 15;
|
|
536
|
+
const cutoffTime = Date.now() - (thresholdMins * 60 * 1000);
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
// Scanning collectionGroup 'tasks' is expensive, so we focus on 'IN_PROGRESS'
|
|
540
|
+
// and filter in memory if necessary, or rely on composite index if available.
|
|
541
|
+
const snapshot = await db.collectionGroup('tasks')
|
|
542
|
+
.where('status', '==', 'IN_PROGRESS')
|
|
543
|
+
.get();
|
|
544
|
+
|
|
545
|
+
const zombies = [];
|
|
546
|
+
snapshot.forEach(doc => {
|
|
547
|
+
const data = doc.data();
|
|
548
|
+
|
|
549
|
+
// Determine last activity (Heartbeat > StartedAt > 0)
|
|
550
|
+
const lastActivity = data.telemetry?.lastHeartbeat
|
|
551
|
+
? new Date(data.telemetry.lastHeartbeat).getTime()
|
|
552
|
+
: (data.startedAt ? new Date(data.startedAt).getTime() : 0);
|
|
553
|
+
|
|
554
|
+
if (lastActivity < cutoffTime) {
|
|
555
|
+
zombies.push({
|
|
556
|
+
computation: data.computation || doc.id,
|
|
557
|
+
workerId: data.workerId || 'unknown',
|
|
558
|
+
startedAt: data.startedAt,
|
|
559
|
+
lastHeartbeat: data.telemetry?.lastHeartbeat,
|
|
560
|
+
stagnantMinutes: Math.floor((Date.now() - lastActivity) / 60000),
|
|
561
|
+
path: doc.ref.path // Useful for manual deletion
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Sort by most stagnant
|
|
567
|
+
zombies.sort((a, b) => b.stagnantMinutes - a.stagnantMinutes);
|
|
568
|
+
|
|
569
|
+
res.json({
|
|
570
|
+
count: zombies.length,
|
|
571
|
+
thresholdMins,
|
|
572
|
+
zombies
|
|
573
|
+
});
|
|
574
|
+
} catch (e) {
|
|
575
|
+
res.status(500).json({ error: e.message });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// --- 12. ROOT DATA HEALTH CHECK ---
|
|
580
|
+
// Debugs the "Available but marked missing" issue
|
|
581
|
+
router.get('/debug/root-data-health', async (req, res) => {
|
|
582
|
+
const { start, end } = req.query;
|
|
583
|
+
if (!start || !end) return res.status(400).json({ error: "Start and End dates required." });
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const startDate = new Date(String(start));
|
|
587
|
+
const endDate = new Date(String(end));
|
|
588
|
+
const dates = [];
|
|
589
|
+
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
590
|
+
dates.push(d.toISOString().slice(0, 10));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const report = {};
|
|
594
|
+
const limit = pLimit(10);
|
|
595
|
+
|
|
596
|
+
await Promise.all(dates.map(date => limit(async () => {
|
|
597
|
+
const rootDoc = await db.collection('system_root_data_index').doc(date).get();
|
|
598
|
+
if (!rootDoc.exists) {
|
|
599
|
+
report[date] = { exists: false, status: 'MISSING_INDEX' };
|
|
600
|
+
} else {
|
|
601
|
+
const data = rootDoc.data();
|
|
602
|
+
report[date] = {
|
|
603
|
+
exists: true,
|
|
604
|
+
status: 'OK',
|
|
605
|
+
flags: data.status, // hasHistory, hasPortfolio, etc.
|
|
606
|
+
lastUpdated: data.lastUpdated
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
})));
|
|
610
|
+
|
|
611
|
+
res.json(report);
|
|
612
|
+
} catch (e) {
|
|
613
|
+
res.status(500).json({ error: e.message });
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// --- 13. SIMHASH INSPECTOR ---
|
|
618
|
+
// Debugs "Why did this run? Code Changed?"
|
|
619
|
+
router.get('/debug/simhash/:hash', async (req, res) => {
|
|
620
|
+
const { hash } = req.params;
|
|
621
|
+
try {
|
|
622
|
+
const doc = await db.collection('system_simhash_registry').doc(hash).get();
|
|
623
|
+
if (!doc.exists) return res.status(404).json({ error: "Hash not found in registry." });
|
|
624
|
+
|
|
625
|
+
res.json(doc.data());
|
|
626
|
+
} catch (e) {
|
|
627
|
+
res.status(500).json({ error: e.message });
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// --- 10. DEVELOPER USER MANAGEMENT (Force Sync & Lookup) ---
|
|
632
|
+
// Only accessible to developer accounts
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* POST /admin/users/:cid/sync
|
|
636
|
+
* Force sync a signed-in user (developer only)
|
|
637
|
+
* Allows developers to trigger sync for any user to fix stuck accounts
|
|
638
|
+
*/
|
|
639
|
+
router.post('/users/:cid/sync', async (req, res) => {
|
|
640
|
+
const { cid } = req.params;
|
|
641
|
+
const { userCid: developerCid } = req.query; // Developer's CID
|
|
642
|
+
|
|
643
|
+
if (!developerCid) {
|
|
644
|
+
return res.status(400).json({
|
|
645
|
+
success: false,
|
|
646
|
+
error: "Missing userCid",
|
|
647
|
+
message: "Please provide userCid query parameter (your developer CID)"
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// SECURITY: Only allow developer accounts
|
|
652
|
+
const { isDeveloperAccount } = require('../user-api/helpers/dev/dev_helpers');
|
|
653
|
+
if (!isDeveloperAccount(Number(developerCid))) {
|
|
654
|
+
logger.log('WARN', `[AdminAPI] Unauthorized sync attempt by non-developer ${developerCid}`);
|
|
655
|
+
return res.status(403).json({
|
|
656
|
+
success: false,
|
|
657
|
+
error: "Forbidden",
|
|
658
|
+
message: "This endpoint is only available for developer accounts"
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const targetCidNum = Number(cid);
|
|
663
|
+
if (isNaN(targetCidNum) || targetCidNum <= 0) {
|
|
664
|
+
return res.status(400).json({
|
|
665
|
+
success: false,
|
|
666
|
+
error: "Invalid CID",
|
|
667
|
+
message: "Please provide a valid user CID"
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
// Import sync helper
|
|
673
|
+
const { requestUserSync } = require('../user-api/helpers/sync/user_sync_helpers');
|
|
674
|
+
|
|
675
|
+
// Create a mock request object with the target CID
|
|
676
|
+
const mockReq = {
|
|
677
|
+
params: { userCid: String(targetCidNum) },
|
|
678
|
+
query: { userCid: String(developerCid), sourcePage: 'admin' },
|
|
679
|
+
headers: {},
|
|
680
|
+
body: {}
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// Call the sync function
|
|
684
|
+
await requestUserSync(mockReq, res, dependencies, config);
|
|
685
|
+
|
|
686
|
+
logger.log('INFO', `[AdminAPI] Developer ${developerCid} force-synced user ${targetCidNum}`);
|
|
687
|
+
|
|
688
|
+
} catch (error) {
|
|
689
|
+
logger.log('ERROR', `[AdminAPI] Error force-syncing user ${targetCidNum}`, error);
|
|
690
|
+
return res.status(500).json({
|
|
691
|
+
success: false,
|
|
692
|
+
error: "Internal server error",
|
|
693
|
+
message: error.message
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* GET /admin/users/:cid/lookup
|
|
700
|
+
* Look up user information by CID (developer only)
|
|
701
|
+
* Helps developers find users to sync
|
|
702
|
+
*/
|
|
703
|
+
router.get('/users/:cid/lookup', async (req, res) => {
|
|
704
|
+
const { cid } = req.params;
|
|
705
|
+
const { userCid: developerCid } = req.query; // Developer's CID
|
|
706
|
+
|
|
707
|
+
if (!developerCid) {
|
|
708
|
+
return res.status(400).json({
|
|
709
|
+
success: false,
|
|
710
|
+
error: "Missing userCid",
|
|
711
|
+
message: "Please provide userCid query parameter (your developer CID)"
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// SECURITY: Only allow developer accounts
|
|
716
|
+
const { isDeveloperAccount } = require('../user-api/helpers/dev/dev_helpers');
|
|
717
|
+
if (!isDeveloperAccount(Number(developerCid))) {
|
|
718
|
+
logger.log('WARN', `[AdminAPI] Unauthorized lookup attempt by non-developer ${developerCid}`);
|
|
719
|
+
return res.status(403).json({
|
|
720
|
+
success: false,
|
|
721
|
+
error: "Forbidden",
|
|
722
|
+
message: "This endpoint is only available for developer accounts"
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const targetCidNum = Number(cid);
|
|
727
|
+
if (isNaN(targetCidNum) || targetCidNum <= 0) {
|
|
728
|
+
return res.status(400).json({
|
|
729
|
+
success: false,
|
|
730
|
+
error: "Invalid CID",
|
|
731
|
+
message: "Please provide a valid user CID"
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
try {
|
|
736
|
+
const signedInUsersCollection = config.signedInUsersCollection || 'signed_in_users';
|
|
737
|
+
const { checkIfUserIsPI } = require('../user-api/helpers/core/user_status_helpers');
|
|
738
|
+
|
|
739
|
+
// Check if user is a PI
|
|
740
|
+
const rankEntry = await checkIfUserIsPI(db, targetCidNum, config, logger);
|
|
741
|
+
const isPI = !!rankEntry;
|
|
742
|
+
|
|
743
|
+
// Get signed-in user data
|
|
744
|
+
const userDoc = await db.collection(signedInUsersCollection).doc(String(targetCidNum)).get();
|
|
745
|
+
const userData = userDoc.exists ? userDoc.data() : null;
|
|
746
|
+
|
|
747
|
+
// Get verification data
|
|
748
|
+
const verificationsCollection = config.verificationsCollection || 'verifications';
|
|
749
|
+
let verificationData = null;
|
|
750
|
+
if (userData && userData.username) {
|
|
751
|
+
const verificationDoc = await db.collection(verificationsCollection)
|
|
752
|
+
.doc(userData.username.toLowerCase())
|
|
753
|
+
.get();
|
|
754
|
+
if (verificationDoc.exists) {
|
|
755
|
+
verificationData = verificationDoc.data();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Get latest sync request status
|
|
760
|
+
const syncRequestsRef = db.collection('user_sync_requests')
|
|
761
|
+
.doc(String(targetCidNum))
|
|
762
|
+
.collection('requests')
|
|
763
|
+
.orderBy('createdAt', 'desc')
|
|
764
|
+
.limit(1);
|
|
765
|
+
|
|
766
|
+
const latestSyncSnapshot = await syncRequestsRef.get();
|
|
767
|
+
let latestSync = null;
|
|
768
|
+
if (!latestSyncSnapshot.empty) {
|
|
769
|
+
latestSync = {
|
|
770
|
+
requestId: latestSyncSnapshot.docs[0].id,
|
|
771
|
+
...latestSyncSnapshot.docs[0].data()
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Get data status directly (don't use the endpoint wrapper)
|
|
776
|
+
let dataStatus = null;
|
|
777
|
+
try {
|
|
778
|
+
const { signedInUsersCollection, signedInHistoryCollection } = config;
|
|
779
|
+
const { findLatestPortfolioDate } = require('../user-api/helpers/core/data_lookup_helpers');
|
|
780
|
+
const CANARY_BLOCK_ID = '19M';
|
|
781
|
+
const today = new Date().toISOString().split('T')[0];
|
|
782
|
+
|
|
783
|
+
// Check portfolio
|
|
784
|
+
const portfolioDate = await findLatestPortfolioDate(db, signedInUsersCollection, targetCidNum, 30);
|
|
785
|
+
const portfolioExists = !!portfolioDate;
|
|
786
|
+
const isPortfolioFallback = portfolioDate && portfolioDate !== today;
|
|
787
|
+
|
|
788
|
+
// Check history
|
|
789
|
+
let historyExists = false;
|
|
790
|
+
let historyDate = null;
|
|
791
|
+
let isHistoryFallback = false;
|
|
792
|
+
|
|
793
|
+
const historyCollection = signedInHistoryCollection || 'signed_in_user_history';
|
|
794
|
+
const todayHistoryRef = db.collection(historyCollection)
|
|
795
|
+
.doc(CANARY_BLOCK_ID)
|
|
796
|
+
.collection('snapshots')
|
|
797
|
+
.doc(today)
|
|
798
|
+
.collection('parts');
|
|
799
|
+
|
|
800
|
+
const todayHistorySnapshot = await todayHistoryRef.get();
|
|
801
|
+
|
|
802
|
+
if (!todayHistorySnapshot.empty) {
|
|
803
|
+
historyExists = true;
|
|
804
|
+
historyDate = today;
|
|
805
|
+
} else {
|
|
806
|
+
// Check fallback dates
|
|
807
|
+
for (let i = 1; i <= 30; i++) {
|
|
808
|
+
const checkDate = new Date(today);
|
|
809
|
+
checkDate.setDate(checkDate.getDate() - i);
|
|
810
|
+
const dateStr = checkDate.toISOString().split('T')[0];
|
|
811
|
+
|
|
812
|
+
const historyRef = db.collection(historyCollection)
|
|
813
|
+
.doc(CANARY_BLOCK_ID)
|
|
814
|
+
.collection('snapshots')
|
|
815
|
+
.doc(dateStr)
|
|
816
|
+
.collection('parts');
|
|
817
|
+
|
|
818
|
+
const snapshot = await historyRef.get();
|
|
819
|
+
if (!snapshot.empty) {
|
|
820
|
+
// Check if this user's data exists in any part
|
|
821
|
+
for (const partDoc of snapshot.docs) {
|
|
822
|
+
const partData = partDoc.data();
|
|
823
|
+
if (partData.users && partData.users[String(targetCidNum)]) {
|
|
824
|
+
historyExists = true;
|
|
825
|
+
historyDate = dateStr;
|
|
826
|
+
isHistoryFallback = true;
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (historyExists) break;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
dataStatus = {
|
|
836
|
+
portfolioAvailable: portfolioExists,
|
|
837
|
+
historyAvailable: historyExists,
|
|
838
|
+
date: portfolioDate || today,
|
|
839
|
+
portfolioDate: portfolioDate,
|
|
840
|
+
historyDate: historyDate,
|
|
841
|
+
isPortfolioFallback: isPortfolioFallback,
|
|
842
|
+
isHistoryFallback: isHistoryFallback,
|
|
843
|
+
requestedDate: today,
|
|
844
|
+
userCid: String(targetCidNum)
|
|
845
|
+
};
|
|
846
|
+
} catch (e) {
|
|
847
|
+
logger.log('WARN', `[AdminAPI] Error getting data status: ${e.message}`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const result = {
|
|
851
|
+
success: true,
|
|
852
|
+
cid: targetCidNum,
|
|
853
|
+
isPI: isPI,
|
|
854
|
+
userData: userData ? {
|
|
855
|
+
username: userData.username,
|
|
856
|
+
fullName: userData.fullName,
|
|
857
|
+
avatar: userData.avatar,
|
|
858
|
+
verifiedAt: userData.verifiedAt,
|
|
859
|
+
lastLogin: userData.lastLogin,
|
|
860
|
+
isOptOut: userData.isOptOut
|
|
861
|
+
} : null,
|
|
862
|
+
piData: rankEntry ? {
|
|
863
|
+
username: rankEntry.UserName || rankEntry.username,
|
|
864
|
+
aum: rankEntry.AUMValue,
|
|
865
|
+
copiers: rankEntry.Copiers,
|
|
866
|
+
riskScore: rankEntry.RiskScore,
|
|
867
|
+
gain: rankEntry.Gain
|
|
868
|
+
} : null,
|
|
869
|
+
verification: verificationData ? {
|
|
870
|
+
status: verificationData.status,
|
|
871
|
+
verifiedAt: verificationData.verifiedAt,
|
|
872
|
+
isOptOut: verificationData.isOptOut
|
|
873
|
+
} : null,
|
|
874
|
+
latestSync: latestSync,
|
|
875
|
+
dataStatus: dataStatus
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
logger.log('INFO', `[AdminAPI] Developer ${developerCid} looked up user ${targetCidNum}`);
|
|
879
|
+
|
|
880
|
+
return res.status(200).json(result);
|
|
881
|
+
|
|
882
|
+
} catch (error) {
|
|
883
|
+
logger.log('ERROR', `[AdminAPI] Error looking up user ${targetCidNum}`, error);
|
|
884
|
+
return res.status(500).json({
|
|
885
|
+
success: false,
|
|
886
|
+
error: "Internal server error",
|
|
887
|
+
message: error.message
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
return router;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
module.exports = createAdminRouter;
|