fix: aggregate anomaly data per day instead of taking latest — fixes missing returned bars when multiple syncs per day

This commit is contained in:
root
2026-05-01 19:28:29 +00:00
parent bfa52c7f8f
commit 5a9df2103f
3 changed files with 124 additions and 18 deletions

6
.gitignore vendored
View File

@@ -78,3 +78,9 @@ docs/card-lookup-results.csv
docs/card-prod-archer-firewall-request.md
docs/granite-reassignment-upload.csv
docs/granite-reassignment-upload.xlsx
# Production DB copies
cve_database_prod.db
cve_database.db.prod
cve_database.db.backup
database.db

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Diagnostic: check alignment between counts history dates and anomaly log dates
// Usage: node backend/scripts/diagnose-chart-alignment.js
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const DB_PATH = path.join(__dirname, '..', 'cve_database.db');
function dbAll(db, sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
function fmtDate(d) {
if (!d) return '';
const p = d.split('-');
if (p.length === 3) return `${p[1]}/${p[2]}/${p[0].slice(2)}`;
return d;
}
function extractDate(ts) {
if (!ts) return '';
return ts.split('T')[0].split(' ')[0];
}
async function main() {
const db = new sqlite3.Database(DB_PATH);
// Get counts history dates (same query as the API)
const countsRows = await dbAll(db,
`SELECT date FROM (
SELECT DATE(recorded_at) AS date,
ROW_NUMBER() OVER (PARTITION BY DATE(recorded_at) ORDER BY recorded_at DESC) AS rn
FROM ivanti_counts_history
) WHERE rn = 1 ORDER BY date ASC`
);
const countsDates = new Set(countsRows.map(r => fmtDate(r.date)));
// Get anomaly history (same query as the API)
const anomalyRows = await dbAll(db,
`SELECT sync_timestamp, newly_archived_count, returned_count, return_classification_json
FROM ivanti_sync_anomaly_log
ORDER BY sync_timestamp DESC LIMIT 30`
);
console.log('=== Counts History Dates (last 10) ===');
const lastTen = countsRows.slice(-10);
for (const r of lastTen) {
console.log(` ${r.date}${fmtDate(r.date)}`);
}
console.log('\n=== Anomaly Log Entries with Activity ===');
for (const a of anomalyRows) {
if (a.newly_archived_count === 0 && a.returned_count === 0) continue;
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
const inCounts = countsDates.has(dateKey);
console.log(` ${a.sync_timestamp} → raw="${rawDate}" → key="${dateKey}" | archived=${a.newly_archived_count} returned=${a.returned_count} | in counts: ${inCounts ? 'YES' : '*** NO ***'}`);
}
console.log('\n=== All Anomaly Dates NOT in Counts History ===');
let missingCount = 0;
for (const a of anomalyRows) {
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
if (!countsDates.has(dateKey)) {
console.log(` MISSING: ${a.sync_timestamp} → "${dateKey}" (archived=${a.newly_archived_count}, returned=${a.returned_count})`);
missingCount++;
}
}
if (missingCount === 0) console.log(' (none — all anomaly dates have matching counts history)');
db.close();
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@@ -226,34 +226,44 @@ export default function IvantiCountsChart() {
);
// Build archive activity data aligned to the same date axis as the main chart.
// Aggregate anomaly rows by date (take the last sync per day, matching the
// counts history pattern), then merge onto the chartData date set.
// Aggregate anomaly rows by date — sum archived/returned counts and merge
// classifications across all syncs that day, then align to the chartData dates.
const archiveData = useMemo(() => {
if (!anomalies.length || !chartData.length) return [];
// Group anomalies by date, keep the latest per day
// Aggregate all anomaly rows per date (sum counts, merge classifications)
const byDate = {};
for (const a of anomalies) {
const rawDate = extractDate(a.sync_timestamp);
const dateKey = fmtDate(rawDate);
// anomaly/history returns newest first, so first seen per date is the latest
if (!byDate[dateKey]) {
byDate[dateKey] = a;
byDate[dateKey] = {
archived: 0,
returned: 0,
classification: {},
return_classification: {},
is_significant: false,
};
}
const entry = byDate[dateKey];
entry.archived += (a.newly_archived_count || 0);
entry.returned += (a.returned_count || 0);
if (a.is_significant) entry.is_significant = true;
// Merge classification counts
for (const [key, val] of Object.entries(a.classification || {})) {
entry.classification[key] = (entry.classification[key] || 0) + (val || 0);
}
for (const [key, val] of Object.entries(a.return_classification || {})) {
entry.return_classification[key] = (entry.return_classification[key] || 0) + (val || 0);
}
}
// Map onto the chart date axis so both charts share the same X positions
return chartData.map(point => {
const anomaly = byDate[point.date];
if (anomaly) {
return {
date: point.date,
archived: anomaly.newly_archived_count || 0,
returned: anomaly.returned_count || 0,
classification: anomaly.classification || {},
return_classification: anomaly.return_classification || {},
is_significant: anomaly.is_significant,
};
const agg = byDate[point.date];
if (agg) {
return { date: point.date, ...agg };
}
return { date: point.date, archived: 0, returned: 0, classification: {}, return_classification: {}, is_significant: false };
});
@@ -377,13 +387,13 @@ export default function IvantiCountsChart() {
}}>
Archive Activity
</div>
<ResponsiveContainer width="100%" height={64}>
<ResponsiveContainer width="100%" height={80}>
<BarChart data={archiveData} margin={{ top: 2, right: 12, bottom: 0, left: -12 }}>
<CartesianGrid {...GRID_STYLE} />
<XAxis dataKey="date" tick={false} axisLine={false} />
<YAxis tick={AXIS_STYLE} allowDecimals={false} width={30} />
<Tooltip content={<ArchiveTooltip />} />
<Bar dataKey="archived" name="Archived" stackId="a" maxBarSize={12}>
<Bar dataKey="archived" name="Archived" stackId="a" maxBarSize={14}>
{archiveData.map((entry, idx) => (
<Cell
key={`arch-${idx}`}
@@ -391,7 +401,14 @@ export default function IvantiCountsChart() {
/>
))}
</Bar>
<Bar dataKey="returned" name="Returned" stackId="a" fill={TEAL} maxBarSize={12} />
<Bar dataKey="returned" name="Returned" stackId="a" maxBarSize={14}>
{archiveData.map((entry, idx) => (
<Cell
key={`ret-${idx}`}
fill={TEAL}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>