fix(reporting): source FP workflow status chart from DB instead of open-findings cache
The FP Workflow Status donut was reading from the in-memory open findings array, so Approved FPs (which close the finding and remove it from the open cache) were invisible. Backend: during each sync, compute FP workflow state counts from open findings then sweep all pages of closed findings to capture Approved (and any other closed-state) FP workflows. Counts are stored in a new fp_workflow_counts_json column on ivanti_counts_cache and exposed via GET /api/ivanti/findings/fp-workflow-counts. Frontend: FPWorkflowDonut now receives counts/total props from the new endpoint (fetched on load and refreshed after manual sync) instead of deriving them from the findings prop. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,11 @@ function initTables(db) {
|
|||||||
)
|
)
|
||||||
`, (err) => { if (err) return reject(err); });
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
// Add fp_workflow_counts_json column if it doesn't exist yet (idempotent migration)
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {
|
||||||
|
// Ignore error — column already exists on subsequent startups
|
||||||
|
});
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
INSERT OR IGNORE INTO ivanti_counts_cache (id, open_count, closed_count)
|
||||||
VALUES (1, 0, 0)
|
VALUES (1, 0, 0)
|
||||||
@@ -278,6 +283,82 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract FP workflow state from a raw (un-extracted) finding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFPWorkflowState(f) {
|
||||||
|
const wfDist = f.workflowDistribution || {};
|
||||||
|
const fpBuckets = [
|
||||||
|
...(wfDist.actionableWorkflows || []),
|
||||||
|
...(wfDist.requestedWorkflows || []),
|
||||||
|
...(wfDist.reworkedWorkflows || []),
|
||||||
|
...(wfDist.rejectedWorkflows || []),
|
||||||
|
...(wfDist.expiredWorkflows || []),
|
||||||
|
...(wfDist.approvedWorkflows || []),
|
||||||
|
].filter(w => (w.generatedId || '').startsWith('FP#'));
|
||||||
|
const fpEntry = fpBuckets[0] || null;
|
||||||
|
if (!fpEntry) return null;
|
||||||
|
return fpEntry.state || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sync FP workflow state counts across ALL findings (open + closed)
|
||||||
|
// Open findings counts are derived from the already-extracted allFindings array.
|
||||||
|
// Closed findings are swept page-by-page to capture Approved FPs (which close
|
||||||
|
// the finding and thus disappear from the open-only cache).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
||||||
|
// Seed counts from open findings (already have workflow.state)
|
||||||
|
const counts = {};
|
||||||
|
openFindings.forEach(f => {
|
||||||
|
if (!f.workflow) return;
|
||||||
|
const state = f.workflow.state || 'Unknown';
|
||||||
|
counts[state] = (counts[state] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sweep closed findings to pick up Approved (and any other closed FP states)
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/hostFinding/search`;
|
||||||
|
let page = 0;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const body = {
|
||||||
|
filters: CLOSED_COUNT_FILTERS,
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'severity', direction: 'ASC' }],
|
||||||
|
page,
|
||||||
|
size: 100
|
||||||
|
};
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
console.warn(`[Ivanti Findings] FP workflow counts: closed findings page ${page} returned ${result.status} — stopping sweep`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
totalPages = data.page?.totalPages || 1;
|
||||||
|
const findings = data._embedded?.hostFindings || [];
|
||||||
|
findings.forEach(f => {
|
||||||
|
const state = extractFPWorkflowState(f);
|
||||||
|
if (!state) return;
|
||||||
|
counts[state] = (counts[state] || 0) + 1;
|
||||||
|
});
|
||||||
|
console.log(`[Ivanti Findings] FP workflow counts: closed page ${page + 1}/${totalPages}`);
|
||||||
|
page++;
|
||||||
|
} while (page < totalPages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Ivanti Findings] FP workflow counts: closed sweep failed:', err.message);
|
||||||
|
// Fall through — store whatever counts we have from open findings
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=? WHERE id=1`,
|
||||||
|
[JSON.stringify(counts)]
|
||||||
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] FP workflow counts updated:', counts);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -333,6 +414,7 @@ async function syncFindings(db) {
|
|||||||
|
|
||||||
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
console.log(`[Ivanti Findings] Sync complete — ${allFindings.length} findings`);
|
||||||
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
await syncClosedCount(db, allFindings.length, apiKey, clientId, skipTls);
|
||||||
|
await syncFPWorkflowCounts(db, allFindings, apiKey, clientId, skipTls);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || 'Unknown error';
|
const msg = err.message || 'Unknown error';
|
||||||
console.error('[Ivanti Findings] Sync failed:', msg);
|
console.error('[Ivanti Findings] Sync failed:', msg);
|
||||||
@@ -477,6 +559,23 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /fp-workflow-counts — FP workflow state distribution (open + closed findings)
|
||||||
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT fp_workflow_counts_json FROM ivanti_counts_cache WHERE id=1',
|
||||||
|
(err, row) => { if (err) reject(err); else resolve(row); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let counts = {};
|
||||||
|
try { counts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
||||||
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||||
|
res.json({ counts, total });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||||
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||||
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
|||||||
@@ -398,28 +398,13 @@ const FP_WORKFLOW_DEFS = [
|
|||||||
{ key: 'Unknown', label: 'Unknown', color: '#334155' },
|
{ key: 'Unknown', label: 'Unknown', color: '#334155' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function FPWorkflowDonut({ findings }) {
|
function FPWorkflowDonut({ counts, total }) {
|
||||||
const SIZE = 180;
|
const SIZE = 180;
|
||||||
const CX = SIZE / 2;
|
const CX = SIZE / 2;
|
||||||
const CY = SIZE / 2;
|
const CY = SIZE / 2;
|
||||||
const OUTER = 72;
|
const OUTER = 72;
|
||||||
const INNER = 48;
|
const INNER = 48;
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const map = {};
|
|
||||||
FP_WORKFLOW_DEFS.forEach(d => { map[d.key] = 0; });
|
|
||||||
findings.forEach((f) => {
|
|
||||||
if (!f.workflow) return;
|
|
||||||
const state = f.workflow.state || '';
|
|
||||||
const def = FP_WORKFLOW_DEFS.find(d => d.key.toLowerCase() === state.toLowerCase());
|
|
||||||
const key = def ? def.key : 'Unknown';
|
|
||||||
map[key] = (map[key] || 0) + 1;
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [findings]);
|
|
||||||
|
|
||||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
||||||
|
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||||
@@ -1091,6 +1076,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
|
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
|
||||||
const [countsLoading, setCountsLoading] = useState(true);
|
const [countsLoading, setCountsLoading] = useState(true);
|
||||||
|
const [fpWorkflowCounts, setFPWorkflowCounts] = useState({ counts: {}, total: 0 });
|
||||||
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
||||||
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
||||||
const [columnFilters, setColumnFilters] = useState(() =>
|
const [columnFilters, setColumnFilters] = useState(() =>
|
||||||
@@ -1127,6 +1113,16 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchFPWorkflowCounts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings/fp-workflow-counts`, { credentials: 'include' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) setFPWorkflowCounts({ counts: data.counts || {}, total: data.total || 0 });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading FP workflow counts:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchFindings = async () => {
|
const fetchFindings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -1147,7 +1143,8 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
applyState(data);
|
applyState(data);
|
||||||
fetchCounts(); // refresh counts after sync
|
fetchCounts(); // refresh counts after sync
|
||||||
|
fetchFPWorkflowCounts(); // refresh FP workflow counts after sync
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error syncing findings:', e);
|
console.error('Error syncing findings:', e);
|
||||||
@@ -1159,6 +1156,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFindings();
|
fetchFindings();
|
||||||
fetchCounts();
|
fetchCounts();
|
||||||
|
fetchFPWorkflowCounts();
|
||||||
}, []); // eslint-disable-line
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
// Set/clear a single column filter
|
// Set/clear a single column filter
|
||||||
@@ -1361,7 +1359,7 @@ export default function ReportingPage({ filterDate, filterEXC }) {
|
|||||||
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
FP Workflow Status
|
FP Workflow Status
|
||||||
</div>
|
</div>
|
||||||
<FPWorkflowDonut findings={findings} />
|
<FPWorkflowDonut counts={fpWorkflowCounts.counts} total={fpWorkflowCounts.total} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user