feat: add return classification for archive chart, CARD API integration, compliance charts, systemd services
This commit is contained in:
@@ -13,8 +13,8 @@
|
||||
// POST /notes — add a note to one or more (hostname, metric_id) pairs
|
||||
// GET /notes/:hostname/:metricId — notes for a specific device+metric
|
||||
// GET /trends — per-upload totals + per-team counts for time-series charts
|
||||
// GET /mttr — mean time to resolution per team
|
||||
// GET /top-recurring — chronic compliance gaps sorted by seen_count
|
||||
// GET /mttr — aging findings distribution by seen_count bucket and team
|
||||
// GET /top-recurring — net change waterfall (per-cycle start/new/recurring/resolved/end)
|
||||
// GET /category-trend — active counts per category per upload for stacked area chart
|
||||
|
||||
const express = require('express');
|
||||
@@ -240,6 +240,60 @@ function groupByHostname(rows, noteHostnames) {
|
||||
return Object.values(deviceMap);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: bucket active items by age group and pivot per-team counts
|
||||
// ---------------------------------------------------------------------------
|
||||
const BUCKET_ORDER = ['1 cycle', '2–3 cycles', '4–6 cycles', '7+ cycles'];
|
||||
|
||||
function bucketAgingItems(items) {
|
||||
// Initialise empty buckets with all teams at zero
|
||||
const teams = ['STEAM', 'ACCESS-ENG', 'ACCESS-OPS', 'INTELDEV'];
|
||||
const buckets = {};
|
||||
for (const b of BUCKET_ORDER) {
|
||||
buckets[b] = { bucket: b, total: 0 };
|
||||
for (const t of teams) buckets[b][t] = 0;
|
||||
}
|
||||
|
||||
// Classify each item into a bucket
|
||||
for (const item of items) {
|
||||
const sc = item.seen_count;
|
||||
let label;
|
||||
if (sc === 1) label = '1 cycle';
|
||||
else if (sc >= 2 && sc <= 3) label = '2–3 cycles';
|
||||
else if (sc >= 4 && sc <= 6) label = '4–6 cycles';
|
||||
else label = '7+ cycles';
|
||||
|
||||
const team = item.team;
|
||||
buckets[label].total += 1;
|
||||
if (team in buckets[label]) {
|
||||
buckets[label][team] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Return in ascending age order
|
||||
return BUCKET_ORDER.map(b => buckets[b]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure function: compute waterfall chain from ordered upload records
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeWaterfall(uploads) {
|
||||
let start = 0;
|
||||
return uploads.map((row) => {
|
||||
const end = start + row.new_count + row.recurring_count - row.resolved_count;
|
||||
const entry = {
|
||||
date: row.report_date,
|
||||
start,
|
||||
new_count: row.new_count,
|
||||
recurring_count: row.recurring_count,
|
||||
resolved_count: row.resolved_count,
|
||||
end,
|
||||
};
|
||||
start = end;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1012,27 +1066,23 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /mttr
|
||||
// Mean time to resolution (calendar days) per team, for resolved items.
|
||||
// Aging Findings Distribution — active findings bucketed by seen_count
|
||||
// with per-team breakdown for stacked bar chart.
|
||||
//
|
||||
// Response: { mttr: [{ team, avg_days, resolved_count }] }
|
||||
// Response: { aging: [{ bucket, total, STEAM, ACCESS-ENG, ACCESS-OPS, INTELDEV }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/mttr', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT
|
||||
ci.team,
|
||||
ROUND(AVG(JULIANDAY(ru.report_date) - JULIANDAY(fu.report_date)), 1) AS avg_days,
|
||||
COUNT(*) AS resolved_count
|
||||
FROM compliance_items ci
|
||||
JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id
|
||||
JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id
|
||||
WHERE ci.resolved_upload_id IS NOT NULL
|
||||
AND fu.report_date IS NOT NULL
|
||||
AND ru.report_date IS NOT NULL
|
||||
GROUP BY ci.team
|
||||
ORDER BY avg_days DESC`
|
||||
`SELECT COALESCE(seen_count, 1) AS seen_count, team
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'`
|
||||
);
|
||||
res.json({ mttr: rows });
|
||||
if (rows.length === 0) {
|
||||
return res.json({ aging: [] });
|
||||
}
|
||||
const aging = bucketAgingItems(rows);
|
||||
res.json({ aging });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /mttr error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
@@ -1041,23 +1091,24 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET /top-recurring
|
||||
// Active findings grouped by team + metric_id, sorted by seen_count desc.
|
||||
// Identifies chronic compliance gaps that keep reappearing.
|
||||
// Net Change Waterfall — per-cycle net movement (start → +new →
|
||||
// +recurring → −resolved → end) computed from compliance_uploads.
|
||||
//
|
||||
// Response: { items: [{ team, metric_id, metric_desc, seen_count,
|
||||
// host_count }] } — limited to top 20
|
||||
// Response: { waterfall: [{ date, start, new_count, recurring_count,
|
||||
// resolved_count, end }] }
|
||||
// -----------------------------------------------------------------------
|
||||
router.get('/top-recurring', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(db,
|
||||
`SELECT team, metric_id, metric_desc, seen_count, COUNT(*) AS host_count
|
||||
FROM compliance_items
|
||||
WHERE status = 'active'
|
||||
GROUP BY team, metric_id
|
||||
ORDER BY seen_count DESC, host_count DESC
|
||||
LIMIT 20`
|
||||
`SELECT id, report_date,
|
||||
COALESCE(new_count, 0) AS new_count,
|
||||
COALESCE(recurring_count, 0) AS recurring_count,
|
||||
COALESCE(resolved_count, 0) AS resolved_count
|
||||
FROM compliance_uploads
|
||||
ORDER BY report_date ASC`
|
||||
);
|
||||
res.json({ items: rows });
|
||||
const waterfall = computeWaterfall(rows);
|
||||
res.json({ waterfall });
|
||||
} catch (err) {
|
||||
console.error('[Compliance] GET /top-recurring error:', err.message);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
@@ -1089,4 +1140,4 @@ function createComplianceRouter(db, upload, requireAuth, requireGroup) {
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = createComplianceRouter;
|
||||
module.exports = { createComplianceRouter, bucketAgingItems, computeWaterfall };
|
||||
|
||||
Reference in New Issue
Block a user