// 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;