Compare commits
8 Commits
feature/re
...
7314dc16cb
| Author | SHA1 | Date | |
|---|---|---|---|
| 7314dc16cb | |||
| 602c75bf24 | |||
| 706ef19872 | |||
| 8392124df5 | |||
| fbe4333e9b | |||
| 07894709ba | |||
| 071aef96a1 | |||
| a9404ff82a |
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const { requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||||
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -146,14 +147,34 @@ function initTables(db) {
|
|||||||
)
|
)
|
||||||
`, (err) => { if (err) return reject(err); });
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
// Idempotent column additions — errors mean the column already exists, which is fine
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_workflow_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||||
|
db.run(`ALTER TABLE ivanti_counts_cache ADD COLUMN fp_id_counts_json TEXT DEFAULT '{}'`, () => {});
|
||||||
|
|
||||||
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)
|
||||||
`, (err) => { if (err) return reject(err); });
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_finding_overrides (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
finding_id TEXT NOT NULL,
|
||||||
|
field TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(finding_id, field)
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
CREATE INDEX IF NOT EXISTS idx_finding_notes_finding_id
|
||||||
ON ivanti_finding_notes(finding_id)
|
ON ivanti_finding_notes(finding_id)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_finding_overrides_finding_id
|
||||||
|
ON ivanti_finding_overrides(finding_id)
|
||||||
`, (err) => {
|
`, (err) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
@@ -261,6 +282,100 @@ async function syncClosedCount(db, openCount, apiKey, clientId, skipTls) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extract FP workflow id+state from a raw (un-extracted) finding
|
||||||
|
// Returns { id, state } or null if no FP# workflow present.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractFPWorkflow(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 { id: fpEntry.generatedId || '', state: fpEntry.state || 'Unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sync FP stats across ALL findings (open + closed).
|
||||||
|
//
|
||||||
|
// Produces two separate counts:
|
||||||
|
// findingCounts — number of *findings* per FP workflow state
|
||||||
|
// idCounts — number of *unique FP# ticket IDs* per state
|
||||||
|
// (one FP# can cover many findings; this chart counts tickets)
|
||||||
|
//
|
||||||
|
// Open findings come from the already-extracted allFindings array.
|
||||||
|
// Closed findings are swept page-by-page to catch Approved FPs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncFPWorkflowCounts(db, openFindings, apiKey, clientId, skipTls) {
|
||||||
|
const findingCounts = {}; // state → # findings
|
||||||
|
const fpIdMap = {}; // FP# id → state (deduplicates across findings)
|
||||||
|
|
||||||
|
// Seed from open findings (already extracted, have workflow.id + workflow.state)
|
||||||
|
openFindings.forEach(f => {
|
||||||
|
if (!f.workflow) return;
|
||||||
|
const state = f.workflow.state || 'Unknown';
|
||||||
|
const id = f.workflow.id || '';
|
||||||
|
findingCounts[state] = (findingCounts[state] || 0) + 1;
|
||||||
|
if (id && !fpIdMap[id]) fpIdMap[id] = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 wf = extractFPWorkflow(f);
|
||||||
|
if (!wf) return;
|
||||||
|
findingCounts[wf.state] = (findingCounts[wf.state] || 0) + 1;
|
||||||
|
if (wf.id && !fpIdMap[wf.id]) fpIdMap[wf.id] = wf.state;
|
||||||
|
});
|
||||||
|
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 we have from open findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate unique FP# IDs by state
|
||||||
|
const idCounts = {};
|
||||||
|
Object.values(fpIdMap).forEach(state => {
|
||||||
|
idCounts[state] = (idCounts[state] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbRun(db,
|
||||||
|
`UPDATE ivanti_counts_cache SET fp_workflow_counts_json=?, fp_id_counts_json=? WHERE id=1`,
|
||||||
|
[JSON.stringify(findingCounts), JSON.stringify(idCounts)]
|
||||||
|
).catch(e => console.error('[Ivanti Findings] Failed to store FP workflow counts:', e.message));
|
||||||
|
|
||||||
|
console.log('[Ivanti Findings] FP finding counts:', findingCounts);
|
||||||
|
console.log('[Ivanti Findings] FP workflow ID counts:', idCounts);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
// Core sync — fetches ALL pages, stores slimmed findings in SQLite
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -316,6 +431,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);
|
||||||
@@ -395,9 +511,28 @@ function readCounts(db) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns { findingId: { hostName: 'override', dns: 'override' }, ... }
|
||||||
|
function readOverrides(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT finding_id, field, value FROM ivanti_finding_overrides', (err, rows) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
const map = {};
|
||||||
|
(rows || []).forEach((r) => {
|
||||||
|
if (!map[r.finding_id]) map[r.finding_id] = {};
|
||||||
|
map[r.finding_id][r.field] = r.value;
|
||||||
|
});
|
||||||
|
resolve(map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function readStateWithNotes(db) {
|
async function readStateWithNotes(db) {
|
||||||
const [state, notes] = await Promise.all([readState(db), readNotes(db)]);
|
const [state, notes, overrides] = await Promise.all([readState(db), readNotes(db), readOverrides(db)]);
|
||||||
state.findings = state.findings.map((f) => ({ ...f, note: notes[f.id] || '' }));
|
state.findings = state.findings.map((f) => ({
|
||||||
|
...f,
|
||||||
|
note: notes[f.id] || '',
|
||||||
|
overrides: overrides[f.id] || {},
|
||||||
|
}));
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +576,65 @@ function createIvantiFindingsRouter(db, requireAuth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /fp-workflow-counts — FP finding + unique workflow counts (open + closed)
|
||||||
|
router.get('/fp-workflow-counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT fp_workflow_counts_json, fp_id_counts_json FROM ivanti_counts_cache WHERE id=1',
|
||||||
|
(err, row) => { if (err) reject(err); else resolve(row); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let findingCounts = {};
|
||||||
|
let idCounts = {};
|
||||||
|
try { findingCounts = JSON.parse(row?.fp_workflow_counts_json || '{}'); } catch (_) {}
|
||||||
|
try { idCounts = JSON.parse(row?.fp_id_counts_json || '{}'); } catch (_) {}
|
||||||
|
res.json({
|
||||||
|
findingCounts,
|
||||||
|
findingTotal: Object.values(findingCounts).reduce((a, b) => a + b, 0),
|
||||||
|
idCounts,
|
||||||
|
idTotal: Object.values(idCounts).reduce((a, b) => a + b, 0),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading FP workflow counts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:findingId/override — save or clear a field override (editor/admin only)
|
||||||
|
const OVERRIDE_ALLOWED = ['hostName', 'dns'];
|
||||||
|
router.put('/:findingId/override', requireRole('editor', 'admin'), (req, res) => {
|
||||||
|
const { findingId } = req.params;
|
||||||
|
const { field, value } = req.body;
|
||||||
|
|
||||||
|
if (!OVERRIDE_ALLOWED.includes(field)) {
|
||||||
|
return res.status(400).json({ error: `Field '${field}' is not editable. Allowed: ${OVERRIDE_ALLOWED.join(', ')}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const val = String(value ?? '').trim();
|
||||||
|
|
||||||
|
if (val === '') {
|
||||||
|
// Empty value = clear the override (revert to Ivanti)
|
||||||
|
db.run(
|
||||||
|
'DELETE FROM ivanti_finding_overrides WHERE finding_id = ? AND field = ?',
|
||||||
|
[findingId, field],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to clear override' });
|
||||||
|
res.json({ finding_id: findingId, field, value: null });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ivanti_finding_overrides (finding_id, field, value, updated_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id, field) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`,
|
||||||
|
[findingId, field, val],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Failed to save override' });
|
||||||
|
res.json({ finding_id: findingId, field, value: val });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
// PUT /:findingId/note — save or update a note (max 255 chars enforced here)
|
||||||
router.put('/:findingId/note', (req, res) => {
|
router.put('/:findingId/note', (req, res) => {
|
||||||
const { findingId } = req.params;
|
const { findingId } = req.params;
|
||||||
|
|||||||
182
backend/scripts/import_notes_from_csv.py
Normal file
182
backend/scripts/import_notes_from_csv.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
import_notes_from_csv.py
|
||||||
|
------------------------
|
||||||
|
Mass-import finding notes from a CSV file into the CVE dashboard database.
|
||||||
|
|
||||||
|
CSV format (header row required, column names are case-insensitive):
|
||||||
|
ID,NOTES
|
||||||
|
12345,EXC-5754
|
||||||
|
67890,EXC-6001 - pending review
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 import_notes_from_csv.py <csv_file> [--db <db_path>] [--dry-run]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--db <path> Path to cve_database.db (default: ../cve_database.db)
|
||||||
|
--dry-run Print what would change without touching the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
NOTE_MAX_LEN = 255
|
||||||
|
|
||||||
|
DEFAULT_DB = os.path.join(os.path.dirname(__file__), '..', 'cve_database.db')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description='Import finding notes from CSV into the dashboard DB.')
|
||||||
|
p.add_argument('csv_file', help='Path to the CSV file (must have ID and NOTES columns)')
|
||||||
|
p.add_argument('--db', default=DEFAULT_DB, help=f'Path to SQLite database (default: {DEFAULT_DB})')
|
||||||
|
p.add_argument('--dry-run', action='store_true', help='Preview changes without writing to DB')
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def load_csv(path):
|
||||||
|
"""Read CSV and return list of (finding_id, note) tuples."""
|
||||||
|
rows = []
|
||||||
|
with open(path, newline='', encoding='utf-8-sig') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
# Normalise header names to uppercase for case-insensitive matching
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
print('ERROR: CSV file is empty or has no header row.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
normalised = {k.strip().upper(): k for k in reader.fieldnames}
|
||||||
|
if 'ID' not in normalised or 'NOTES' not in normalised:
|
||||||
|
print(f'ERROR: CSV must have "ID" and "NOTES" columns.')
|
||||||
|
print(f' Found columns: {list(reader.fieldnames)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
id_col = normalised['ID']
|
||||||
|
notes_col = normalised['NOTES']
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2): # start=2 because row 1 is the header
|
||||||
|
finding_id = row[id_col].strip()
|
||||||
|
note = row[notes_col].strip()
|
||||||
|
|
||||||
|
if not finding_id:
|
||||||
|
print(f' WARNING row {i}: empty ID — skipping')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(note) > NOTE_MAX_LEN:
|
||||||
|
print(f' WARNING row {i} ({finding_id}): note is {len(note)} chars, '
|
||||||
|
f'truncating to {NOTE_MAX_LEN}')
|
||||||
|
note = note[:NOTE_MAX_LEN]
|
||||||
|
|
||||||
|
rows.append((finding_id, note))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
csv_path = os.path.abspath(args.csv_file)
|
||||||
|
db_path = os.path.abspath(args.db)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ checks
|
||||||
|
if not os.path.exists(csv_path):
|
||||||
|
print(f'ERROR: CSV file not found: {csv_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f'ERROR: Database not found: {db_path}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'CSV : {csv_path}')
|
||||||
|
print(f'DB : {db_path}')
|
||||||
|
if args.dry_run:
|
||||||
|
print('MODE: DRY RUN — no changes will be written\n')
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- load CSV
|
||||||
|
rows = load_csv(csv_path)
|
||||||
|
if not rows:
|
||||||
|
print('No valid rows found in CSV.')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
print(f'Loaded {len(rows)} row(s) from CSV.\n')
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- open DB
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
# Fetch all known finding IDs — only IDs present here will be processed
|
||||||
|
import json
|
||||||
|
cur.execute('SELECT findings_json FROM ivanti_findings_cache WHERE id = 1')
|
||||||
|
cache_row = cur.fetchone()
|
||||||
|
known_ids = set()
|
||||||
|
if cache_row and cache_row['findings_json']:
|
||||||
|
try:
|
||||||
|
known_ids = {str(f['id']) for f in json.loads(cache_row['findings_json'])}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not known_ids:
|
||||||
|
print('ERROR: No findings found in the database cache.')
|
||||||
|
print(' Run a Sync from the dashboard first, then re-run this script.')
|
||||||
|
con.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'{len(known_ids)} active finding(s) in cache.\n')
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- process
|
||||||
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for finding_id, note in rows:
|
||||||
|
str_id = str(finding_id)
|
||||||
|
|
||||||
|
if str_id not in known_ids:
|
||||||
|
print(f' SKIP {str_id} — not in active findings (resolved or never synced)')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if a note already exists
|
||||||
|
cur.execute('SELECT note FROM ivanti_finding_notes WHERE finding_id = ?', (str_id,))
|
||||||
|
existing = cur.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing['note'] == note:
|
||||||
|
print(f' SKIP {str_id} — note unchanged')
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
action = 'UPDATE'
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
action = 'INSERT'
|
||||||
|
inserted += 1
|
||||||
|
|
||||||
|
print(f' {action:6s} {str_id} → {note[:80]}{"…" if len(note) > 80 else ""}')
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ivanti_finding_notes (finding_id, note, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(finding_id) DO UPDATE
|
||||||
|
SET note = excluded.note, updated_at = datetime('now')
|
||||||
|
""",
|
||||||
|
(str_id, note)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- summary
|
||||||
|
print()
|
||||||
|
if args.dry_run:
|
||||||
|
print(f'DRY RUN complete — would insert {inserted}, update {updated}, skip {skipped}.')
|
||||||
|
else:
|
||||||
|
con.commit()
|
||||||
|
print(f'Done — inserted {inserted}, updated {updated}, skipped {skipped} (unchanged).')
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run(parse_args())
|
||||||
@@ -179,6 +179,7 @@ export default function App() {
|
|||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const [calendarFilter, setCalendarFilter] = useState(null);
|
const [calendarFilter, setCalendarFilter] = useState(null);
|
||||||
|
const [reportingExcFilter, setReportingExcFilter] = useState(null);
|
||||||
const [showAddCVE, setShowAddCVE] = useState(false);
|
const [showAddCVE, setShowAddCVE] = useState(false);
|
||||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||||
const [showAuditLog, setShowAuditLog] = useState(false);
|
const [showAuditLog, setShowAuditLog] = useState(false);
|
||||||
@@ -963,8 +964,8 @@ export default function App() {
|
|||||||
onClose={() => setNavOpen(false)}
|
onClose={() => setNavOpen(false)}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
onNavigate={(page) => {
|
onNavigate={(page) => {
|
||||||
// Clear calendar filter when navigating directly via the nav drawer
|
// Clear contextual filters when navigating directly via the nav drawer
|
||||||
if (page === 'reporting') setCalendarFilter(null);
|
if (page === 'reporting') { setCalendarFilter(null); setReportingExcFilter(null); }
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1041,7 +1042,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} />}
|
{currentPage === 'reporting' && <ReportingPage filterDate={calendarFilter} filterEXC={reportingExcFilter} />}
|
||||||
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
{currentPage === 'knowledge-base' && <KnowledgeBasePage />}
|
||||||
{currentPage === 'exports' && <ExportsPage />}
|
{currentPage === 'exports' && <ExportsPage />}
|
||||||
|
|
||||||
@@ -2332,16 +2333,23 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{ticket.exc_number}
|
{ticket.exc_number}
|
||||||
</a>
|
</a>
|
||||||
{canWrite() && (
|
<div className="flex gap-1">
|
||||||
<div className="flex gap-1">
|
<button
|
||||||
|
onClick={() => { setReportingExcFilter(ticket.exc_number); setCurrentPage('reporting'); }}
|
||||||
|
title="View findings referencing this ticket"
|
||||||
|
className="text-gray-400 hover:text-sky-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Filter className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{canWrite() && (<>
|
||||||
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
<button onClick={() => handleEditArcherTicket(ticket)} className="text-gray-400 hover:text-purple-400 transition-colors">
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
<button onClick={() => handleDeleteArcherTicket(ticket)} className="text-gray-400 hover:text-intel-danger transition-colors">
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
<div className="text-xs text-white font-mono mb-1">{ticket.cve_id}</div>
|
||||||
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
<div className="text-xs text-gray-400">{ticket.vendor}</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download } from 'lucide-react';
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download, RotateCcw } from 'lucide-react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
||||||
const STORAGE_KEY = 'steam_findings_columns_v2';
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
||||||
@@ -117,6 +118,17 @@ function getExportVal(finding, key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action coverage classification — used by chart and filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const EXC_PATTERN = /EXC-\d+/i;
|
||||||
|
|
||||||
|
function classifyFinding(finding) {
|
||||||
|
if (finding.workflow != null) return 'fp';
|
||||||
|
if (EXC_PATTERN.test(finding.note || '')) return 'archer';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Style helpers
|
// Style helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -264,6 +276,195 @@ function StatusDonut({ open, closed, loading }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG Donut Chart — Action Coverage (FP Request | Archer Exception | Pending)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ACTION_DEFS = [
|
||||||
|
{ key: 'fp', label: 'FP Request', color: '#0EA5E9' },
|
||||||
|
{ key: 'archer', label: 'Archer Exception', color: '#F59E0B' },
|
||||||
|
{ key: 'pending', label: 'Pending', color: '#EF4444' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ActionCoverageDonut({ findings, activeSegment, onSegmentClick }) {
|
||||||
|
const SIZE = 180;
|
||||||
|
const CX = SIZE / 2;
|
||||||
|
const CY = SIZE / 2;
|
||||||
|
const OUTER = 72;
|
||||||
|
const INNER = 48;
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const map = { fp: 0, archer: 0, pending: 0 };
|
||||||
|
findings.forEach((f) => { map[classifyFinding(f)]++; });
|
||||||
|
return map;
|
||||||
|
}, [findings]);
|
||||||
|
|
||||||
|
const total = findings.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||||
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No data — click Sync to load</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
const segments = ACTION_DEFS.map((def) => {
|
||||||
|
const count = counts[def.key];
|
||||||
|
const start = cursor;
|
||||||
|
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
|
||||||
|
if (count > 0) cursor = end;
|
||||||
|
return { ...def, count, start, end };
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasActive = !!activeSegment;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||||
|
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||||
|
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||||
|
{segments.filter((s) => s.count > 0).map((seg) => {
|
||||||
|
const isActive = activeSegment === seg.key;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={seg.key}
|
||||||
|
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||||
|
fill={seg.color}
|
||||||
|
opacity={hasActive ? (isActive ? 1 : 0.25) : 0.88}
|
||||||
|
stroke={isActive ? 'rgba(255,255,255,0.6)' : 'none'}
|
||||||
|
strokeWidth={isActive ? 2 : 0}
|
||||||
|
style={{ cursor: 'pointer', transition: 'opacity 0.2s' }}
|
||||||
|
onClick={() => onSegmentClick(isActive ? null : seg.key)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||||
|
TOTAL
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend — always shows all 3 categories */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
|
{segments.map((seg) => {
|
||||||
|
const isActive = activeSegment === seg.key;
|
||||||
|
const dimmed = hasActive && !isActive;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={seg.key}
|
||||||
|
onClick={() => onSegmentClick(isActive ? null : seg.key)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', opacity: dimmed ? 0.35 : 1, transition: 'opacity 0.2s' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0, outline: isActive ? `2px solid ${seg.color}` : 'none', outlineOffset: '1px' }} />
|
||||||
|
<div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{seg.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||||
|
{seg.count}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||||
|
({total > 0 ? ((seg.count / total) * 100).toFixed(0) : 0}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hasActive && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSegmentClick(null)}
|
||||||
|
style={{ marginTop: '0.25rem', background: 'none', border: 'none', fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', cursor: 'pointer', textAlign: 'left', padding: 0, textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SVG Donut Chart — FP Workflow Status distribution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FP_WORKFLOW_DEFS = [
|
||||||
|
{ key: 'Actionable', label: 'Actionable', color: '#F59E0B' },
|
||||||
|
{ key: 'Requested', label: 'Requested', color: '#0EA5E9' },
|
||||||
|
{ key: 'Reworked', label: 'Reworked', color: '#A855F7' },
|
||||||
|
{ key: 'Approved', label: 'Approved', color: '#22C55E' },
|
||||||
|
{ key: 'Rejected', label: 'Rejected', color: '#EF4444' },
|
||||||
|
{ key: 'Expired', label: 'Expired', color: '#64748B' },
|
||||||
|
{ key: 'Unknown', label: 'Unknown', color: '#334155' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function FPWorkflowDonut({ counts, total, centerLabel = 'FP TOTAL' }) {
|
||||||
|
const SIZE = 180;
|
||||||
|
const CX = SIZE / 2;
|
||||||
|
const CY = SIZE / 2;
|
||||||
|
const OUTER = 72;
|
||||||
|
const INNER = 48;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
||||||
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>No FP workflows — click Sync to load</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
const segments = FP_WORKFLOW_DEFS.map((def) => {
|
||||||
|
const count = counts[def.key] || 0;
|
||||||
|
const start = cursor;
|
||||||
|
const end = count > 0 ? cursor + (count / total) * 360 : cursor;
|
||||||
|
if (count > 0) cursor = end;
|
||||||
|
return { ...def, count, start, end };
|
||||||
|
}).filter(s => s.count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||||
|
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
||||||
|
<circle cx={CX} cy={CY} r={OUTER + 1} fill="none" stroke="rgba(10,18,32,0.8)" strokeWidth="2" />
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<path
|
||||||
|
key={seg.key}
|
||||||
|
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
||||||
|
fill={seg.color}
|
||||||
|
opacity={0.88}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<text x={CX} y={CY - 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '22px', fontWeight: '700', fill: '#E2E8F0' }}>
|
||||||
|
{total.toLocaleString()}
|
||||||
|
</text>
|
||||||
|
<text x={CX} y={CY + 10} textAnchor="middle" style={{ fontFamily: 'monospace', fontSize: '8.5px', fontWeight: '600', fill: '#475569', letterSpacing: '0.12em' }}>
|
||||||
|
{centerLabel}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
|
{segments.map((seg) => (
|
||||||
|
<div key={seg.key} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
{seg.label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.8rem', fontWeight: '700', color: '#E2E8F0', marginLeft: '0.4rem' }}>
|
||||||
|
{seg.count}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '0.65rem', color: '#475569', marginLeft: '0.25rem' }}>
|
||||||
|
({((seg.count / total) * 100).toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SortIcon({ colKey, sort }) {
|
function SortIcon({ colKey, sort }) {
|
||||||
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
return sort.dir === 'asc'
|
return sort.dir === 'asc'
|
||||||
@@ -271,6 +472,119 @@ function SortIcon({ colKey, sort }) {
|
|||||||
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OverrideCell — inline editable hostname/dns with amber dot when overridden
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function OverrideCell({ findingId, field, originalValue, initialOverride, canWrite }) {
|
||||||
|
const effective = initialOverride ?? originalValue ?? '';
|
||||||
|
const [value, setValue] = useState(effective);
|
||||||
|
const [isOverridden, setOverridden] = useState(!!initialOverride);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const lastSaved = useRef(effective);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
// Sync when the finding updates (e.g. after a full sync)
|
||||||
|
useEffect(() => {
|
||||||
|
const eff = initialOverride ?? originalValue ?? '';
|
||||||
|
setValue(eff);
|
||||||
|
setOverridden(!!initialOverride);
|
||||||
|
lastSaved.current = eff;
|
||||||
|
}, [initialOverride, originalValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing && inputRef.current) inputRef.current.focus();
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
|
const persist = useCallback(async (newVal) => {
|
||||||
|
const trimmed = newVal.trim();
|
||||||
|
if (trimmed === lastSaved.current) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/ivanti/findings/${findingId}/override`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ field, value: trimmed }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const cleared = data.value === null;
|
||||||
|
const displayed = cleared ? (originalValue ?? '') : trimmed;
|
||||||
|
setValue(displayed);
|
||||||
|
setOverridden(!cleared);
|
||||||
|
lastSaved.current = displayed;
|
||||||
|
} else {
|
||||||
|
setValue(lastSaved.current); // revert on error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setValue(lastSaved.current);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [findingId, field, originalValue]);
|
||||||
|
|
||||||
|
const handleBlur = () => { setEditing(false); persist(value); };
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') { e.target.blur(); }
|
||||||
|
if (e.key === 'Escape') { setValue(lastSaved.current); setEditing(false); }
|
||||||
|
};
|
||||||
|
const handleRevert = (e) => { e.stopPropagation(); setValue(''); persist(''); };
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<td style={{ padding: '0.3rem 0.5rem' }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
width: '100%', minWidth: '120px',
|
||||||
|
background: 'rgba(14,165,233,0.08)',
|
||||||
|
border: '1px solid rgba(14,165,233,0.4)',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
padding: '0.2rem 0.4rem',
|
||||||
|
color: '#E2E8F0', fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
||||||
|
<span
|
||||||
|
onClick={canWrite ? () => setEditing(true) : undefined}
|
||||||
|
title={isOverridden ? `Ivanti value: ${originalValue || '—'}\nClick to edit` : canWrite ? 'Click to edit' : undefined}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
||||||
|
color: isOverridden ? '#E2E8F0' : '#94A3B8',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.72rem',
|
||||||
|
cursor: canWrite ? 'text' : 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOverridden && (
|
||||||
|
<span title="Local override active" style={{ width: '5px', height: '5px', borderRadius: '50%', background: '#F59E0B', flexShrink: 0, marginRight: '1px' }} />
|
||||||
|
)}
|
||||||
|
{value || '—'}
|
||||||
|
{saving && <Loader style={{ width: '10px', height: '10px', color: '#475569', animation: 'spin 1s linear infinite', flexShrink: 0 }} />}
|
||||||
|
{isOverridden && canWrite && !saving && (
|
||||||
|
<button
|
||||||
|
onClick={handleRevert}
|
||||||
|
title="Revert to Ivanti value"
|
||||||
|
style={{ background: 'none', border: 'none', padding: '0 1px', cursor: 'pointer', color: '#475569', lineHeight: 1, flexShrink: 0, display: 'inline-flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<RotateCcw style={{ width: '10px', height: '10px' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// NoteCell — inline editable, saves on blur
|
// NoteCell — inline editable, saves on blur
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -593,7 +907,7 @@ function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChan
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Render a single table cell by column key
|
// Render a single table cell by column key
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function TableCell({ colKey, finding }) {
|
function TableCell({ colKey, finding, canWrite }) {
|
||||||
switch (colKey) {
|
switch (colKey) {
|
||||||
case 'findingId':
|
case 'findingId':
|
||||||
return (
|
return (
|
||||||
@@ -643,19 +957,30 @@ function TableCell({ colKey, finding }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'hostName':
|
case 'hostName':
|
||||||
|
return (
|
||||||
|
<OverrideCell
|
||||||
|
findingId={finding.id}
|
||||||
|
field="hostName"
|
||||||
|
originalValue={finding.hostName}
|
||||||
|
initialOverride={finding.overrides?.hostName ?? null}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'ipAddress':
|
case 'ipAddress':
|
||||||
return (
|
return (
|
||||||
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
||||||
{finding[colKey] || '—'}
|
{finding.ipAddress || '—'}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case 'dns':
|
case 'dns':
|
||||||
return (
|
return (
|
||||||
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
|
<OverrideCell
|
||||||
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
|
findingId={finding.id}
|
||||||
{finding.dns || '—'}
|
field="dns"
|
||||||
</span>
|
originalValue={finding.dns}
|
||||||
</td>
|
initialOverride={finding.overrides?.dns ?? null}
|
||||||
|
canWrite={canWrite}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
case 'dueDate': {
|
case 'dueDate': {
|
||||||
const color = dueDateColor(finding.dueDate);
|
const color = dueDateColor(finding.dueDate);
|
||||||
@@ -740,7 +1065,8 @@ function TableCell({ colKey, finding }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main ReportingPage
|
// Main ReportingPage
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export default function ReportingPage({ filterDate }) {
|
export default function ReportingPage({ filterDate, filterEXC }) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const [findings, setFindings] = useState([]);
|
const [findings, setFindings] = useState([]);
|
||||||
const [total, setTotal] = useState(null);
|
const [total, setTotal] = useState(null);
|
||||||
const [syncedAt, setSyncedAt] = useState(null);
|
const [syncedAt, setSyncedAt] = useState(null);
|
||||||
@@ -750,6 +1076,7 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
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 [fpCounts, setFPCounts] = useState({ findingCounts: {}, findingTotal: 0, idCounts: {}, idTotal: 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(() =>
|
||||||
@@ -757,6 +1084,8 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
);
|
);
|
||||||
const [openFilter, setOpenFilter] = useState(null);
|
const [openFilter, setOpenFilter] = useState(null);
|
||||||
const filterBtnRefs = useRef({});
|
const filterBtnRefs = useRef({});
|
||||||
|
const [actionFilter, setActionFilter] = useState(null);
|
||||||
|
const [excFilter, setExcFilter] = useState(filterEXC || null);
|
||||||
|
|
||||||
const updateColumns = useCallback((newOrder) => {
|
const updateColumns = useCallback((newOrder) => {
|
||||||
setColumnOrder(newOrder);
|
setColumnOrder(newOrder);
|
||||||
@@ -784,6 +1113,21 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) setFPCounts({
|
||||||
|
findingCounts: data.findingCounts || {},
|
||||||
|
findingTotal: data.findingTotal || 0,
|
||||||
|
idCounts: data.idCounts || {},
|
||||||
|
idTotal: data.idTotal || 0,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading FP workflow counts:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchFindings = async () => {
|
const fetchFindings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -804,7 +1148,8 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
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);
|
||||||
@@ -816,6 +1161,7 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
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
|
||||||
@@ -830,22 +1176,38 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply all active column filters to produce the visible row set
|
// Apply all active filters to produce the visible row set
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
|
let result = findings;
|
||||||
|
|
||||||
|
// Column filters
|
||||||
const active = Object.entries(columnFilters);
|
const active = Object.entries(columnFilters);
|
||||||
if (active.length === 0) return findings;
|
if (active.length > 0) {
|
||||||
return findings.filter((f) =>
|
result = result.filter((f) =>
|
||||||
active.every(([key, vals]) => {
|
active.every(([key, vals]) => {
|
||||||
if (!vals || vals.size === 0) return false;
|
if (!vals || vals.size === 0) return false;
|
||||||
const def = COLUMN_DEFS[key];
|
const def = COLUMN_DEFS[key];
|
||||||
if (def?.multiValue) {
|
if (def?.multiValue) {
|
||||||
// Row matches if ANY of its values is in the selected set
|
return (f[key] || []).some((v) => vals.has(String(v).trim()));
|
||||||
return (f[key] || []).some((v) => vals.has(String(v).trim()));
|
}
|
||||||
}
|
return vals.has(getFilterVal(f, key).trim());
|
||||||
return vals.has(getFilterVal(f, key).trim());
|
})
|
||||||
})
|
);
|
||||||
);
|
}
|
||||||
}, [findings, columnFilters]);
|
|
||||||
|
// Action coverage filter (chart segment click)
|
||||||
|
if (actionFilter) {
|
||||||
|
result = result.filter((f) => classifyFinding(f) === actionFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXC filter (navigated from home page Archer ticket)
|
||||||
|
if (excFilter) {
|
||||||
|
const upper = excFilter.toUpperCase();
|
||||||
|
result = result.filter((f) => (f.note || '').toUpperCase().includes(upper));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [findings, columnFilters, actionFilter, excFilter]);
|
||||||
|
|
||||||
// Visible columns in current order
|
// Visible columns in current order
|
||||||
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
||||||
@@ -871,7 +1233,7 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeFilterCount = Object.keys(columnFilters).length;
|
const activeFilterCount = Object.keys(columnFilters).length + (actionFilter ? 1 : 0) + (excFilter ? 1 : 0);
|
||||||
|
|
||||||
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
||||||
const exportBtnRef = useRef(null);
|
const exportBtnRef = useRef(null);
|
||||||
@@ -962,7 +1324,7 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
Metric Graphs
|
Metric Graphs
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||||
{/* Open vs Closed donut */}
|
{/* Open vs Closed donut */}
|
||||||
<div style={{ flex: '0 0 auto' }}>
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
<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' }}>
|
||||||
@@ -974,6 +1336,47 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
loading={countsLoading}
|
loading={countsLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* Action Coverage donut */}
|
||||||
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
|
Action Coverage
|
||||||
|
{actionFilter && <span style={{ marginLeft: '0.5rem', color: ACTION_DEFS.find(d => d.key === actionFilter)?.color, fontSize: '0.6rem' }}>● filtered</span>}
|
||||||
|
</div>
|
||||||
|
<ActionCoverageDonut
|
||||||
|
findings={findings}
|
||||||
|
activeSegment={actionFilter}
|
||||||
|
onSegmentClick={(key) => {
|
||||||
|
setExcFilter(null);
|
||||||
|
setActionFilter(key);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* FP Finding Status donut — # of findings per FP workflow state */}
|
||||||
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
|
FP Finding Status
|
||||||
|
</div>
|
||||||
|
<FPWorkflowDonut counts={fpCounts.findingCounts} total={fpCounts.findingTotal} centerLabel="FINDINGS" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* FP Workflow Status donut — # of unique FP# ticket IDs per state */}
|
||||||
|
<div style={{ flex: '0 0 auto' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||||
|
FP Workflow Status
|
||||||
|
</div>
|
||||||
|
<FPWorkflowDonut counts={fpCounts.idCounts} total={fpCounts.idTotal} centerLabel="FP TICKETS" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1011,7 +1414,46 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
{activeFilterCount > 0 && (
|
{/* EXC filter badge (from home page navigation) */}
|
||||||
|
{excFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExcFilter(null)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: 'rgba(245,158,11,0.08)',
|
||||||
|
border: '1px solid rgba(245,158,11,0.3)',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: '#F59E0B', cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
letterSpacing: '0.05em'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Filter style={{ width: '11px', height: '11px' }} />
|
||||||
|
{excFilter} ×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Action coverage filter badge (from chart click) */}
|
||||||
|
{actionFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActionFilter(null)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: actionFilter === 'fp' ? 'rgba(14,165,233,0.08)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.08)' : 'rgba(239,68,68,0.08)',
|
||||||
|
border: `1px solid ${actionFilter === 'fp' ? 'rgba(14,165,233,0.3)' : actionFilter === 'archer' ? 'rgba(245,158,11,0.3)' : 'rgba(239,68,68,0.3)'}`,
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
color: actionFilter === 'fp' ? '#0EA5E9' : actionFilter === 'archer' ? '#F59E0B' : '#EF4444',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
||||||
|
letterSpacing: '0.05em'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Filter style={{ width: '11px', height: '11px' }} />
|
||||||
|
{ACTION_DEFS.find(d => d.key === actionFilter)?.label} ×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Object.keys(columnFilters).length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setColumnFilters({})}
|
onClick={() => setColumnFilters({})}
|
||||||
style={{
|
style={{
|
||||||
@@ -1188,7 +1630,7 @@ export default function ReportingPage({ filterDate }) {
|
|||||||
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
||||||
>
|
>
|
||||||
{visibleCols.map((col) => (
|
{visibleCols.map((col) => (
|
||||||
<TableCell key={col.key} colKey={col.key} finding={finding} />
|
<TableCell key={col.key} colKey={col.key} finding={finding} canWrite={canWrite()} />
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user