Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
378 lines
11 KiB
JavaScript
378 lines
11 KiB
JavaScript
import express from 'express';
|
|
import { dbRun, dbGet, dbAll } from '../../database/connection.js';
|
|
import { isPostgres } from '../../database/connection.js';
|
|
import { apiLogger } from '../../utils/logger.js';
|
|
import { success, badRequest, serverError } from '../../utils/response.js';
|
|
import { authenticate } from '../../middleware/auth.js';
|
|
import crypto from 'crypto';
|
|
|
|
const router = express.Router();
|
|
|
|
/**
|
|
* POST /api/v3/device-logs
|
|
* Receive BLE debug logs from mobile app.
|
|
* Inserts individual log entries into device_logs table.
|
|
*
|
|
* Body: { device, stats, entries: [{timestamp, level, category, message, data}], uploaded_at }
|
|
*/
|
|
router.post('/', async (req, res) => {
|
|
try {
|
|
const { device, stats, entries, uploaded_at, session_id: reqSessionId } = req.body;
|
|
const userId = req.user?.id;
|
|
|
|
if (!device) {
|
|
return badRequest(res, 'device is required');
|
|
}
|
|
|
|
await _ensureTable();
|
|
|
|
const sessionId = reqSessionId || crypto.randomUUID();
|
|
const appVersion = stats?.app_version || 'unknown';
|
|
const bleState = stats?.connection_state || 'unknown';
|
|
const logEntries = (entries || []).slice(-200); // Keep last 200 entries max
|
|
|
|
let insertedCount = 0;
|
|
|
|
// Insert individual log entries
|
|
for (const entry of logEntries) {
|
|
try {
|
|
const metadata = {
|
|
...(entry.data || {}),
|
|
rssi: stats?.rssi,
|
|
battery: stats?.battery,
|
|
mtu: stats?.mtu,
|
|
device_name: stats?.device_name,
|
|
device_id: stats?.device_id,
|
|
user_id: userId,
|
|
};
|
|
|
|
const now = isPostgres() ? 'NOW()' : "datetime('now')";
|
|
const eventTs = entry.timestamp || new Date().toISOString();
|
|
|
|
await dbRun(
|
|
`INSERT INTO device_logs (device, session_id, app_version, ble_state, level, category, message, event_ts, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${isPostgres() ? '?::jsonb' : '?'}, ${now})`,
|
|
[
|
|
device,
|
|
sessionId,
|
|
appVersion,
|
|
bleState,
|
|
entry.level || 'info',
|
|
entry.category || 'BLE',
|
|
entry.message || '',
|
|
eventTs,
|
|
JSON.stringify(metadata),
|
|
]
|
|
);
|
|
insertedCount++;
|
|
} catch (entryErr) {
|
|
apiLogger.warn({ err: entryErr, entry }, 'Failed to insert single log entry');
|
|
}
|
|
}
|
|
|
|
// Also insert a summary entry with full stats
|
|
if (stats) {
|
|
try {
|
|
const now = isPostgres() ? 'NOW()' : "datetime('now')";
|
|
await dbRun(
|
|
`INSERT INTO device_logs (device, session_id, app_version, ble_state, level, category, message, event_ts, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, 'INF', 'UPLOAD', ?, ${now}, ${isPostgres() ? '?::jsonb' : '?'}, ${now})`,
|
|
[
|
|
device,
|
|
sessionId,
|
|
appVersion,
|
|
bleState,
|
|
`Upload: ${insertedCount} entries from ${appVersion}`,
|
|
JSON.stringify({
|
|
...stats,
|
|
user_id: userId,
|
|
uploaded_at: uploaded_at || new Date().toISOString(),
|
|
entry_count: insertedCount,
|
|
}),
|
|
]
|
|
);
|
|
} catch (summaryErr) {
|
|
apiLogger.warn({ err: summaryErr }, 'Failed to insert upload summary');
|
|
}
|
|
}
|
|
|
|
apiLogger.info({ userId, device, entryCount: insertedCount, sessionId },
|
|
'Device logs received');
|
|
|
|
return success(res, {
|
|
session_id: sessionId,
|
|
device,
|
|
entry_count: insertedCount,
|
|
message: 'Logs stored successfully',
|
|
}, 'Logs received', 201);
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'POST /device-logs error');
|
|
return serverError(res, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/device-logs
|
|
* Retrieve stored device logs.
|
|
* Query params: device, session_id, level, category, limit (default 100), offset (default 0)
|
|
*/
|
|
router.get('/', authenticate, async (req, res) => {
|
|
try {
|
|
const { device, session_id, level, category, limit = 100, offset = 0 } = req.query;
|
|
|
|
await _ensureTable();
|
|
|
|
let query = `SELECT id, device, session_id, app_version, ble_state, level, category, message, event_ts, metadata, created_at
|
|
FROM device_logs WHERE 1=1`;
|
|
const params = [];
|
|
|
|
if (device) {
|
|
query += ` AND device = ?`;
|
|
params.push(device);
|
|
}
|
|
|
|
if (session_id) {
|
|
query += ` AND session_id = ?`;
|
|
params.push(session_id);
|
|
}
|
|
|
|
if (level) {
|
|
query += ` AND level = ?`;
|
|
params.push(level.toUpperCase());
|
|
}
|
|
|
|
if (category) {
|
|
query += ` AND category = ?`;
|
|
params.push(category.toUpperCase());
|
|
}
|
|
|
|
query += ` ORDER BY event_ts DESC, id DESC LIMIT ? OFFSET ?`;
|
|
params.push(parseInt(limit), parseInt(offset));
|
|
|
|
const rows = await dbAll(query, params);
|
|
|
|
// Parse metadata JSON if needed
|
|
const logs = rows.map(row => ({
|
|
id: row.id,
|
|
device: row.device,
|
|
session_id: row.session_id,
|
|
app_version: row.app_version,
|
|
ble_state: row.ble_state,
|
|
level: row.level,
|
|
category: row.category,
|
|
message: row.message,
|
|
event_ts: row.event_ts,
|
|
metadata: _safeJsonParse(row.metadata),
|
|
created_at: row.created_at,
|
|
}));
|
|
|
|
// Count total entries
|
|
let countQuery = `SELECT COUNT(*) as total FROM device_logs WHERE 1=1`;
|
|
const countParams = [];
|
|
if (device) {
|
|
countQuery += ` AND device = ?`;
|
|
countParams.push(device);
|
|
}
|
|
if (session_id) {
|
|
countQuery += ` AND session_id = ?`;
|
|
countParams.push(session_id);
|
|
}
|
|
const countRow = await dbGet(countQuery, countParams);
|
|
|
|
return success(res, { logs, total: countRow?.total || logs.length });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /device-logs error');
|
|
return serverError(res, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/device-logs/latest
|
|
* Get the latest log session for a device.
|
|
*/
|
|
router.get('/latest', authenticate, async (req, res) => {
|
|
try {
|
|
const { device = 'frame' } = req.query;
|
|
|
|
await _ensureTable();
|
|
|
|
// Get latest session
|
|
const latestSession = await dbGet(
|
|
`SELECT DISTINCT session_id, app_version, ble_state, MAX(created_at) as last_upload
|
|
FROM device_logs
|
|
WHERE device = ? AND session_id IS NOT NULL
|
|
GROUP BY session_id, app_version, ble_state
|
|
ORDER BY last_upload DESC LIMIT 1`,
|
|
[device]
|
|
);
|
|
|
|
if (!latestSession) {
|
|
return success(res, { log: null, entries: [], message: 'No logs found' });
|
|
}
|
|
|
|
// Get all entries from latest session
|
|
const entries = await dbAll(
|
|
`SELECT id, level, category, message, event_ts, metadata
|
|
FROM device_logs
|
|
WHERE device = ? AND session_id = ?
|
|
ORDER BY event_ts ASC`,
|
|
[device, latestSession.session_id]
|
|
);
|
|
|
|
const parsedEntries = entries.map(e => ({
|
|
...e,
|
|
metadata: _safeJsonParse(e.metadata),
|
|
}));
|
|
|
|
// Extract stats from the UPLOAD summary entry
|
|
const uploadEntry = parsedEntries.find(e => e.category === 'UPLOAD');
|
|
const stats = uploadEntry?.metadata || {};
|
|
|
|
return success(res, {
|
|
session_id: latestSession.session_id,
|
|
app_version: latestSession.app_version,
|
|
ble_state: latestSession.ble_state,
|
|
last_upload: latestSession.last_upload,
|
|
stats,
|
|
entries: parsedEntries.filter(e => e.category !== 'UPLOAD'),
|
|
entry_count: parsedEntries.length,
|
|
});
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /device-logs/latest error');
|
|
return serverError(res, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/v3/device-logs/sessions
|
|
* List all debug sessions for a device.
|
|
*/
|
|
router.get('/sessions', authenticate, async (req, res) => {
|
|
try {
|
|
const { device = 'frame', limit = 20 } = req.query;
|
|
|
|
await _ensureTable();
|
|
|
|
const sessions = await dbAll(
|
|
`SELECT session_id, app_version, ble_state,
|
|
COUNT(*) as entry_count,
|
|
MIN(event_ts) as first_event,
|
|
MAX(event_ts) as last_event,
|
|
MAX(created_at) as uploaded_at,
|
|
COUNT(CASE WHEN level = 'ERR' THEN 1 END) as error_count,
|
|
COUNT(CASE WHEN level = 'WRN' THEN 1 END) as warning_count
|
|
FROM device_logs
|
|
WHERE device = ? AND session_id IS NOT NULL
|
|
GROUP BY session_id, app_version, ble_state
|
|
ORDER BY uploaded_at DESC
|
|
LIMIT ?`,
|
|
[device, parseInt(limit)]
|
|
);
|
|
|
|
return success(res, { sessions });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'GET /device-logs/sessions error');
|
|
return serverError(res, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/v3/device-logs/:id
|
|
* Delete a specific log entry.
|
|
*/
|
|
router.delete('/:id', authenticate, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
await dbRun(`DELETE FROM device_logs WHERE id = ?`, [id]);
|
|
|
|
return success(res, { deleted: true });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'DELETE /device-logs error');
|
|
return serverError(res, err.message);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/v3/device-logs/session/:sessionId
|
|
* Delete all entries for a session.
|
|
*/
|
|
router.delete('/session/:sessionId', authenticate, async (req, res) => {
|
|
try {
|
|
const { sessionId } = req.params;
|
|
|
|
const result = await dbRun(
|
|
`DELETE FROM device_logs WHERE session_id = ?`,
|
|
[sessionId]
|
|
);
|
|
|
|
return success(res, { deleted: true, count: result?.changes || 0 });
|
|
} catch (err) {
|
|
apiLogger.error({ err }, 'DELETE /device-logs/session error');
|
|
return serverError(res, err.message);
|
|
}
|
|
});
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────
|
|
|
|
let _tableCreated = false;
|
|
|
|
async function _ensureTable() {
|
|
if (_tableCreated) return;
|
|
try {
|
|
if (isPostgres()) {
|
|
await dbRun(`
|
|
CREATE TABLE IF NOT EXISTS device_logs (
|
|
id SERIAL PRIMARY KEY,
|
|
device VARCHAR(64) NOT NULL DEFAULT 'frame',
|
|
session_id VARCHAR(128),
|
|
app_version VARCHAR(32),
|
|
ble_state VARCHAR(32),
|
|
level VARCHAR(8),
|
|
category VARCHAR(32),
|
|
message TEXT,
|
|
event_ts TIMESTAMPTZ,
|
|
metadata JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)
|
|
`);
|
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_device_logs_device ON device_logs (device)`);
|
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_device_logs_session ON device_logs (session_id)`);
|
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_device_logs_created_at ON device_logs (created_at DESC)`);
|
|
} else {
|
|
await dbRun(`
|
|
CREATE TABLE IF NOT EXISTS device_logs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
device TEXT NOT NULL DEFAULT 'frame',
|
|
session_id TEXT,
|
|
app_version TEXT,
|
|
ble_state TEXT,
|
|
level TEXT,
|
|
category TEXT,
|
|
message TEXT,
|
|
event_ts TEXT,
|
|
metadata TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
`);
|
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_device_logs_device ON device_logs (device)`);
|
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_device_logs_session ON device_logs (session_id)`);
|
|
}
|
|
_tableCreated = true;
|
|
} catch (e) {
|
|
// Table might already exist, that's fine
|
|
_tableCreated = true;
|
|
}
|
|
}
|
|
|
|
function _safeJsonParse(str) {
|
|
if (!str) return {};
|
|
if (typeof str === 'object') return str;
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch {
|
|
return { raw: str };
|
|
}
|
|
}
|
|
|
|
export default router;
|