2026-03-11 13:03:17 -06:00
|
|
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
|
|
|
import ReactDOM from 'react-dom';
|
2026-03-13 12:08:20 -06:00
|
|
|
import { RefreshCw, Loader, AlertCircle, PieChart, ChevronUp, ChevronDown, ChevronsUpDown, Settings2, GripVertical, Eye, EyeOff, Filter, Download } from 'lucide-react';
|
|
|
|
|
import * as XLSX from 'xlsx';
|
2026-03-11 11:47:03 -06:00
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api';
|
2026-03-11 14:44:53 -06:00
|
|
|
const STORAGE_KEY = 'steam_findings_columns_v2';
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 12:47:11 -06:00
|
|
|
// Column definitions — source of truth for labels, sort behaviour, rendering
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 12:47:11 -06:00
|
|
|
const COLUMN_DEFS = {
|
2026-03-11 14:23:50 -06:00
|
|
|
findingId: { label: 'Finding ID', sortable: true, filterable: false },
|
2026-03-11 13:17:01 -06:00
|
|
|
severity: { label: 'Severity', sortable: true, filterable: true },
|
|
|
|
|
title: { label: 'Title', sortable: true, filterable: true },
|
|
|
|
|
cves: { label: 'CVEs', sortable: true, filterable: true, multiValue: true },
|
|
|
|
|
hostName: { label: 'Host', sortable: true, filterable: true },
|
|
|
|
|
ipAddress: { label: 'IP Address', sortable: true, filterable: true },
|
|
|
|
|
dns: { label: 'DNS', sortable: true, filterable: true },
|
|
|
|
|
dueDate: { label: 'Due Date', sortable: true, filterable: true },
|
2026-03-11 14:44:53 -06:00
|
|
|
slaStatus: { label: 'SLA', sortable: true, filterable: true },
|
2026-03-11 13:17:01 -06:00
|
|
|
buOwnership: { label: 'BU', sortable: true, filterable: true },
|
2026-03-11 14:44:53 -06:00
|
|
|
workflow: { label: 'Workflow', sortable: true, filterable: true },
|
2026-03-11 13:17:01 -06:00
|
|
|
lastFoundOn: { label: 'Last Found', sortable: true, filterable: true },
|
|
|
|
|
note: { label: 'Notes', sortable: false, filterable: false },
|
2026-03-11 12:47:11 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const DEFAULT_COLUMN_ORDER = [
|
2026-03-11 14:23:50 -06:00
|
|
|
{ key: 'findingId', visible: true },
|
2026-03-11 12:47:11 -06:00
|
|
|
{ key: 'severity', visible: true },
|
|
|
|
|
{ key: 'title', visible: true },
|
2026-03-11 13:17:01 -06:00
|
|
|
{ key: 'cves', visible: true },
|
2026-03-11 12:47:11 -06:00
|
|
|
{ key: 'hostName', visible: true },
|
|
|
|
|
{ key: 'ipAddress', visible: true },
|
|
|
|
|
{ key: 'dns', visible: true },
|
|
|
|
|
{ key: 'dueDate', visible: true },
|
|
|
|
|
{ key: 'slaStatus', visible: true },
|
2026-03-11 13:03:17 -06:00
|
|
|
{ key: 'buOwnership', visible: true },
|
2026-03-11 14:44:53 -06:00
|
|
|
{ key: 'workflow', visible: true },
|
2026-03-11 12:47:11 -06:00
|
|
|
{ key: 'lastFoundOn', visible: true },
|
|
|
|
|
{ key: 'note', visible: true },
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 12:47:11 -06:00
|
|
|
// Persist / load column config
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function loadColumnOrder() {
|
|
|
|
|
try {
|
|
|
|
|
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
|
|
|
|
|
if (saved && Array.isArray(saved)) {
|
|
|
|
|
const savedKeys = new Set(saved.map((c) => c.key));
|
2026-03-11 13:03:17 -06:00
|
|
|
const merged = saved.filter((c) => COLUMN_DEFS[c.key]);
|
2026-03-11 12:47:11 -06:00
|
|
|
DEFAULT_COLUMN_ORDER.forEach((d) => {
|
|
|
|
|
if (!savedKeys.has(d.key)) merged.push({ ...d });
|
|
|
|
|
});
|
|
|
|
|
return merged;
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
return DEFAULT_COLUMN_ORDER.map((c) => ({ ...c }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveColumnOrder(order) {
|
|
|
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(order)); } catch { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Sort accessor by column key
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function getVal(finding, key) {
|
|
|
|
|
switch (key) {
|
2026-03-11 14:23:50 -06:00
|
|
|
case 'findingId': return finding.id ?? '';
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'severity': return finding.severity ?? 0;
|
|
|
|
|
case 'title': return finding.title ?? '';
|
|
|
|
|
case 'hostName': return finding.hostName ?? '';
|
|
|
|
|
case 'ipAddress': return finding.ipAddress ?? '';
|
|
|
|
|
case 'dns': return finding.dns ?? '';
|
|
|
|
|
case 'dueDate': return finding.dueDate ?? '';
|
|
|
|
|
case 'slaStatus': return finding.slaStatus ?? '';
|
2026-03-11 13:17:01 -06:00
|
|
|
case 'cves': return (finding.cves || []).length; // sort by CVE count
|
2026-03-11 13:03:17 -06:00
|
|
|
case 'buOwnership': return finding.buOwnership ?? '';
|
2026-03-11 14:44:53 -06:00
|
|
|
case 'workflow': return finding.workflow?.id ?? '';
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
|
|
|
|
case 'note': return finding.note ?? '';
|
|
|
|
|
default: return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 13:17:01 -06:00
|
|
|
// Filter accessor — severity → vrrGroup label; cves handled as multi-value
|
2026-03-11 13:03:17 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function getFilterVal(finding, key) {
|
|
|
|
|
if (key === 'severity') return finding.vrrGroup || '';
|
2026-03-11 13:17:01 -06:00
|
|
|
if (key === 'cves') return (finding.cves || []).join(','); // not used directly; see multiValue logic
|
2026-03-11 14:44:53 -06:00
|
|
|
if (key === 'workflow') return finding.workflow?.id || '';
|
2026-03-11 13:03:17 -06:00
|
|
|
return String(getVal(finding, key) ?? '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 12:08:20 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Export value accessor — plain text representation for CSV/XLSX
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function getExportVal(finding, key) {
|
|
|
|
|
switch (key) {
|
|
|
|
|
case 'findingId': return finding.id ?? '';
|
|
|
|
|
case 'severity': return finding.vrrGroup ? `${finding.severity?.toFixed(2)} ${finding.vrrGroup}` : String(finding.severity ?? '');
|
|
|
|
|
case 'title': return finding.title ?? '';
|
|
|
|
|
case 'cves': return (finding.cves || []).join(', ');
|
|
|
|
|
case 'hostName': return finding.hostName ?? '';
|
|
|
|
|
case 'ipAddress': return finding.ipAddress ?? '';
|
|
|
|
|
case 'dns': return finding.dns ?? '';
|
|
|
|
|
case 'dueDate': return finding.dueDate ?? '';
|
|
|
|
|
case 'slaStatus': return finding.slaStatus ?? '';
|
|
|
|
|
case 'buOwnership': return finding.buOwnership ?? '';
|
|
|
|
|
case 'workflow': return finding.workflow ? `${finding.workflow.id} (${finding.workflow.state})` : '';
|
|
|
|
|
case 'lastFoundOn': return finding.lastFoundOn ?? '';
|
|
|
|
|
case 'note': return finding.note ?? '';
|
|
|
|
|
default: return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Style helpers
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function severityColor(vrrGroup) {
|
|
|
|
|
switch ((vrrGroup || '').toUpperCase()) {
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'CRITICAL': return { bg: 'rgba(239,68,68,0.15)', border: '#EF4444', text: '#EF4444' };
|
|
|
|
|
case 'HIGH': return { bg: 'rgba(245,158,11,0.15)', border: '#F59E0B', text: '#F59E0B' };
|
|
|
|
|
case 'MEDIUM': return { bg: 'rgba(234,179,8,0.15)', border: '#EAB308', text: '#EAB308' };
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
default: return { bg: 'rgba(100,116,139,0.15)', border: '#64748B', text: '#94A3B8' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function slaColor(slaStatus) {
|
|
|
|
|
switch ((slaStatus || '').toUpperCase()) {
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'OVERDUE': return '#EF4444';
|
|
|
|
|
case 'AT_RISK': return '#F59E0B';
|
|
|
|
|
case 'WITHIN_SLA': return '#10B981';
|
|
|
|
|
default: return '#64748B';
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
function dueDateColor(dueDate) {
|
|
|
|
|
if (!dueDate) return '#64748B';
|
|
|
|
|
const today = new Date();
|
|
|
|
|
const due = new Date(dueDate);
|
|
|
|
|
const diffDays = Math.ceil((due - today) / (1000 * 60 * 60 * 24));
|
2026-03-11 13:03:17 -06:00
|
|
|
if (diffDays < 0) return '#EF4444';
|
|
|
|
|
if (diffDays <= 30) return '#F59E0B';
|
2026-03-11 12:47:11 -06:00
|
|
|
return '#94A3B8';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 14:44:53 -06:00
|
|
|
function workflowStyle(state) {
|
2026-03-11 15:36:02 -06:00
|
|
|
// Colors reflect action urgency — all findings here are Open, so Approved won't appear.
|
2026-03-11 14:44:53 -06:00
|
|
|
switch ((state || '').toLowerCase()) {
|
2026-03-11 15:36:02 -06:00
|
|
|
case 'expired': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // overdue — renew FP
|
|
|
|
|
case 'rejected': return { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.4)', text: '#EF4444' }; // denied — must remediate
|
|
|
|
|
case 'reworked': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // challenged — resubmit FP
|
|
|
|
|
case 'actionable': return { bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.4)', text: '#F59E0B' }; // needs action
|
|
|
|
|
case 'requested': return { bg: 'rgba(14,165,233,0.12)', border: 'rgba(14,165,233,0.35)', text: '#0EA5E9' }; // in flight — awaiting approval
|
|
|
|
|
default: return { bg: 'rgba(100,116,139,0.08)', border: 'rgba(100,116,139,0.2)', text: '#64748B' }; // unknown state
|
2026-03-11 14:44:53 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// SVG Donut Chart — Open vs Closed findings
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function polarToCartesian(cx, cy, r, angleDeg) {
|
|
|
|
|
const rad = ((angleDeg - 90) * Math.PI) / 180;
|
|
|
|
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function donutArcPath(cx, cy, outerR, innerR, startDeg, endDeg) {
|
|
|
|
|
// Full circle must be split into two arcs (SVG can't render a 360° arc)
|
|
|
|
|
if (Math.abs(endDeg - startDeg) >= 359.9) {
|
|
|
|
|
const mid = startDeg + 180;
|
|
|
|
|
return donutArcPath(cx, cy, outerR, innerR, startDeg, mid) + ' ' +
|
|
|
|
|
donutArcPath(cx, cy, outerR, innerR, mid, endDeg);
|
|
|
|
|
}
|
|
|
|
|
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
|
|
|
|
const s = polarToCartesian(cx, cy, outerR, startDeg);
|
|
|
|
|
const e = polarToCartesian(cx, cy, outerR, endDeg);
|
|
|
|
|
const si = polarToCartesian(cx, cy, innerR, endDeg);
|
|
|
|
|
const ei = polarToCartesian(cx, cy, innerR, startDeg);
|
|
|
|
|
return [
|
|
|
|
|
`M ${s.x.toFixed(2)} ${s.y.toFixed(2)}`,
|
|
|
|
|
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${e.x.toFixed(2)} ${e.y.toFixed(2)}`,
|
|
|
|
|
`L ${si.x.toFixed(2)} ${si.y.toFixed(2)}`,
|
|
|
|
|
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ei.x.toFixed(2)} ${ei.y.toFixed(2)}`,
|
|
|
|
|
'Z',
|
|
|
|
|
].join(' ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatusDonut({ open, closed, loading }) {
|
|
|
|
|
const SIZE = 180;
|
|
|
|
|
const CX = SIZE / 2;
|
|
|
|
|
const CY = SIZE / 2;
|
|
|
|
|
const OUTER = 72;
|
|
|
|
|
const INNER = 48;
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: `${SIZE}px` }}>
|
|
|
|
|
<Loader style={{ width: '24px', height: '24px', color: '#0EA5E9', animation: 'spin 1s linear infinite' }} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const total = open + closed;
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openDeg = (open / total) * 360;
|
|
|
|
|
const segments = [
|
|
|
|
|
{ label: 'Open', count: open, color: '#0EA5E9', start: 0, end: openDeg },
|
|
|
|
|
{ label: 'Closed', count: closed, color: '#475569', start: openDeg, end: 360 },
|
|
|
|
|
].filter((s) => s.count > 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
|
|
|
|
<svg width={SIZE} height={SIZE} style={{ flexShrink: 0 }}>
|
|
|
|
|
{/* Gap ring behind slices */}
|
|
|
|
|
<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.label}
|
|
|
|
|
d={donutArcPath(CX, CY, OUTER, INNER, seg.start, seg.end)}
|
|
|
|
|
fill={seg.color}
|
|
|
|
|
opacity={0.88}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
{/* Center total */}
|
|
|
|
|
<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 */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
|
|
|
{segments.map((seg) => (
|
|
|
|
|
<div key={seg.label} style={{ display: 'flex', alignItems: 'center', gap: '0.625rem' }}>
|
|
|
|
|
<div style={{ width: '10px', height: '10px', borderRadius: '2px', background: seg.color, flexShrink: 0 }} />
|
|
|
|
|
<div>
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
|
|
|
|
{seg.label}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem', fontWeight: '700', color: '#E2E8F0', lineHeight: 1.2 }}>
|
|
|
|
|
{seg.count.toLocaleString()}
|
|
|
|
|
<span style={{ fontSize: '0.68rem', fontWeight: '400', color: '#64748B', marginLeft: '0.4rem' }}>
|
|
|
|
|
({((seg.count / total) * 100).toFixed(1)}%)
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 12:50:15 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// SVG Donut Chart — FP# workflow state distribution
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
const WF_STATE_DEFS = [
|
|
|
|
|
{ key: 'expired', label: 'Expired', color: '#EF4444' },
|
|
|
|
|
{ key: 'rejected', label: 'Rejected', color: '#F87171' },
|
|
|
|
|
{ key: 'reworked', label: 'Reworked', color: '#F59E0B' },
|
|
|
|
|
{ key: 'actionable', label: 'Actionable', color: '#FCD34D' },
|
|
|
|
|
{ key: 'requested', label: 'Requested', color: '#0EA5E9' },
|
|
|
|
|
{ key: 'approved', label: 'Approved', color: '#10B981' },
|
|
|
|
|
{ key: 'none', label: 'No FP#', color: '#334155' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function WorkflowDonut({ findings }) {
|
|
|
|
|
const SIZE = 180;
|
|
|
|
|
const CX = SIZE / 2;
|
|
|
|
|
const CY = SIZE / 2;
|
|
|
|
|
const OUTER = 72;
|
|
|
|
|
const INNER = 48;
|
|
|
|
|
|
|
|
|
|
const counts = useMemo(() => {
|
|
|
|
|
const map = Object.fromEntries(WF_STATE_DEFS.map((d) => [d.key, 0]));
|
|
|
|
|
findings.forEach((f) => {
|
|
|
|
|
const state = (f.workflow?.state || '').toLowerCase();
|
|
|
|
|
if (state && state in map) map[state]++;
|
|
|
|
|
else map.none++;
|
|
|
|
|
});
|
|
|
|
|
return map;
|
|
|
|
|
}, [findings]);
|
|
|
|
|
|
|
|
|
|
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
|
|
|
|
|
|
|
|
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 = WF_STATE_DEFS
|
|
|
|
|
.map((def) => {
|
|
|
|
|
const count = counts[def.key];
|
|
|
|
|
if (!count) return null;
|
|
|
|
|
const start = cursor;
|
|
|
|
|
const end = cursor + (count / total) * 360;
|
|
|
|
|
cursor = end;
|
|
|
|
|
return { ...def, count, start, end };
|
|
|
|
|
})
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
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' }}>
|
|
|
|
|
TOTAL
|
|
|
|
|
</text>
|
|
|
|
|
</svg>
|
|
|
|
|
|
|
|
|
|
{/* Legend */}
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
|
|
|
{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.75rem', 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
function SortIcon({ colKey, sort }) {
|
2026-03-11 12:47:11 -06:00
|
|
|
if (sort.field !== colKey) return <ChevronsUpDown style={{ width: '11px', height: '11px', opacity: 0.3, marginLeft: '3px', flexShrink: 0 }} />;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
return sort.dir === 'asc'
|
2026-03-11 12:47:11 -06:00
|
|
|
? <ChevronUp style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />
|
|
|
|
|
: <ChevronDown style={{ width: '11px', height: '11px', color: '#0EA5E9', marginLeft: '3px', flexShrink: 0 }} />;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// NoteCell — inline editable, saves on blur
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function NoteCell({ findingId, initialNote }) {
|
2026-03-11 12:47:11 -06:00
|
|
|
const [value, setValue] = useState(initialNote || '');
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const [saving, setSaving] = useState(false);
|
2026-03-11 12:47:11 -06:00
|
|
|
const lastSaved = useRef(initialNote || '');
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setValue(initialNote || '');
|
|
|
|
|
lastSaved.current = initialNote || '';
|
|
|
|
|
}, [initialNote]);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
const save = useCallback(async () => {
|
2026-03-11 12:47:11 -06:00
|
|
|
if (value === lastSaved.current) return;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await fetch(`${API_BASE}/ivanti/findings/${encodeURIComponent(findingId)}/note`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
body: JSON.stringify({ note: value })
|
|
|
|
|
});
|
2026-03-11 12:47:11 -06:00
|
|
|
lastSaved.current = value;
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to save note:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
2026-03-11 12:47:11 -06:00
|
|
|
}, [findingId, value]);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={value}
|
|
|
|
|
maxLength={255}
|
|
|
|
|
onChange={(e) => setValue(e.target.value)}
|
|
|
|
|
onBlur={save}
|
|
|
|
|
placeholder="Add note…"
|
|
|
|
|
style={{
|
2026-03-11 12:47:11 -06:00
|
|
|
width: '100%', minWidth: '160px',
|
|
|
|
|
background: 'rgba(14,165,233,0.05)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.2)',
|
|
|
|
|
borderRadius: '4px', padding: '4px 8px',
|
|
|
|
|
color: '#CBD5E1', fontSize: '0.75rem',
|
|
|
|
|
fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box'
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
}}
|
2026-03-11 12:47:11 -06:00
|
|
|
onFocus={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.6)'; e.target.style.background = 'rgba(14,165,233,0.1)'; }}
|
|
|
|
|
onBlurCapture={(e) => { e.target.style.borderColor = 'rgba(14,165,233,0.2)'; e.target.style.background = 'rgba(14,165,233,0.05)'; }}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
/>
|
2026-03-11 12:47:11 -06:00
|
|
|
{saving && <Loader style={{ width: '10px', height: '10px', position: 'absolute', right: '6px', top: '6px', color: '#0EA5E9' }} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// ColumnManager — popover with drag-to-reorder and show/hide toggles
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function ColumnManager({ columnOrder, onChange }) {
|
2026-03-11 13:03:17 -06:00
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [dragIdx, setDragIdx] = useState(null);
|
|
|
|
|
const [overIdx, setOverIdx] = useState(null);
|
|
|
|
|
const panelRef = useRef(null);
|
|
|
|
|
const btnRef = useRef(null);
|
2026-03-11 12:47:11 -06:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
const handler = (e) => {
|
2026-03-11 13:03:17 -06:00
|
|
|
if (!panelRef.current?.contains(e.target) && !btnRef.current?.contains(e.target)) setOpen(false);
|
2026-03-11 12:47:11 -06:00
|
|
|
};
|
|
|
|
|
document.addEventListener('mousedown', handler);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
const toggleVisible = (key) => {
|
2026-03-11 13:03:17 -06:00
|
|
|
onChange(columnOrder.map((c) => c.key === key ? { ...c, visible: !c.visible } : c));
|
2026-03-11 12:47:11 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragStart = (idx) => setDragIdx(idx);
|
|
|
|
|
const handleDragOver = (e, idx) => { e.preventDefault(); setOverIdx(idx); };
|
|
|
|
|
const handleDrop = (idx) => {
|
|
|
|
|
if (dragIdx === null || dragIdx === idx) { setDragIdx(null); setOverIdx(null); return; }
|
|
|
|
|
const updated = [...columnOrder];
|
|
|
|
|
const [moved] = updated.splice(dragIdx, 1);
|
|
|
|
|
updated.splice(idx, 0, moved);
|
|
|
|
|
onChange(updated);
|
|
|
|
|
setDragIdx(null);
|
|
|
|
|
setOverIdx(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const visibleCount = columnOrder.filter((c) => c.visible).length;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ position: 'relative' }}>
|
|
|
|
|
<button
|
|
|
|
|
ref={btnRef}
|
|
|
|
|
onClick={() => setOpen((p) => !p)}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
|
|
|
|
padding: '0.375rem 0.75rem',
|
|
|
|
|
background: open ? 'rgba(14,165,233,0.15)' : 'rgba(14,165,233,0.07)',
|
|
|
|
|
border: `1px solid rgba(14,165,233,${open ? '0.5' : '0.25'})`,
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
color: '#0EA5E9', cursor: 'pointer',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.05em'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Settings2 style={{ width: '13px', height: '13px' }} />
|
|
|
|
|
Columns
|
|
|
|
|
<span style={{ fontSize: '0.65rem', opacity: 0.7 }}>({visibleCount}/{columnOrder.length})</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<div
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute', top: 'calc(100% + 8px)', right: 0,
|
|
|
|
|
width: '220px', zIndex: 100,
|
|
|
|
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.25)',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
|
|
|
|
|
padding: '0.5rem'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ fontSize: '0.65rem', color: '#475569', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', padding: '0.25rem 0.5rem 0.5rem', borderBottom: '1px solid rgba(255,255,255,0.05)', marginBottom: '0.375rem' }}>
|
|
|
|
|
Drag to reorder · click to toggle
|
|
|
|
|
</div>
|
|
|
|
|
{columnOrder.map((col, idx) => {
|
2026-03-11 13:03:17 -06:00
|
|
|
const def = COLUMN_DEFS[col.key];
|
2026-03-11 12:47:11 -06:00
|
|
|
const isDragging = dragIdx === idx;
|
|
|
|
|
const isOver = overIdx === idx && dragIdx !== null && dragIdx !== idx;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={col.key}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={() => handleDragStart(idx)}
|
|
|
|
|
onDragOver={(e) => handleDragOver(e, idx)}
|
|
|
|
|
onDrop={() => handleDrop(idx)}
|
|
|
|
|
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: '0.5rem',
|
2026-03-11 13:03:17 -06:00
|
|
|
padding: '0.4rem 0.5rem', borderRadius: '0.25rem', cursor: 'grab',
|
2026-03-11 12:47:11 -06:00
|
|
|
opacity: isDragging ? 0.4 : 1,
|
|
|
|
|
background: isOver ? 'rgba(14,165,233,0.12)' : 'transparent',
|
|
|
|
|
borderTop: isOver ? '2px solid #0EA5E9' : '2px solid transparent',
|
|
|
|
|
transition: 'background 0.1s'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<GripVertical style={{ width: '14px', height: '14px', color: '#334155', flexShrink: 0 }} />
|
|
|
|
|
<span style={{ flex: 1, fontSize: '0.78rem', color: col.visible ? '#CBD5E1' : '#475569', fontFamily: 'monospace' }}>
|
|
|
|
|
{def?.label || col.key}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); toggleVisible(col.key); }}
|
|
|
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: col.visible ? '#0EA5E9' : '#334155', lineHeight: 1 }}
|
|
|
|
|
>
|
2026-03-11 13:03:17 -06:00
|
|
|
{col.visible ? <Eye style={{ width: '14px', height: '14px' }} /> : <EyeOff style={{ width: '14px', height: '14px' }} />}
|
2026-03-11 12:47:11 -06:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// FilterDropdown — portal-based so it escapes overflow:auto clipping
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function FilterDropdown({ anchorEl, colKey, findings, activeFilter, onFilterChange, onClose }) {
|
|
|
|
|
const [pos, setPos] = useState({ top: 0, left: 0 });
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const panelRef = useRef(null);
|
|
|
|
|
const inputRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
// Compute fixed position from anchor button's viewport rect
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!anchorEl) return;
|
|
|
|
|
const r = anchorEl.getBoundingClientRect();
|
|
|
|
|
setPos({ top: r.bottom + 4, left: r.left });
|
|
|
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
|
|
|
}, [anchorEl]);
|
|
|
|
|
|
|
|
|
|
// Close on outside click
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
if (panelRef.current && !panelRef.current.contains(e.target) &&
|
|
|
|
|
!(anchorEl && anchorEl.contains(e.target))) {
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('mousedown', handler);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
|
|
|
}, [anchorEl, onClose]);
|
|
|
|
|
|
|
|
|
|
// Close on Escape
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
|
|
|
|
document.addEventListener('keydown', handler);
|
|
|
|
|
return () => document.removeEventListener('keydown', handler);
|
|
|
|
|
}, [onClose]);
|
|
|
|
|
|
2026-03-11 13:17:01 -06:00
|
|
|
// Unique values from the full (unfiltered) findings list.
|
|
|
|
|
// Multi-value columns (e.g. cves) expand their array so each item is a separate option.
|
2026-03-11 13:03:17 -06:00
|
|
|
const allValues = useMemo(() => {
|
2026-03-11 13:17:01 -06:00
|
|
|
const def = COLUMN_DEFS[colKey];
|
2026-03-11 13:03:17 -06:00
|
|
|
const vals = new Set();
|
|
|
|
|
findings.forEach((f) => {
|
2026-03-11 13:17:01 -06:00
|
|
|
if (def?.multiValue) {
|
|
|
|
|
(f[colKey] || []).forEach((v) => { if (String(v).trim()) vals.add(String(v).trim()); });
|
|
|
|
|
} else {
|
|
|
|
|
const v = getFilterVal(f, colKey).trim();
|
|
|
|
|
if (v) vals.add(v);
|
|
|
|
|
}
|
2026-03-11 13:03:17 -06:00
|
|
|
});
|
|
|
|
|
return [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
|
|
|
}, [findings, colKey]);
|
|
|
|
|
|
|
|
|
|
const displayed = search.trim()
|
|
|
|
|
? allValues.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
|
|
|
|
|
: allValues;
|
|
|
|
|
|
|
|
|
|
const isChecked = (val) => !activeFilter || activeFilter.has(val);
|
|
|
|
|
const activeCount = activeFilter ? activeFilter.size : allValues.length;
|
|
|
|
|
|
|
|
|
|
const toggle = (val) => {
|
|
|
|
|
let next;
|
|
|
|
|
if (!activeFilter) {
|
|
|
|
|
next = new Set(allValues);
|
|
|
|
|
next.delete(val);
|
|
|
|
|
} else {
|
|
|
|
|
next = new Set(activeFilter);
|
|
|
|
|
if (next.has(val)) next.delete(val); else next.add(val);
|
|
|
|
|
}
|
|
|
|
|
// If all values selected again, remove the filter entirely
|
|
|
|
|
onFilterChange(next.size >= allValues.length ? null : next);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return ReactDOM.createPortal(
|
|
|
|
|
<div
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
style={{
|
|
|
|
|
position: 'fixed', top: pos.top, left: pos.left,
|
|
|
|
|
width: '220px', zIndex: 9999,
|
|
|
|
|
background: 'linear-gradient(180deg, #0F1A2E 0%, #0A1628 100%)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.3)',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.8)',
|
|
|
|
|
padding: '0.5rem',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* Search */}
|
|
|
|
|
<input
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
type="text"
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
placeholder="Search values…"
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%', marginBottom: '0.375rem',
|
|
|
|
|
background: 'rgba(14,165,233,0.05)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.2)',
|
|
|
|
|
borderRadius: '0.25rem', padding: '0.3rem 0.5rem',
|
|
|
|
|
color: '#CBD5E1', fontSize: '0.72rem',
|
|
|
|
|
fontFamily: 'monospace', outline: 'none', boxSizing: 'border-box',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Select All / Clear */}
|
|
|
|
|
<div style={{ display: 'flex', gap: '0.375rem', marginBottom: '0.375rem', paddingBottom: '0.375rem', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onFilterChange(null)}
|
|
|
|
|
style={{ flex: 1, padding: '0.2rem', background: 'rgba(14,165,233,0.08)', border: '1px solid rgba(14,165,233,0.2)', borderRadius: '0.25rem', color: '#0EA5E9', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
|
|
|
|
>
|
|
|
|
|
Select All
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onFilterChange(new Set())}
|
|
|
|
|
style={{ flex: 1, padding: '0.2rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.2)', borderRadius: '0.25rem', color: '#EF4444', cursor: 'pointer', fontSize: '0.65rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
|
|
|
|
>
|
|
|
|
|
Clear
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Value checkboxes */}
|
|
|
|
|
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
|
|
|
|
{displayed.length === 0 ? (
|
|
|
|
|
<div style={{ fontSize: '0.68rem', color: '#475569', textAlign: 'center', padding: '0.5rem 0' }}>No values</div>
|
|
|
|
|
) : displayed.map((val) => (
|
|
|
|
|
<label
|
|
|
|
|
key={val}
|
|
|
|
|
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.25rem 0.375rem', borderRadius: '0.25rem', cursor: 'pointer', color: isChecked(val) ? '#CBD5E1' : '#475569', fontSize: '0.72rem', fontFamily: 'monospace' }}
|
|
|
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.08)'}
|
|
|
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={isChecked(val)}
|
|
|
|
|
onChange={() => toggle(val)}
|
|
|
|
|
style={{ accentColor: '#0EA5E9', width: '12px', height: '12px', flexShrink: 0, cursor: 'pointer' }}
|
|
|
|
|
/>
|
|
|
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{val}</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Status footer */}
|
|
|
|
|
<div style={{ marginTop: '0.375rem', paddingTop: '0.375rem', borderTop: '1px solid rgba(255,255,255,0.06)', fontSize: '0.65rem', color: '#475569', textAlign: 'center', fontFamily: 'monospace' }}>
|
|
|
|
|
{activeCount} / {allValues.length} selected
|
|
|
|
|
</div>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 12:47:11 -06:00
|
|
|
// Render a single table cell by column key
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
function TableCell({ colKey, finding }) {
|
|
|
|
|
switch (colKey) {
|
2026-03-11 14:23:50 -06:00
|
|
|
case 'findingId':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#475569', fontFamily: 'monospace', fontSize: '0.68rem' }}>
|
|
|
|
|
{finding.id || '—'}
|
|
|
|
|
</td>
|
|
|
|
|
);
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'severity': {
|
|
|
|
|
const sc = severityColor(finding.vrrGroup);
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', padding: '0.2rem 0.45rem', borderRadius: '0.25rem', background: sc.bg, border: `1px solid ${sc.border}40`, fontFamily: 'monospace', fontWeight: '700', color: sc.text, fontSize: '0.72rem' }}>
|
|
|
|
|
{finding.severity?.toFixed(2)}
|
|
|
|
|
<span style={{ fontSize: '0.6rem', opacity: 0.75 }}>{finding.vrrGroup}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
case 'title':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '280px' }}>
|
|
|
|
|
<span style={{ color: '#CBD5E1', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.title}>
|
|
|
|
|
{finding.title}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
2026-03-11 13:17:01 -06:00
|
|
|
case 'cves': {
|
|
|
|
|
const cves = finding.cves || [];
|
|
|
|
|
if (cves.length === 0) return <td style={{ padding: '0.45rem 0.75rem', color: '#475569' }}>—</td>;
|
|
|
|
|
const shown = cves.slice(0, 2);
|
|
|
|
|
const rest = cves.length - shown.length;
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', minWidth: '160px', maxWidth: '240px' }}>
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.2rem' }}>
|
|
|
|
|
{shown.map((cve) => (
|
|
|
|
|
<span key={cve} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(139,92,246,0.1)', border: '1px solid rgba(139,92,246,0.3)', color: '#A78BFA', fontFamily: 'monospace', fontSize: '0.65rem', fontWeight: '600', whiteSpace: 'nowrap' }}>
|
|
|
|
|
{cve}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
{rest > 0 && (
|
|
|
|
|
<span title={cves.slice(2).join('\n')} style={{ padding: '0.1rem 0.35rem', borderRadius: '0.2rem', background: 'rgba(100,116,139,0.12)', border: '1px solid rgba(100,116,139,0.25)', color: '#64748B', fontFamily: 'monospace', fontSize: '0.65rem', cursor: 'help', whiteSpace: 'nowrap' }}>
|
|
|
|
|
+{rest} more
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'hostName':
|
|
|
|
|
case 'ipAddress':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
|
|
|
|
{finding[colKey] || '—'}
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
case 'dns':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', maxWidth: '200px' }}>
|
|
|
|
|
<span style={{ color: '#94A3B8', fontFamily: 'monospace', fontSize: '0.72rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={finding.dns}>
|
|
|
|
|
{finding.dns || '—'}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
case 'dueDate': {
|
|
|
|
|
const color = dueDateColor(finding.dueDate);
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.72rem', fontWeight: '600', color }}>
|
|
|
|
|
{finding.dueDate || '—'}
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
case 'slaStatus':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600', color: slaColor(finding.slaStatus) }}>
|
|
|
|
|
{finding.slaStatus || '—'}
|
|
|
|
|
</td>
|
|
|
|
|
);
|
2026-03-11 13:03:17 -06:00
|
|
|
case 'buOwnership': {
|
|
|
|
|
const bu = finding.buOwnership || '';
|
|
|
|
|
const isSteam = bu.toUpperCase().includes('STEAM');
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
|
|
|
|
{bu ? (
|
|
|
|
|
<span
|
|
|
|
|
title={bu}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'inline-block', padding: '0.15rem 0.4rem',
|
|
|
|
|
borderRadius: '0.25rem',
|
|
|
|
|
background: isSteam ? 'rgba(14,165,233,0.1)' : 'rgba(245,158,11,0.1)',
|
|
|
|
|
border: `1px solid ${isSteam ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.3)'}`,
|
|
|
|
|
color: isSteam ? '#0EA5E9' : '#F59E0B',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{bu.replace('NTS-AEO-', '')}
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span style={{ color: '#475569' }}>—</span>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-11 14:44:53 -06:00
|
|
|
case 'workflow': {
|
|
|
|
|
const wf = finding.workflow;
|
|
|
|
|
if (!wf || !wf.id) return <td style={{ padding: '0.45rem 0.75rem', color: '#334155' }}>—</td>;
|
|
|
|
|
const ws = workflowStyle(wf.state);
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap' }}>
|
|
|
|
|
<span
|
|
|
|
|
title={`${wf.id} · ${wf.state || 'Unknown'} · ${wf.type || ''}`}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
|
|
|
|
|
padding: '0.15rem 0.45rem', borderRadius: '0.25rem',
|
|
|
|
|
background: ws.bg, border: `1px solid ${ws.border}`,
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
|
|
|
|
color: ws.text, cursor: 'default',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{wf.id}
|
|
|
|
|
<span style={{ fontSize: '0.58rem', opacity: 0.8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
|
|
|
|
{wf.state}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-11 12:47:11 -06:00
|
|
|
case 'lastFoundOn':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem', whiteSpace: 'nowrap', color: '#64748B', fontFamily: 'monospace', fontSize: '0.72rem' }}>
|
|
|
|
|
{finding.lastFoundOn || '—'}
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
case 'note':
|
|
|
|
|
return (
|
|
|
|
|
<td style={{ padding: '0.45rem 0.75rem' }}>
|
|
|
|
|
<NoteCell findingId={finding.id} initialNote={finding.note} />
|
|
|
|
|
</td>
|
|
|
|
|
);
|
|
|
|
|
default:
|
|
|
|
|
return <td style={{ padding: '0.45rem 0.75rem', color: '#64748B' }}>—</td>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Main ReportingPage
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-11 14:09:08 -06:00
|
|
|
export default function ReportingPage({ filterDate }) {
|
2026-03-11 13:03:17 -06:00
|
|
|
const [findings, setFindings] = useState([]);
|
|
|
|
|
const [total, setTotal] = useState(null);
|
|
|
|
|
const [syncedAt, setSyncedAt] = useState(null);
|
|
|
|
|
const [syncStatus, setSyncStatus] = useState(null);
|
|
|
|
|
const [syncError, setSyncError] = useState(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [syncing, setSyncing] = useState(false);
|
2026-03-13 12:23:05 -06:00
|
|
|
const [statusCounts, setStatusCounts] = useState({ open: 0, closed: 0 });
|
|
|
|
|
const [countsLoading, setCountsLoading] = useState(true);
|
2026-03-11 13:03:17 -06:00
|
|
|
const [sort, setSort] = useState({ field: 'severity', dir: 'desc' });
|
|
|
|
|
const [columnOrder, setColumnOrder] = useState(loadColumnOrder);
|
2026-03-11 14:09:08 -06:00
|
|
|
const [columnFilters, setColumnFilters] = useState(() =>
|
|
|
|
|
filterDate ? { dueDate: new Set([filterDate]) } : {}
|
|
|
|
|
);
|
2026-03-11 13:03:17 -06:00
|
|
|
const [openFilter, setOpenFilter] = useState(null);
|
|
|
|
|
const filterBtnRefs = useRef({});
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
const updateColumns = useCallback((newOrder) => {
|
|
|
|
|
setColumnOrder(newOrder);
|
|
|
|
|
saveColumnOrder(newOrder);
|
|
|
|
|
}, []);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
const applyState = (data) => {
|
|
|
|
|
setTotal(data.total ?? 0);
|
|
|
|
|
setFindings(data.findings || []);
|
|
|
|
|
setSyncedAt(data.synced_at || null);
|
|
|
|
|
setSyncStatus(data.sync_status || null);
|
|
|
|
|
setSyncError(data.error_message || null);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
const fetchCounts = async () => {
|
|
|
|
|
setCountsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings/counts`, { credentials: 'include' });
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (res.ok) setStatusCounts({ open: data.open ?? 0, closed: data.closed ?? 0 });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error loading status counts:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setCountsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const fetchFindings = async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2026-03-11 13:03:17 -06:00
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings`, { credentials: 'include' });
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const data = await res.json();
|
|
|
|
|
if (res.ok) applyState(data);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error loading findings:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const syncFindings = async () => {
|
|
|
|
|
setSyncing(true);
|
|
|
|
|
try {
|
2026-03-11 13:03:17 -06:00
|
|
|
const res = await fetch(`${API_BASE}/ivanti/findings/sync`, { method: 'POST', credentials: 'include' });
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const data = await res.json();
|
2026-03-13 12:23:05 -06:00
|
|
|
if (res.ok) {
|
|
|
|
|
applyState(data);
|
|
|
|
|
fetchCounts(); // refresh counts after sync
|
|
|
|
|
}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error syncing findings:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setSyncing(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 12:23:05 -06:00
|
|
|
useEffect(() => {
|
|
|
|
|
fetchFindings();
|
|
|
|
|
fetchCounts();
|
|
|
|
|
}, []); // eslint-disable-line
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
// Set/clear a single column filter
|
|
|
|
|
const setColFilter = useCallback((colKey, vals) => {
|
|
|
|
|
setColumnFilters((prev) => {
|
|
|
|
|
if (!vals) {
|
|
|
|
|
const next = { ...prev };
|
|
|
|
|
delete next[colKey];
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, [colKey]: vals };
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Apply all active column filters to produce the visible row set
|
|
|
|
|
const filtered = useMemo(() => {
|
|
|
|
|
const active = Object.entries(columnFilters);
|
|
|
|
|
if (active.length === 0) return findings;
|
|
|
|
|
return findings.filter((f) =>
|
|
|
|
|
active.every(([key, vals]) => {
|
|
|
|
|
if (!vals || vals.size === 0) return false;
|
2026-03-11 13:17:01 -06:00
|
|
|
const def = COLUMN_DEFS[key];
|
|
|
|
|
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()));
|
|
|
|
|
}
|
2026-03-11 13:03:17 -06:00
|
|
|
return vals.has(getFilterVal(f, key).trim());
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}, [findings, columnFilters]);
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
// Visible columns in current order
|
|
|
|
|
const visibleCols = columnOrder.filter((c) => c.visible && COLUMN_DEFS[c.key]);
|
|
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
// Sort filtered results
|
|
|
|
|
const sorted = useMemo(() => [...filtered].sort((a, b) => {
|
2026-03-11 12:47:11 -06:00
|
|
|
const av = getVal(a, sort.field);
|
|
|
|
|
const bv = getVal(b, sort.field);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
let cmp = 0;
|
|
|
|
|
if (typeof av === 'number' && typeof bv === 'number') {
|
|
|
|
|
cmp = av - bv;
|
|
|
|
|
} else {
|
|
|
|
|
cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });
|
|
|
|
|
}
|
|
|
|
|
return sort.dir === 'asc' ? cmp : -cmp;
|
2026-03-11 13:03:17 -06:00
|
|
|
}), [filtered, sort]);
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
|
|
|
|
|
const toggleSort = (key) => {
|
|
|
|
|
setSort((prev) =>
|
|
|
|
|
prev.field === key
|
|
|
|
|
? { field: key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
|
|
|
|
: { field: key, dir: 'asc' }
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-11 13:03:17 -06:00
|
|
|
const activeFilterCount = Object.keys(columnFilters).length;
|
|
|
|
|
|
2026-03-13 12:08:20 -06:00
|
|
|
const [exportMenuOpen, setExportMenuOpen] = useState(false);
|
|
|
|
|
const exportBtnRef = useRef(null);
|
|
|
|
|
|
|
|
|
|
// Close export menu on outside click
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!exportMenuOpen) return;
|
|
|
|
|
const handler = (e) => {
|
|
|
|
|
if (exportBtnRef.current && !exportBtnRef.current.contains(e.target)) {
|
|
|
|
|
setExportMenuOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('mousedown', handler);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
|
|
|
}, [exportMenuOpen]);
|
|
|
|
|
|
|
|
|
|
const buildExportRows = useCallback(() => {
|
|
|
|
|
const cols = visibleCols.filter((c) => COLUMN_DEFS[c.key]);
|
|
|
|
|
const headers = cols.map((c) => COLUMN_DEFS[c.key].label);
|
|
|
|
|
const rows = sorted.map((finding) =>
|
|
|
|
|
cols.map((c) => getExportVal(finding, c.key))
|
|
|
|
|
);
|
|
|
|
|
return [headers, ...rows];
|
|
|
|
|
}, [sorted, visibleCols]);
|
|
|
|
|
|
|
|
|
|
const exportCSV = useCallback(() => {
|
|
|
|
|
setExportMenuOpen(false);
|
|
|
|
|
const rows = buildExportRows();
|
|
|
|
|
const csvContent = rows.map((row) =>
|
|
|
|
|
row.map((cell) => {
|
|
|
|
|
const s = String(cell ?? '');
|
|
|
|
|
// Quote if it contains comma, double-quote, or newline
|
|
|
|
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
|
|
|
|
return `"${s.replace(/"/g, '""')}"`;
|
|
|
|
|
}
|
|
|
|
|
return s;
|
|
|
|
|
}).join(',')
|
|
|
|
|
).join('\r\n');
|
|
|
|
|
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = `findings-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}, [buildExportRows]);
|
|
|
|
|
|
|
|
|
|
const exportXLSX = useCallback(() => {
|
|
|
|
|
setExportMenuOpen(false);
|
|
|
|
|
const rows = buildExportRows();
|
|
|
|
|
const ws = XLSX.utils.aoa_to_sheet(rows);
|
|
|
|
|
// Auto-fit column widths
|
|
|
|
|
const colWidths = rows[0].map((_, ci) =>
|
|
|
|
|
Math.min(60, Math.max(10, ...rows.map((r) => String(r[ci] ?? '').length)))
|
|
|
|
|
);
|
|
|
|
|
ws['!cols'] = colWidths.map((w) => ({ wch: w }));
|
|
|
|
|
const wb = XLSX.utils.book_new();
|
|
|
|
|
XLSX.utils.book_append_sheet(wb, ws, 'Findings');
|
|
|
|
|
XLSX.writeFile(wb, `findings-export-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
|
|
|
}, [buildExportRows]);
|
|
|
|
|
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
const syncedDisplay = syncedAt
|
2026-03-11 12:47:11 -06:00
|
|
|
? `Synced ${new Date(syncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
: 'Never synced';
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Render
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
|
|
|
|
|
|
|
|
|
{/* ----------------------------------------------------------------
|
2026-03-11 12:47:11 -06:00
|
|
|
Panel 1 — Metrics placeholder
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
---------------------------------------------------------------- */}
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
|
|
|
|
border: '1px solid rgba(245,158,11,0.2)',
|
|
|
|
|
borderLeft: '3px solid #F59E0B',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
padding: '1.5rem',
|
|
|
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
|
|
|
|
}}>
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.625rem', marginBottom: '1rem' }}>
|
|
|
|
|
<PieChart style={{ width: '20px', height: '20px', color: '#F59E0B' }} />
|
|
|
|
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#F59E0B', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(245,158,11,0.4)', margin: 0 }}>
|
|
|
|
|
Metric Graphs
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
2026-03-13 12:50:15 -06:00
|
|
|
<div style={{ display: 'flex', gap: '3rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
2026-03-13 12:23:05 -06:00
|
|
|
{/* Open vs Closed 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' }}>
|
|
|
|
|
Open vs Closed
|
|
|
|
|
</div>
|
|
|
|
|
<StatusDonut
|
|
|
|
|
open={statusCounts.open}
|
|
|
|
|
closed={statusCounts.closed}
|
|
|
|
|
loading={countsLoading}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-03-13 12:50:15 -06:00
|
|
|
|
|
|
|
|
{/* Divider */}
|
|
|
|
|
<div style={{ width: '1px', background: 'rgba(255,255,255,0.06)', alignSelf: 'stretch', flexShrink: 0 }} />
|
|
|
|
|
|
|
|
|
|
{/* FP# Workflow state 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' }}>
|
|
|
|
|
FP# Workflow Status
|
|
|
|
|
</div>
|
|
|
|
|
<WorkflowDonut findings={findings} />
|
|
|
|
|
</div>
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ----------------------------------------------------------------
|
|
|
|
|
Panel 2 — Findings table
|
|
|
|
|
---------------------------------------------------------------- */}
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'linear-gradient(135deg, rgba(15,26,46,0.95) 0%, rgba(10,22,40,0.9) 100%)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.2)',
|
|
|
|
|
borderLeft: '3px solid #0EA5E9',
|
|
|
|
|
borderRadius: '0.5rem',
|
|
|
|
|
padding: '1.5rem',
|
|
|
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.4)'
|
|
|
|
|
}}>
|
2026-03-11 12:47:11 -06:00
|
|
|
{/* Panel header */}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 style={{ fontFamily: 'monospace', fontSize: '1rem', fontWeight: '600', color: '#0EA5E9', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(14,165,233,0.4)', margin: '0 0 4px 0' }}>
|
|
|
|
|
Host Findings
|
|
|
|
|
</h2>
|
|
|
|
|
<div style={{ fontFamily: 'monospace', fontSize: '0.7rem', color: '#475569' }}>
|
|
|
|
|
{syncedDisplay}
|
|
|
|
|
{syncStatus === 'success' && total !== null && (
|
2026-03-11 13:03:17 -06:00
|
|
|
<span style={{ marginLeft: '0.75rem', color: '#64748B' }}>
|
|
|
|
|
{activeFilterCount > 0 ? `${filtered.length} of ${total}` : total} findings
|
|
|
|
|
{activeFilterCount > 0 && (
|
|
|
|
|
<span style={{ marginLeft: '0.5rem', color: '#F59E0B' }}>
|
|
|
|
|
({activeFilterCount} filter{activeFilterCount > 1 ? 's' : ''} active)
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-11 12:47:11 -06:00
|
|
|
|
|
|
|
|
{/* Action buttons */}
|
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
2026-03-11 13:03:17 -06:00
|
|
|
{activeFilterCount > 0 && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setColumnFilters({})}
|
|
|
|
|
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',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.05em'
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Filter style={{ width: '11px', height: '11px' }} />
|
|
|
|
|
Clear Filters
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-03-13 12:08:20 -06:00
|
|
|
{/* Export dropdown */}
|
|
|
|
|
<div ref={exportBtnRef} style={{ position: 'relative' }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setExportMenuOpen((o) => !o)}
|
|
|
|
|
disabled={sorted.length === 0}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
|
|
|
|
padding: '0.375rem 0.75rem',
|
|
|
|
|
background: 'rgba(16,185,129,0.08)',
|
|
|
|
|
border: '1px solid rgba(16,185,129,0.3)',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
color: '#10B981', cursor: sorted.length === 0 ? 'not-allowed' : 'pointer',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
|
|
|
|
opacity: sorted.length === 0 ? 0.4 : 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Download style={{ width: '11px', height: '11px' }} />
|
|
|
|
|
Export
|
|
|
|
|
<ChevronDown style={{ width: '10px', height: '10px', marginLeft: '1px' }} />
|
|
|
|
|
</button>
|
|
|
|
|
{exportMenuOpen && (
|
|
|
|
|
<div style={{
|
|
|
|
|
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 200,
|
|
|
|
|
background: 'rgb(12,22,40)', border: '1px solid rgba(16,185,129,0.3)',
|
|
|
|
|
borderRadius: '0.375rem', overflow: 'hidden',
|
|
|
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
|
|
|
|
minWidth: '120px',
|
|
|
|
|
}}>
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'CSV (.csv)', action: exportCSV },
|
|
|
|
|
{ label: 'Excel (.xlsx)', action: exportXLSX },
|
|
|
|
|
].map(({ label, action }) => (
|
|
|
|
|
<button
|
|
|
|
|
key={label}
|
|
|
|
|
onClick={action}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'block', width: '100%', textAlign: 'left',
|
|
|
|
|
padding: '0.5rem 0.875rem',
|
|
|
|
|
background: 'none', border: 'none',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.73rem', fontWeight: '600',
|
|
|
|
|
color: '#10B981', cursor: 'pointer',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.04em',
|
|
|
|
|
transition: 'background 0.1s',
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(16,185,129,0.1)'}
|
|
|
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'none'}
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-11 12:47:11 -06:00
|
|
|
<ColumnManager columnOrder={columnOrder} onChange={updateColumns} />
|
|
|
|
|
<button
|
|
|
|
|
onClick={syncFindings}
|
|
|
|
|
disabled={syncing || loading}
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex', alignItems: 'center', gap: '0.375rem',
|
|
|
|
|
padding: '0.375rem 0.75rem',
|
|
|
|
|
background: 'rgba(14,165,233,0.1)',
|
|
|
|
|
border: '1px solid rgba(14,165,233,0.35)',
|
|
|
|
|
borderRadius: '0.375rem',
|
|
|
|
|
color: '#0EA5E9', cursor: 'pointer',
|
|
|
|
|
fontFamily: 'monospace', fontSize: '0.75rem', fontWeight: '600',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
|
|
|
|
opacity: (syncing || loading) ? 0.6 : 1
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw style={{ width: '13px', height: '13px', animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
|
|
|
|
|
{syncing ? 'Syncing…' : 'Sync'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Error banner */}
|
|
|
|
|
{syncStatus === 'error' && syncError && (
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', padding: '0.625rem 0.875rem', background: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.25)', borderRadius: '0.375rem', marginBottom: '1rem' }}>
|
|
|
|
|
<AlertCircle style={{ width: '15px', height: '15px', color: '#EF4444', flexShrink: 0, marginTop: '1px' }} />
|
|
|
|
|
<span style={{ fontSize: '0.75rem', color: '#FCA5A5', fontFamily: 'monospace' }}>{syncError}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-11 12:47:11 -06:00
|
|
|
{/* Content */}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
{loading ? (
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
|
|
|
|
<Loader style={{ width: '28px', height: '28px', color: '#0EA5E9', animation: 'spin 1s linear infinite', margin: '0 auto 0.75rem' }} />
|
|
|
|
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Loading findings…</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : syncStatus === 'never' ? (
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
|
|
|
|
<p style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#475569' }}>Click Sync to load findings data</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-11 13:23:56 -06:00
|
|
|
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: 'calc(100vh - 420px)', minHeight: '200px', marginTop: '0.75rem' }}>
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', fontFamily: 'sans-serif' }}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style={{ borderBottom: '1px solid rgba(14,165,233,0.2)' }}>
|
2026-03-11 12:47:11 -06:00
|
|
|
{visibleCols.map((col) => {
|
2026-03-11 13:03:17 -06:00
|
|
|
const def = COLUMN_DEFS[col.key];
|
|
|
|
|
const active = sort.field === col.key;
|
|
|
|
|
const isFiltered = !!columnFilters[col.key];
|
2026-03-11 12:47:11 -06:00
|
|
|
return (
|
|
|
|
|
<th
|
|
|
|
|
key={col.key}
|
|
|
|
|
onClick={def?.sortable ? () => toggleSort(col.key) : undefined}
|
|
|
|
|
style={{
|
2026-03-11 13:03:17 -06:00
|
|
|
padding: '0.5rem 0.75rem', textAlign: 'left',
|
2026-03-11 12:47:11 -06:00
|
|
|
fontFamily: 'monospace', fontSize: '0.68rem', fontWeight: '600',
|
|
|
|
|
color: active ? '#0EA5E9' : '#64748B',
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.08em',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
cursor: def?.sortable ? 'pointer' : 'default',
|
|
|
|
|
userSelect: 'none',
|
2026-03-11 13:23:56 -06:00
|
|
|
background: 'rgb(10, 20, 36)',
|
|
|
|
|
position: 'sticky', top: 0, zIndex: 10,
|
|
|
|
|
boxShadow: '0 1px 0 rgba(14,165,233,0.2)',
|
2026-03-11 12:47:11 -06:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
|
|
|
|
{def?.label || col.key}
|
|
|
|
|
{def?.sortable && <SortIcon colKey={col.key} sort={sort} />}
|
2026-03-11 13:03:17 -06:00
|
|
|
{def?.filterable && (
|
|
|
|
|
<button
|
|
|
|
|
ref={(el) => { filterBtnRefs.current[col.key] = el; }}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setOpenFilter(openFilter === col.key ? null : col.key);
|
|
|
|
|
}}
|
|
|
|
|
title={`Filter ${def.label}`}
|
|
|
|
|
style={{
|
|
|
|
|
background: 'none', border: 'none',
|
|
|
|
|
cursor: 'pointer', padding: '1px 1px 1px 3px',
|
|
|
|
|
color: isFiltered ? '#F59E0B' : '#334155',
|
|
|
|
|
lineHeight: 1, flexShrink: 0,
|
|
|
|
|
transition: 'color 0.15s',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Filter style={{ width: '10px', height: '10px' }} />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-03-11 12:47:11 -06:00
|
|
|
</span>
|
|
|
|
|
</th>
|
|
|
|
|
);
|
|
|
|
|
})}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{sorted.map((finding, idx) => {
|
|
|
|
|
const rowBg = idx % 2 === 0 ? 'rgba(15,26,46,0.4)' : 'rgba(10,18,32,0.4)';
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
key={finding.id}
|
|
|
|
|
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', background: rowBg }}
|
|
|
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
|
|
|
|
onMouseLeave={(e) => e.currentTarget.style.background = rowBg}
|
|
|
|
|
>
|
2026-03-11 12:47:11 -06:00
|
|
|
{visibleCols.map((col) => (
|
|
|
|
|
<TableCell key={col.key} colKey={col.key} finding={finding} />
|
|
|
|
|
))}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{sorted.length === 0 && (
|
|
|
|
|
<tr>
|
2026-03-11 12:47:11 -06:00
|
|
|
<td colSpan={visibleCols.length} style={{ textAlign: 'center', padding: '2rem', color: '#475569', fontFamily: 'monospace', fontSize: '0.75rem' }}>
|
2026-03-11 13:03:17 -06:00
|
|
|
{activeFilterCount > 0 ? 'No findings match the current filters' : 'No findings found'}
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-03-11 13:03:17 -06:00
|
|
|
|
|
|
|
|
{/* Filter dropdown — rendered via portal at document.body */}
|
|
|
|
|
{openFilter && COLUMN_DEFS[openFilter]?.filterable && (
|
|
|
|
|
<FilterDropdown
|
|
|
|
|
anchorEl={filterBtnRefs.current[openFilter]}
|
|
|
|
|
colKey={openFilter}
|
|
|
|
|
findings={findings}
|
|
|
|
|
activeFilter={columnFilters[openFilter] || null}
|
|
|
|
|
onFilterChange={(vals) => setColFilter(openFilter, vals)}
|
|
|
|
|
onClose={() => setOpenFilter(null)}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-03-11 11:47:03 -06:00
|
|
|
</div>
|
Add Reporting page with Ivanti host findings table
Backend:
- New route /api/ivanti/findings (GET cached data, POST /sync, PUT /:id/note)
- Fetches all pages of hostFinding/search filtered to NTS-AEO groups, severity 8.5-9.9, Open state
- SQLite cache (ivanti_findings_cache) stores slimmed findings across syncs
- Separate ivanti_finding_notes table persists user notes by finding ID
- Daily auto-sync on startup + 24h interval, manual sync endpoint
- Notes capped at 255 chars server-side
Frontend (ReportingPage):
- Panel 1: Metric graphs placeholder (full width, amber theme)
- Panel 2: Sortable findings table (all columns click-to-sort with ASC/DESC toggle)
- Columns: Severity (color-coded badge), Title, Host, IP, DNS, SLA, Discovered, Last Found, Source, Notes
- Notes column: inline editable input, saves on blur via PUT endpoint
- Sync button with spinner, last-synced timestamp, error banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:56:37 -06:00
|
|
|
);
|
2026-03-11 11:47:03 -06:00
|
|
|
}
|