From 4676279a72e4ca0cfcf8b973fa4f7fe321f4be12 Mon Sep 17 00:00:00 2001 From: jramos Date: Tue, 31 Mar 2026 15:14:51 -0600 Subject: [PATCH] feat(compliance): add AEO compliance frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CompliancePage: team tabs (STEAM/ACCESS-ENG), metric health cards with click-to-filter, device table with Active/Resolved tabs, hostname search, seen-count badges, notes indicator, empty/loading/error states - ComplianceUploadModal: phased flow (idle→upload→preview→commit→done), drag-and-drop xlsx drop zone, diff summary before commit - ComplianceDetailPanel: slide-out panel with failing metrics, surfaced extra fields (CVEs, SLA, OS, Splunk), upload history, notes timeline, per-metric note add with Ctrl+Enter submit - NavDrawer: add Compliance nav item (teal, ShieldCheck icon) - App.js: import and render CompliancePage on compliance route - Fix SQL join bug in compliance route (lu ON upload_id = lu.id) - Fix groupByHostname to use max last_seen across all metric rows --- backend/routes/compliance.js | 9 +- frontend/src/App.js | 2 + frontend/src/components/NavDrawer.js | 11 +- .../components/pages/ComplianceDetailPanel.js | 334 ++++++++++++ .../src/components/pages/CompliancePage.js | 502 ++++++++++++++++++ .../components/pages/ComplianceUploadModal.js | 221 ++++++++ 6 files changed, 1070 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/pages/ComplianceDetailPanel.js create mode 100644 frontend/src/components/pages/CompliancePage.js create mode 100644 frontend/src/components/pages/ComplianceUploadModal.js diff --git a/backend/routes/compliance.js b/backend/routes/compliance.js index 8f02d0c..7299ef3 100644 --- a/backend/routes/compliance.js +++ b/backend/routes/compliance.js @@ -200,9 +200,10 @@ function groupByHostname(rows, noteHostnames) { }); // Use the highest seen_count and earliest first_seen across all metrics if ((row.seen_count || 1) > dev.seen_count) dev.seen_count = row.seen_count; - if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) { + if (row.first_seen && (!dev.first_seen || row.first_seen < dev.first_seen)) dev.first_seen = row.first_seen; - } + if (row.last_seen && (!dev.last_seen || row.last_seen > dev.last_seen)) + dev.last_seen = row.last_seen; } return Object.values(deviceMap); @@ -419,7 +420,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) { ru.report_date AS resolved_on FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id - LEFT JOIN compliance_uploads lu ON ci.upload_id = fu.id + LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id WHERE ci.team = ? AND ci.status = ? ORDER BY ci.hostname, ci.metric_id`, @@ -466,7 +467,7 @@ function createComplianceRouter(db, upload, requireAuth, requireRole) { ru.report_date AS resolved_on FROM compliance_items ci LEFT JOIN compliance_uploads fu ON ci.first_seen_upload_id = fu.id - LEFT JOIN compliance_uploads lu ON ci.upload_id = fu.id + LEFT JOIN compliance_uploads lu ON ci.upload_id = lu.id LEFT JOIN compliance_uploads ru ON ci.resolved_upload_id = ru.id WHERE ci.hostname = ? ORDER BY ci.status DESC, ci.metric_id`, diff --git a/frontend/src/App.js b/frontend/src/App.js index 4d8f369..506691d 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -13,6 +13,7 @@ import CalendarWidget from './components/CalendarWidget'; import ReportingPage from './components/pages/ReportingPage'; import KnowledgeBasePage from './components/pages/KnowledgeBasePage'; import ExportsPage from './components/pages/ExportsPage'; +import CompliancePage from './components/pages/CompliancePage'; import './App.css'; const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; @@ -1043,6 +1044,7 @@ export default function App() { {/* Page content */} {currentPage === 'reporting' && } + {currentPage === 'compliance' && } {currentPage === 'knowledge-base' && } {currentPage === 'exports' && } diff --git a/frontend/src/components/NavDrawer.js b/frontend/src/components/NavDrawer.js index a22c0f9..365976c 100644 --- a/frontend/src/components/NavDrawer.js +++ b/frontend/src/components/NavDrawer.js @@ -1,11 +1,12 @@ import React from 'react'; -import { X, Home, BarChart2, BookOpen, Download } from 'lucide-react'; +import { X, Home, BarChart2, BookOpen, Download, ShieldCheck } from 'lucide-react'; const NAV_ITEMS = [ - { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, - { id: 'reporting', label: 'Reporting', icon: BarChart2,color: '#F59E0B', description: 'Reports & analytics' }, - { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, - { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, + { id: 'home', label: 'Home', icon: Home, color: '#0EA5E9', description: 'Main dashboard' }, + { id: 'reporting', label: 'Reporting', icon: BarChart2, color: '#F59E0B', description: 'Reports & analytics' }, + { id: 'compliance', label: 'Compliance', icon: ShieldCheck, color: '#14B8A6', description: 'AEO posture & metrics' }, + { id: 'knowledge-base', label: 'Knowledge Base', icon: BookOpen, color: '#10B981', description: 'Articles & documentation' }, + { id: 'exports', label: 'Exports', icon: Download, color: '#8B5CF6', description: 'Export data & reports' }, ]; export default function NavDrawer({ isOpen, onClose, currentPage, onNavigate }) { diff --git a/frontend/src/components/pages/ComplianceDetailPanel.js b/frontend/src/components/pages/ComplianceDetailPanel.js new file mode 100644 index 0000000..6d582cd --- /dev/null +++ b/frontend/src/components/pages/ComplianceDetailPanel.js @@ -0,0 +1,334 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { X, MessageSquare, Send, Loader, AlertCircle, Clock, Shield } from 'lucide-react'; + +const API_BASE = process.env.REACT_APP_API_BASE || 'http://localhost:3001/api'; + +const TEAL = '#14B8A6'; + +const CATEGORY_COLORS = { + 'Vulnerability Management': '#EF4444', + 'Access & MFA': '#F59E0B', + 'Logging & Monitoring': '#8B5CF6', + 'End-of-Life OS': '#F97316', + 'Decommissioned Assets': '#64748B', + 'Asset Data Quality': '#64748B', + 'Application Security': '#0EA5E9', + 'Disaster Recovery': TEAL, + 'Endpoint Protection': '#F97316', +}; + +function categoryColor(category) { + return CATEGORY_COLORS[category] || '#94A3B8'; +} + +function MetricChip({ metricId, category, status }) { + const color = status === 'resolved' ? '#64748B' : categoryColor(category); + return ( + + {metricId} + + ); +} + +export default function ComplianceDetailPanel({ hostname, onClose, onNoteAdded }) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [noteText, setNoteText] = useState(''); + const [noteMetric, setNoteMetric] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [noteError, setNoteError] = useState(null); + + const fetchDetail = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`${API_BASE}/compliance/items/${encodeURIComponent(hostname)}`, { credentials: 'include' }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to load device'); + setDetail(data); + + // Default note metric to first active failing metric + const firstActive = (data.metrics || []).find(m => m.status === 'active'); + if (firstActive) setNoteMetric(firstActive.metric_id); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [hostname]); + + useEffect(() => { fetchDetail(); }, [fetchDetail]); + + const handleAddNote = async () => { + if (!noteText.trim() || !noteMetric) return; + setSubmitting(true); + setNoteError(null); + try { + const res = await fetch(`${API_BASE}/compliance/notes`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostname, metric_id: noteMetric, note: noteText.trim() }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to save note'); + setNoteText(''); + await fetchDetail(); + if (onNoteAdded) onNoteAdded(); + } catch (err) { + setNoteError(err.message); + } finally { + setSubmitting(false); + } + }; + + const activeMetrics = detail?.metrics?.filter(m => m.status === 'active') || []; + const resolvedMetrics = detail?.metrics?.filter(m => m.status === 'resolved') || []; + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+
+
+ {hostname} +
+ {detail && ( +
+ {detail.ip_address && ( + {detail.ip_address} + )} + {detail.device_type && ( + · {detail.device_type} + )} + · {detail.team} +
+ )} +
+ +
+
+ + {loading && ( +
+ +
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && detail && ( +
+ + {/* Active failing metrics */} + {activeMetrics.length > 0 && ( +
}> + {activeMetrics.map(m => ( + + ))} +
+ )} + + {/* Resolved metrics */} + {resolvedMetrics.length > 0 && ( +
+ {resolvedMetrics.map(m => ( + + ))} +
+ )} + + {/* Upload history summary */} + {activeMetrics.length > 0 && ( +
}> + {activeMetrics.map(m => ( +
+ +
+ 2 ? '#F59E0B' : '#94A3B8' }}> + {m.seen_count}× seen + + {m.first_seen && since {m.first_seen}} +
+
+ ))} +
+ )} + + {/* Notes */} +
} grow> + {detail.notes.length === 0 && ( +
No notes yet
+ )} + {detail.notes.map(n => ( +
+
+ m.metric_id === n.metric_id)?.category || ''} /> + + {n.created_by && `${n.created_by} · `}{n.created_at?.slice(0, 10)} + +
+
{n.note}
+
+ ))} + + {/* Add note */} +
+ {activeMetrics.length > 1 && ( + + )} +
+