dragonflight/services/mam-api/src/routes/metrics.js

103 lines
3.8 KiB
JavaScript
Raw Normal View History

// Real metrics for the Home page sparklines.
//
// Buckets the last N hours into N points, counting rows in each window.
// Returns a flat shape that's easy for the React Sparkline to consume.
import express from 'express';
import pool from '../db/pool.js';
const router = express.Router();
const DEFAULT_HOURS = 24;
const DEFAULT_POINTS = 13;
function bucketCountSQL(table, statusFilter) {
// Use date_trunc + generate_series so we always return `points` buckets
// (even hours with no rows show up as 0). All times are UTC.
return `
WITH series AS (
SELECT generate_series(
date_trunc('hour', NOW() - ($1 || ' hours')::interval),
date_trunc('hour', NOW()),
('1 hour')::interval
) AS bucket
)
SELECT s.bucket,
COALESCE(COUNT(t.created_at), 0)::int AS count
FROM series s
LEFT JOIN ${table} t
ON date_trunc('hour', t.created_at) = s.bucket
${statusFilter ? ` AND ${statusFilter}` : ''}
GROUP BY s.bucket
ORDER BY s.bucket ASC
`;
}
async function bucketSeries(table, hours, statusFilter = null) {
const result = await pool.query(bucketCountSQL(table, statusFilter), [hours]);
return result.rows.map(r => ({ t: r.bucket, v: r.count }));
}
router.get('/home', async (req, res, next) => {
try {
const hours = Math.min(parseInt(req.query.hours || DEFAULT_HOURS, 10), 168); // cap at 1 week
const [assets, jobsDone, jobsFailed, recordersTotal, recordersLive, jobsRunning, jobsDoneTotal, jobsFailedTotal] = await Promise.all([
bucketSeries('assets', hours),
bucketSeries('jobs', hours, `t.status = 'complete'`),
bucketSeries('jobs', hours, `t.status = 'failed'`),
pool.query(`SELECT COUNT(*)::int AS n FROM recorders`),
pool.query(`SELECT COUNT(*)::int AS n FROM recorders WHERE status = 'recording'`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status IN ('queued','processing')`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'complete'`),
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'failed'`),
]);
// Cluster snapshot — heartbeat freshness drives online/offline
const cluster = await pool.query(
`SELECT id, hostname, role,
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
FROM cluster_nodes`
);
const nodes = cluster.rows.map(n => ({
id: n.id, hostname: n.hostname, role: n.role,
online: n.stale_seconds != null && n.stale_seconds < 120,
}));
res.json({
hours,
generated_at: new Date().toISOString(),
cards: {
assets: {
total: (await pool.query(`SELECT COUNT(*)::int AS n FROM assets`)).rows[0].n,
series: assets,
},
recorders: {
total: recordersTotal.rows[0].n,
live: recordersLive.rows[0].n,
// No historical "active" metric yet — synthesize as the live count
// replayed across the window so the card has *something* to graph.
series: assets.map(p => ({ t: p.t, v: recordersLive.rows[0].n })),
},
jobs: {
running: jobsRunning.rows[0].n,
done_total: jobsDoneTotal.rows[0].n,
failed_total: jobsFailedTotal.rows[0].n,
series_done: jobsDone,
series_failed: jobsFailed,
},
cluster: {
total: nodes.length,
online: nodes.filter(n => n.online).length,
nodes,
// Heartbeat liveness is binary — emit a 1/0 across the window keyed
// to current state so the sparkline shows a sensible bar shape.
series: assets.map(p => ({ t: p.t, v: nodes.filter(n => n.online).length })),
},
},
});
} catch (err) { next(err); }
});
export default router;