From 5e04acab3e1e132d21964481d65bb66056a46014 Mon Sep 17 00:00:00 2001 From: Brook4747 <109784182+Brook4747@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:07:48 +1000 Subject: [PATCH] Fix compliance dashboard chart visualisation --- frontend/src/components/ComplianceChart.tsx | 492 +++----------------- frontend/src/pages/Dashboard.tsx | 318 ++++++++----- 2 files changed, 275 insertions(+), 535 deletions(-) diff --git a/frontend/src/components/ComplianceChart.tsx b/frontend/src/components/ComplianceChart.tsx index af1f4b610..f50c5fae3 100644 --- a/frontend/src/components/ComplianceChart.tsx +++ b/frontend/src/components/ComplianceChart.tsx @@ -1,440 +1,76 @@ -import React, { useState, useEffect, useCallback, JSX } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { - ArrowLeft, - CheckCircle, - XCircle, - Clock, - Loader2, - AlertCircle, - FileText, - Shield, - AlertTriangle, -} from 'lucide-react'; -import { useAuth } from '../context/AuthContext'; -import { getScan } from '../api/client'; -import { formatDateAEST, formatTimeAEST } from '../utils/helpers'; +import Chart from "react-apexcharts"; +import type { ApexOptions } from "apexcharts"; -type ScanDetailPageProps = { - sidebarWidth?: number; - isDarkMode?: boolean; -} - -type ScanResult = { - control_id?: string | number; - status?: string; - title?: string; - description?: string; - message?: string; -} - -type ScanDetail = { - id?: number | string; - status?: string; - benchmark?: string; - version?: string; - connection_name?: string; - m365_connection_id?: number | string; - started_at?: string | null; - created_at?: string | null; - finished_at?: string | null; - completed_at?: string | null; - total_controls?: number; - passed_count?: number; - failed_count?: number; - error_count?: number; - skipped_count?: number; - results?: ScanResult[]; - error?: string; -} - -function getErrorMessage(err: unknown, fallback: string): string { - if (err instanceof Error && err.message) return err.message; - if ((err as { message?: string })?.message) return (err as { message: string }).message; - return fallback; -} - -function compareControlIdAscending(a: ScanResult, b: ScanResult): number { - const aId = (a?.control_id ?? '').toString(); - const bId = (b?.control_id ?? '').toString(); - const aParts = aId.split('.').map((s) => Number.parseInt(s, 10)); - const bParts = bId.split('.').map((s) => Number.parseInt(s, 10)); - - const len = Math.max(aParts.length, bParts.length); - for (let i = 0; i < len; i++) { - const av = Number.isFinite(aParts[i]) ? aParts[i] : -1; - const bv = Number.isFinite(bParts[i]) ? bParts[i] : -1; - if (av !== bv) return av - bv; - } - return aId.localeCompare(bId); -} - -const ScanDetailPage: React.FC = ({ sidebarWidth = 220, isDarkMode = true }) => { - const { scanId } = useParams<{ scanId: string }>(); - const navigate = useNavigate(); - const { token } = useAuth(); +type ChartType = "doughnut" | "pie" | "bar"; - const [scan, setScan] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const loadScan = useCallback(async (): Promise => { - if (!scanId) { - setError('Scan ID is missing'); - return null; - } - - try { - const scanData = await getScan(token, scanId); - setScan(scanData as ScanDetail); - setError(null); - return scanData as ScanDetail; - } catch (err: unknown) { - setError(getErrorMessage(err, 'Failed to load scan')); - return null; - } - }, [token, scanId]); - - useEffect(() => { - async function initialLoad(): Promise { - setIsLoading(true); - await loadScan(); - setIsLoading(false); - } - initialLoad(); - }, [loadScan]); - - // Poll for updates every 3 seconds while pending/running - useEffect(() => { - if (!scan || scan.status === 'completed' || scan.status === 'failed') { - return; - } - - const interval = setInterval(async () => { - const updatedScan = await loadScan(); - if (updatedScan?.status === 'completed' || updatedScan?.status === 'failed') { - clearInterval(interval); - } - }, 3000); - - return () => clearInterval(interval); - }, [scan, loadScan]); - - function getStatusIcon(status?: string): JSX.Element { - switch (status) { - case 'completed': - return ; - case 'failed': - return ; - case 'running': - return ; - default: - return ; - } - } - - function getStatusText(status?: string): string { - switch (status) { - case 'completed': - return 'Completed'; - case 'failed': - return 'Failed'; - case 'running': - return 'Running'; - default: - return 'Pending'; - } - } - - function formatDate(dateString?: string | null): string { - return formatDateAEST(dateString); - } - - function formatTime(dateString?: string | null): string { - return formatTimeAEST(dateString); - } - - function getResultIcon(status?: string): JSX.Element { - switch (status) { - case 'passed': - return ; - case 'failed': - return ; - case 'error': - return ; - case 'pending': - return ; - default: - return ; - } - } - - function getResultBadgeText(status?: string): string { - switch (status) { - case 'passed': - return 'Pass'; - case 'failed': - return 'Fail'; - case 'error': - return 'Error'; - case 'pending': - return 'Pending'; - case 'skipped': - return 'Skipped'; - default: - return 'Unknown'; - } - } +type ComplianceChartProps = { + isDarkMode: boolean; + sidebarWidth?: number; + chartType: ChartType; + labels: string[]; + values: number[]; +}; - if (isLoading) { - return ( -
-
-
- -

Loading scan details...

-
-
-
- ); - } +export default function ComplianceChart({ + isDarkMode, + chartType, + labels, + values, +}: ComplianceChartProps) { + const safeLabels = labels.length > 0 ? labels : ["No data"]; + const safeValues = values.length > 0 ? values : [0]; + + if (chartType === "bar") { + const options: ApexOptions = { + chart: { + type: "bar", + background: "transparent", + toolbar: { show: false }, + }, + xaxis: { + categories: safeLabels, + }, + yaxis: { + min: 0, + max: 100, + }, + theme: { + mode: isDarkMode ? "dark" : "light", + }, + }; - if (error || !scan) { return ( -
-
-
- -

Failed to load scan

-

{error || 'Scan not found'}

- -
-
-
+ ); } - // Build summary from scan counts (API returns these directly) - const summary = { - total: scan.total_controls || 0, - passed: scan.passed_count || 0, - failed: scan.failed_count || 0, - errors: scan.error_count || 0, - pending: - (scan.total_controls || 0) - - (scan.passed_count || 0) - - (scan.failed_count || 0) - - (scan.error_count || 0) - - (scan.skipped_count || 0), + const pieType = chartType === "doughnut" ? "donut" : "pie"; + + const options: ApexOptions = { + chart: { + type: pieType, + background: "transparent", + toolbar: { show: false }, + }, + labels: safeLabels, + theme: { + mode: isDarkMode ? "dark" : "light", + }, }; - const done = summary.passed + summary.failed + summary.errors + (scan.skipped_count || 0); - - const progressPercent = - summary.total > 0 - ? Math.min(100, Math.round((done / summary.total) * 100)) - : scan.status === 'completed' - ? 100 - : 0; - - const results = (scan.results || []) - .filter((r) => (r?.status || '').toLowerCase() !== 'skipped') - .slice() - .sort(compareControlIdAscending); - return ( -
-
-
- -
- -
-
-
- -
-
-

{scan.benchmark || 'Compliance Scan'}

-

{scan.version || ''}

-
- - {getStatusIcon(scan.status)} - {getStatusText(scan.status)} - -
- -
-
- Connection - - {scan.connection_name || (scan.m365_connection_id ? `Connection #${scan.m365_connection_id}` : '-')} - -
-
- Started -
-
{formatDate(scan.started_at || scan.created_at)}
-
{formatTime(scan.started_at || scan.created_at)}
-
-
-
- Completed - {scan.finished_at || scan.completed_at ? ( -
-
{formatDate(scan.finished_at || scan.completed_at)}
-
{formatTime(scan.finished_at || scan.completed_at)}
-
- ) : ( -
-
- {scan.status === 'pending' || scan.status === 'running' ? 'In progress' : '-'} -
-
- )} -
-
-
- - {(scan.status === 'pending' || scan.status === 'running') && ( -
-
- -
-

Scan in Progress

-

- {scan.status === 'pending' - ? 'Waiting to start...' - : `Evaluating controls... ${done} of ${summary.total} complete`} -

-
-
- -
-
-
-
-
- {done}/{summary.total} controls - - {progressPercent}% -
-
-
- )} - -
-
-
- -
-
- {summary.total} - Total Controls -
-
-
-
- -
-
- {summary.passed} - Passed -
-
-
-
- -
-
- {summary.failed} - Failed -
-
-
-
- -
-
- {summary.errors} - Errors -
-
-
- - {results.length > 0 && ( -
-

Control Results

-
- {results.map((result, index) => ( -
-
-
- {getResultIcon(result.status)} - {result.control_id} -

{result.title || result.control_id}

-
- - {getResultBadgeText(result.status)} - -
- {result.description && ( -

{result.description}

- )} - {result.message && ( -

{result.message}

- )} -
- ))} -
-
- )} - - {scan.status === 'failed' && scan.error && ( -
- -
-

Scan Failed

-

{scan.error}

-
-
- )} -
-
+ ); -}; - -export default ScanDetailPage; \ No newline at end of file +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 6d88c1272..c544d856b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -13,7 +13,12 @@ import { } from "lucide-react"; import { Loader2, AlertCircle } from "lucide-react"; import { useAuth } from "../context/AuthContext"; -import { getBenchmarks, getConnections, getScans, getScan } from "../api/client"; +import { + getBenchmarks, + getConnections, + getScans, + getScan, +} from "../api/client"; import { formatDateTimePartsAEST } from "../utils/helpers"; type ChartType = "doughnut" | "pie" | "bar"; @@ -88,9 +93,9 @@ export default function Dashboard({ const [connections, setConnections] = useState([]); const [benchmarks, setBenchmarksState] = useState([]); - const [scanDetailsById, setScanDetailsById] = useState>( - {} - ); + const [scanDetailsById, setScanDetailsById] = useState< + Record + >({}); const [scanDetailsError, setScanDetailsError] = useState(null); const chartTypeOptions = [ @@ -99,9 +104,12 @@ export default function Dashboard({ { value: "bar", label: "Compliance Trend (Bar)" }, ]; - const [selectedChartType, setSelectedChartType] = useState("doughnut"); - const [selectedConnectionId, setSelectedConnectionId] = useState("all"); - const [selectedBenchmarkKey, setSelectedBenchmarkKey] = useState("all"); + const [selectedChartType, setSelectedChartType] = + useState("doughnut"); + const [selectedConnectionId, setSelectedConnectionId] = + useState("all"); + const [selectedBenchmarkKey, setSelectedBenchmarkKey] = + useState("all"); useEffect(() => { async function loadDashboard() { @@ -115,20 +123,30 @@ export default function Dashboard({ getBenchmarks(token), ]); setScans((scansData as ApiScanSummary[] | null | undefined) || []); - setConnections((connectionsData as ApiConnection[] | null | undefined) || []); - setBenchmarksState((benchmarksData as ApiBenchmark[] | null | undefined) || []); + setConnections( + (connectionsData as ApiConnection[] | null | undefined) || [], + ); + setBenchmarksState( + (benchmarksData as ApiBenchmark[] | null | undefined) || [], + ); // Prefer the latest completed scan as the default context. - const completed = ((scansData as ApiScanSummary[] | null | undefined) || []).filter( - (s) => s.status === "completed" - ); + const completed = ( + (scansData as ApiScanSummary[] | null | undefined) || [] + ).filter((s) => s.status === "completed"); const latestCompleted = completed.length > 0 ? completed[0] : null; // API sorts started_at desc if (latestCompleted) { if (latestCompleted.m365_connection_id) { setSelectedConnectionId(String(latestCompleted.m365_connection_id)); } - if (latestCompleted.framework && latestCompleted.benchmark && latestCompleted.version) { - setSelectedBenchmarkKey(`${latestCompleted.framework}|${latestCompleted.benchmark}|${latestCompleted.version}`); + if ( + latestCompleted.framework && + latestCompleted.benchmark && + latestCompleted.version + ) { + setSelectedBenchmarkKey( + `${latestCompleted.framework}|${latestCompleted.benchmark}|${latestCompleted.version}`, + ); } } } catch (err: unknown) { @@ -143,7 +161,7 @@ export default function Dashboard({ const benchmarkOptions = useMemo(() => { const m365 = (benchmarks || []).filter( - (b) => String(b.platform || "").toLowerCase() === "m365" + (b) => String(b.platform || "").toLowerCase() === "m365", ); const opts = m365.map((b) => ({ value: `${b.framework || ""}|${b.slug || ""}|${b.version || ""}`, @@ -164,12 +182,13 @@ export default function Dashboard({ let out = scans || []; if (selectedConnectionId !== "all") { out = out.filter( - (s) => String(s.m365_connection_id || "") === selectedConnectionId + (s) => String(s.m365_connection_id || "") === selectedConnectionId, ); } if (selectedBenchmarkKey !== "all") { out = out.filter( - (s) => `${s.framework}|${s.benchmark}|${s.version}` === selectedBenchmarkKey + (s) => + `${s.framework}|${s.benchmark}|${s.version}` === selectedBenchmarkKey, ); } return out; @@ -182,7 +201,11 @@ export default function Dashboard({ return filteredScans[0]; }, [filteredScans]); - const chartModel = useMemo<{ chartType: ChartType; labels: string[]; values: number[] }>(() => { + const chartModel = useMemo<{ + chartType: ChartType; + labels: string[]; + values: number[]; + }>(() => { const s = latestRelevantScan; const passed = Number(s?.passed_count || 0); const failed = Number(s?.failed_count || 0); @@ -212,14 +235,14 @@ export default function Dashboard({ } // Default: Pass / Fail (+ optional Error / Skipped) - const labels = ['Pass', 'Fail']; + const labels = ["Pass", "Fail"]; const values = [passed, failed]; if (errors > 0) { - labels.push('Error'); + labels.push("Error"); values.push(errors); } if (skipped > 0) { - labels.push('Skipped'); + labels.push("Skipped"); values.push(skipped); } return { chartType: selectedChartType, labels, values }; @@ -245,7 +268,9 @@ export default function Dashboard({ const detail = (await getScan(token, id)) as ApiScanDetail; setScanDetailsById((prev) => ({ ...prev, [id]: detail })); } catch (err: unknown) { - setScanDetailsError(getErrorMessage(err) || "Failed to load scan details"); + setScanDetailsError( + getErrorMessage(err) || "Failed to load scan details", + ); } } @@ -269,7 +294,8 @@ export default function Dashboard({ return Number.isFinite(num) ? num.toLocaleString() : "—"; }; - const compliancePct = evaluated > 0 ? Math.round((passed / evaluated) * 100) : null; + const compliancePct = + evaluated > 0 ? Math.round((passed / evaluated) * 100) : null; const complianceTone = hasScan ? "good" : "neutral"; const failedTone = failed > 0 ? "bad" : hasTotal ? "good" : "neutral"; @@ -277,82 +303,109 @@ export default function Dashboard({ s?.connection_name || (s?.m365_connection_id ? `Connection #${s.m365_connection_id}` : "—"); const isCompleted = String(s?.status || "").toLowerCase() === "completed"; - const lastScanLabel = (isCompleted ? (s?.finished_at || s?.started_at) : (s?.started_at || s?.finished_at)) || null; - const dt = lastScanLabel ? formatDateTimePartsAEST(lastScanLabel) : { date: '-', time: '-' }; - const lastTime = dt.time !== '-' ? dt.time : '—'; - const lastDate = dt.date !== '-' ? dt.date : '—'; + const lastScanLabel = + (isCompleted + ? s?.finished_at || s?.started_at + : s?.started_at || s?.finished_at) || null; + const dt = lastScanLabel + ? formatDateTimePartsAEST(lastScanLabel) + : { date: "-", time: "-" }; + const lastTime = dt.time !== "-" ? dt.time : "—"; + const lastDate = dt.date !== "-" ? dt.date : "—"; const subtitle = !hasScan - ? 'No scans yet' - : `${isCompleted ? 'Latest completed scan' : 'Latest scan'}${hasTotal ? ` • ${formatCount(evaluated)} evaluated` : ''}`; + ? "No scans yet" + : `${isCompleted ? "Latest completed scan" : "Latest scan"}${hasTotal ? ` • ${formatCount(evaluated)} evaluated` : ""}`; const kpis = [ { - label: compliancePct === null ? 'Compliance —' : `Compliance ${compliancePct}%`, + label: + compliancePct === null + ? "Compliance —" + : `Compliance ${compliancePct}%`, tone: complianceTone, icon: CheckCircle2, }, { - label: hasTotal ? `${formatCount(failed)} failed` : 'Failed —', + label: hasTotal ? `${formatCount(failed)} failed` : "Failed —", tone: failedTone, icon: AlertTriangle, }, { - label: hasTotal ? `${formatCount(total)} total` : 'Total —', - tone: 'neutral', + label: hasTotal ? `${formatCount(total)} total` : "Total —", + tone: "neutral", icon: Shield, }, { label: `Updated ${lastTime}`, - tone: 'neutral', + tone: "neutral", icon: Clock3, }, ]; const groups = [ { - title: 'Evaluation', + title: "Evaluation", items: [ - { label: 'Evaluated', value: hasTotal ? `${formatCount(evaluated)} of ${formatCount(total)}` : '—' }, - { label: 'Passed', value: hasTotal ? formatCount(passed) : '—' }, - { label: 'Failed', value: hasTotal ? formatCount(failed) : '—' }, + { + label: "Evaluated", + value: hasTotal + ? `${formatCount(evaluated)} of ${formatCount(total)}` + : "—", + }, + { label: "Passed", value: hasTotal ? formatCount(passed) : "—" }, + { label: "Failed", value: hasTotal ? formatCount(failed) : "—" }, ], }, { - title: 'Quality', + title: "Quality", items: [ - { label: 'Errors', value: hasTotal ? formatCount(errors) : '—' }, - { label: 'Skipped', value: hasTotal ? formatCount(skipped) : '—' }, - { label: 'Pending', value: hasTotal ? formatCount(pending) : '—' }, + { label: "Errors", value: hasTotal ? formatCount(errors) : "—" }, + { label: "Skipped", value: hasTotal ? formatCount(skipped) : "—" }, + { label: "Pending", value: hasTotal ? formatCount(pending) : "—" }, ], }, { - title: 'Context', + title: "Context", items: [ - { label: 'Connection', value: hasScan ? connectionLabel : '—' }, - { label: 'Date', value: hasScan ? lastDate : '—' }, + { label: "Connection", value: hasScan ? connectionLabel : "—" }, + { label: "Date", value: hasScan ? lastDate : "—" }, ], }, - ].map(group => ({ - ...group, - items: group.items.filter(item => item.value !== '—'), - })).filter(group => group.items.length > 0); + ] + .map((group) => ({ + ...group, + items: group.items.filter((item) => item.value !== "—"), + })) + .filter((group) => group.items.length > 0); return { subtitle, kpis, groups }; }, [latestRelevantScan]); const nextFixes = useMemo(() => { const results = latestScanDetails?.results || []; - const failed = results.filter((r) => (r?.status || "").toLowerCase() === "failed"); - const errors = results.filter((r) => (r?.status || "").toLowerCase() === "error"); + const failed = results.filter( + (r) => (r?.status || "").toLowerCase() === "failed", + ); + const errors = results.filter( + (r) => (r?.status || "").toLowerCase() === "error", + ); const byControlId = (a: ApiScanResultItem, b: ApiScanResultItem) => - String(a?.control_id || "").localeCompare(String(b?.control_id || ""), undefined, { - numeric: true, - }); + String(a?.control_id || "").localeCompare( + String(b?.control_id || ""), + undefined, + { + numeric: true, + }, + ); return { failedCount: failed.length, errorCount: errors.length, - topItems: failed.slice().sort(byControlId).slice(0, 6).concat(errors.slice().sort(byControlId).slice(0, 2)), + topItems: failed + .slice() + .sort(byControlId) + .slice(0, 6) + .concat(errors.slice().sort(byControlId).slice(0, 2)), }; }, [latestScanDetails]); @@ -361,25 +414,28 @@ export default function Dashboard({ }, [filteredScans]); function statusTone(status: unknown) { - switch (String(status || '').toLowerCase()) { - case 'completed': - return 'success'; - case 'failed': - return 'error'; - case 'running': - return 'running'; + switch (String(status || "").toLowerCase()) { + case "completed": + return "success"; + case "failed": + return "error"; + case "running": + return "running"; default: - return 'pending'; + return "pending"; } } const handleRunNewScan = () => { const preselect = { m365_connection_id: - selectedConnectionId !== "all" ? Number(selectedConnectionId) : undefined, - benchmark_key: selectedBenchmarkKey !== "all" ? selectedBenchmarkKey : undefined, + selectedConnectionId !== "all" + ? Number(selectedConnectionId) + : undefined, + benchmark_key: + selectedBenchmarkKey !== "all" ? selectedBenchmarkKey : undefined, }; - navigate('/scans', { state: { openNewScan: true, preselect } }); + navigate("/scans", { state: { openNewScan: true, preselect } }); }; const handleExportReport = () => { @@ -392,20 +448,23 @@ export default function Dashboard({ }; return ( -
+
- AutoAudit LogoMicrosoft 365 Compliance Platform

- +
- + - +
@@ -451,15 +516,25 @@ export default function Dashboard({ isDarkMode={isDarkMode} />
- +
- - -
@@ -499,11 +574,11 @@ export default function Dashboard({
{summary.groups.length > 0 && (
- {summary.groups.map(group => ( + {summary.groups.map((group) => (
{group.title}
- {group.items.map(item => ( + {group.items.map((item) => (
{item.label} {item.value} @@ -524,17 +599,20 @@ export default function Dashboard({

Scan Results

- setSelectedChartType(value as ChartType)} - options={chartTypeOptions} - isDarkMode={isDarkMode} - /> + setSelectedChartType(value as ChartType)} + options={chartTypeOptions} + isDarkMode={isDarkMode} + />
@@ -545,7 +623,10 @@ export default function Dashboard({

Recent Scans

Latest activity for your selected connection/benchmark

-
@@ -553,7 +634,10 @@ export default function Dashboard({ {recentScans.length === 0 ? (

No scans found for the current filters.

-
@@ -569,16 +653,20 @@ export default function Dashboard({ - {recentScans.map(s => { - const dt = formatDateTimePartsAEST(s.started_at || s.finished_at); + {recentScans.map((s) => { + const dt = formatDateTimePartsAEST( + s.started_at || s.finished_at, + ); const passed = Number(s.passed_count || 0); const failed = Number(s.failed_count || 0); const errors = Number(s.error_count || 0); return ( - - {String(s.status || 'pending').toUpperCase()} + + {String(s.status || "pending").toUpperCase()} @@ -591,11 +679,19 @@ export default function Dashboard({
{passed} pass {failed} fail - {errors > 0 && {errors} err} + {errors > 0 && ( + + {errors} err + + )}
- @@ -619,10 +715,15 @@ export default function Dashboard({

What you should change next

- {latestRelevantScan ? Number(latestRelevantScan.failed_count || 0) + Number(latestRelevantScan.error_count || 0) : '—'} + {latestRelevantScan + ? Number(latestRelevantScan.failed_count || 0) + + Number(latestRelevantScan.error_count || 0) + : "—"} -

Top failing controls from the latest scan

+

+ Top failing controls from the latest scan +

{scanDetailsError ? (

{scanDetailsError}

@@ -645,18 +746,21 @@ export default function Dashboard({ ))}
)} - -