105 lines
3.8 KiB
JavaScript
105 lines
3.8 KiB
JavaScript
|
|
// 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';
|
||
|
|
import { requireAuth } from '../middleware/auth.js';
|
||
|
|
|
||
|
|
const router = express.Router();
|
||
|
|
router.use(requireAuth);
|
||
|
|
|
||
|
|
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 = 'done'`),
|
||
|
|
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','running')`),
|
||
|
|
pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'done'`),
|
||
|
|
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;
|