|
| 1 | +import { useMemo, useState } from "react"; |
| 2 | +import { Badge } from "@/components/ui/badge"; |
| 3 | +import { |
| 4 | + Accordion, |
| 5 | + AccordionContent, |
| 6 | + AccordionItem, |
| 7 | + AccordionTrigger, |
| 8 | +} from "@/components/ui/accordion"; |
| 9 | +import { reportData, Test } from "@/config/report-data"; |
| 10 | +import { CheckCircledIcon, CrossCircledIcon, MinusCircledIcon, QuestionMarkCircledIcon } from "@radix-ui/react-icons"; |
| 11 | + |
| 12 | +// ─── Plane spec-ID constants ────────────────────────────────────────────────── |
| 13 | + |
| 14 | +const AVAILABILITY_IDS = ["25533"]; |
| 15 | +const APPGW_WAF_IDS = ["25541", "26879", "26881", "26882", "27015", "27016", "27017"]; |
| 16 | +const FRONTDOOR_IDS = ["25543", "26880", "26883", "26884", "27018", "27019", "27020", "27024"]; |
| 17 | +const OUTBOUND_IDS = ["25535", "25537", "25539", "25550"]; |
| 18 | +const ALL_SPEC_IDS = [...AVAILABILITY_IDS, ...APPGW_WAF_IDS, ...FRONTDOOR_IDS, ...OUTBOUND_IDS]; |
| 19 | + |
| 20 | +// ─── Types ──────────────────────────────────────────────────────────────────── |
| 21 | + |
| 22 | +type PlaneStatus = "pass" | "partial" | "fail" | "na"; |
| 23 | + |
| 24 | +interface GroupResult { |
| 25 | + passed: number; |
| 26 | + total: number; |
| 27 | + status: PlaneStatus; |
| 28 | + tests: Test[]; |
| 29 | +} |
| 30 | + |
| 31 | +// ─── Helper functions ───────────────────────────────────────────────────────── |
| 32 | + |
| 33 | +function calcStatus(passed: number, failed: number, investigate: number, total: number): PlaneStatus { |
| 34 | + if (total === 0) return "na"; |
| 35 | + if (failed > 0) return "fail"; |
| 36 | + if (investigate > 0) return "partial"; |
| 37 | + if (passed === total) return "pass"; |
| 38 | + if (passed === 0) return "na"; |
| 39 | + return "partial"; |
| 40 | +} |
| 41 | + |
| 42 | +function computeGroup(specIds: string[]): GroupResult { |
| 43 | + const tests = reportData.Tests.filter((test) => specIds.includes(String(test.TestId))); |
| 44 | + const passed = tests.filter((test) => test.TestStatus === "Passed").length; |
| 45 | + const failed = tests.filter((test) => test.TestStatus === "Failed" || test.TestStatus === "Error").length; |
| 46 | + const investigate = tests.filter((test) => test.TestStatus === "Investigate").length; |
| 47 | + |
| 48 | + return { passed, total: tests.length, status: calcStatus(passed, failed, investigate, tests.length), tests }; |
| 49 | +} |
| 50 | + |
| 51 | +// ─── Guard for conditional rendering in Dashboard.tsx ───────────────────────── |
| 52 | + |
| 53 | +export function hasAzureNetSecData(): boolean { |
| 54 | + return reportData.Tests.some((test) => ALL_SPEC_IDS.includes(String(test.TestId))); |
| 55 | +} |
| 56 | + |
| 57 | +function getStatusBadgeVariant(status: PlaneStatus): "success" | "destructive" | "warning" | "secondary" { |
| 58 | + switch (status) { |
| 59 | + case "pass": return "success"; |
| 60 | + case "partial": return "warning"; |
| 61 | + case "fail": return "destructive"; |
| 62 | + default: return "secondary"; |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +function getStatusLabel(status: PlaneStatus): string { |
| 67 | + switch (status) { |
| 68 | + case "pass": return "Pass"; |
| 69 | + case "partial": return "Partial"; |
| 70 | + case "fail": return "Fail"; |
| 71 | + default: return "N/A"; |
| 72 | + } |
| 73 | +} |
| 74 | + |
| 75 | +function getStatusFill(status: PlaneStatus): string { |
| 76 | + switch (status) { |
| 77 | + case "pass": return "#22C55E"; |
| 78 | + case "partial": return "#EAB308"; |
| 79 | + case "fail": return "#EF4444"; |
| 80 | + default: return "#9CA3AF"; |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +function getTestStatusIcon(testStatus: string) { |
| 85 | + switch (testStatus) { |
| 86 | + case "Passed": |
| 87 | + return <CheckCircledIcon className="size-4 shrink-0 text-teal-600" />; |
| 88 | + case "Failed": |
| 89 | + case "Error": |
| 90 | + return <CrossCircledIcon className="size-4 shrink-0 text-rose-500" />; |
| 91 | + case "Investigate": |
| 92 | + return <QuestionMarkCircledIcon className="size-4 shrink-0 text-amber-500" />; |
| 93 | + case "Skipped": |
| 94 | + case "Planned": |
| 95 | + case "NotApplicable": |
| 96 | + default: |
| 97 | + return <MinusCircledIcon className="size-4 shrink-0 text-muted-foreground" />; |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +// ─── Main exported component ────────────────────────────────────────────────── |
| 102 | + |
| 103 | +export function AzureNetSecPlanes() { |
| 104 | + const [hoveredPlane, setHoveredPlane] = useState<number | null>(null); |
| 105 | + const { |
| 106 | + av, ag, fd, out, |
| 107 | + inbPassed, inbTotal, inbStatus, |
| 108 | + } = useMemo(() => { |
| 109 | + const av = computeGroup(AVAILABILITY_IDS); |
| 110 | + const ag = computeGroup(APPGW_WAF_IDS); |
| 111 | + const fd = computeGroup(FRONTDOOR_IDS); |
| 112 | + const out = computeGroup(OUTBOUND_IDS); |
| 113 | + |
| 114 | + const inbPassed = ag.passed + fd.passed; |
| 115 | + const inbTotal = ag.total + fd.total; |
| 116 | + const inbFailed = [...ag.tests, ...fd.tests].filter( |
| 117 | + (test) => test.TestStatus === "Failed" || test.TestStatus === "Error" |
| 118 | + ).length; |
| 119 | + const inbInvestigate = [...ag.tests, ...fd.tests].filter( |
| 120 | + (test) => test.TestStatus === "Investigate" |
| 121 | + ).length; |
| 122 | + const inbStatus = calcStatus(inbPassed, inbFailed, inbInvestigate, inbTotal); |
| 123 | + |
| 124 | + return { |
| 125 | + av, ag, fd, out, |
| 126 | + inbPassed, inbTotal, inbStatus, |
| 127 | + }; |
| 128 | + }, []); |
| 129 | + |
| 130 | + const planes = [ |
| 131 | + { id: 1, name: "Availability protection", summary: "Azure DDoS Protection", result: av, tests: av.tests, rx: 190, ry: 190 }, |
| 132 | + { id: 2, name: "Inbound application protection", summary: "Application Gateway WAF and Front Door WAF", result: { passed: inbPassed, total: inbTotal, status: inbStatus }, tests: [...ag.tests, ...fd.tests], rx: 140, ry: 140 }, |
| 133 | + { id: 3, name: "Outbound and east-west protection", summary: "Azure Firewall", result: out, tests: out.tests, rx: 88, ry: 88 }, |
| 134 | + ]; |
| 135 | + const applicablePlanes = planes.filter((plane) => plane.result.status !== "na"); |
| 136 | + const failingPlanes = applicablePlanes.filter((plane) => plane.result.status === "fail" || plane.result.status === "partial"); |
| 137 | + const allPlanesPass = applicablePlanes.length > 0 && applicablePlanes.every((plane) => plane.result.status === "pass"); |
| 138 | + |
| 139 | + return ( |
| 140 | + <div className="flex flex-col gap-4"> |
| 141 | + <div className="flex flex-col items-center gap-6 lg:flex-row lg:items-center"> |
| 142 | + <div className="shrink-0"> |
| 143 | + <svg width="420" height="420" viewBox="0 0 420 420" className="h-auto max-w-full" aria-label="Azure network security defense planes"> |
| 144 | + {planes.map((plane) => { |
| 145 | + const isHovered = hoveredPlane === plane.id; |
| 146 | + const percentage = plane.result.total > 0 ? Math.round((plane.result.passed / plane.result.total) * 100) : 0; |
| 147 | + const labelY = 210 - plane.ry + (plane.id === 1 ? 30 : 25); |
| 148 | + const fill = getStatusFill(plane.result.status); |
| 149 | + |
| 150 | + return ( |
| 151 | + <g key={plane.id} className="cursor-pointer" opacity={hoveredPlane !== null && !isHovered ? 0.65 : 1} onMouseEnter={() => setHoveredPlane(plane.id)} onMouseLeave={() => setHoveredPlane(null)}> |
| 152 | + <ellipse cx="210" cy="210" rx={plane.rx} ry={plane.ry} fill={fill} stroke={isHovered ? "#ffffff" : "rgba(255,255,255,0.35)"} strokeWidth={isHovered ? 3 : 1.5} /> |
| 153 | + <text x="210" y={labelY} textAnchor="middle" fill="#ffffff" fontSize={plane.id === 1 ? 14 : 12} fontWeight="600"> |
| 154 | + {plane.name} |
| 155 | + </text> |
| 156 | + <text x="210" y={labelY + 18} textAnchor="middle" fill="#ffffff" fontSize="12"> |
| 157 | + {plane.result.total === 0 ? "No applicable results" : `${plane.result.passed}/${plane.result.total} passing (${percentage}%)`} |
| 158 | + </text> |
| 159 | + </g> |
| 160 | + ); |
| 161 | + })} |
| 162 | + </svg> |
| 163 | + </div> |
| 164 | + |
| 165 | + <div className="w-full min-w-0 flex-1"> |
| 166 | + <div className="mb-3 flex items-center gap-2"> |
| 167 | + <Badge variant={applicablePlanes.length === 0 ? "secondary" : allPlanesPass ? "success" : "destructive"}> |
| 168 | + {applicablePlanes.length === 0 |
| 169 | + ? "No applicable plane results" |
| 170 | + : allPlanesPass |
| 171 | + ? "All applicable defense planes pass" |
| 172 | + : `${failingPlanes.length} of ${applicablePlanes.length} applicable defense planes have issues`} |
| 173 | + </Badge> |
| 174 | + </div> |
| 175 | + <Accordion type="multiple" className="w-full"> |
| 176 | + {planes.map((plane) => ( |
| 177 | + <AccordionItem key={plane.id} value={`plane-${plane.id}`} className="mb-1 rounded-md border px-3"> |
| 178 | + <AccordionTrigger className="py-3 hover:no-underline"> |
| 179 | + <div className="grid w-full grid-cols-[1rem_minmax(0,1fr)_5rem_2rem] items-center gap-3 text-left"> |
| 180 | + <span className="w-4 shrink-0 text-xs font-mono text-muted-foreground">{plane.id}</span> |
| 181 | + <span className="min-w-0 text-sm font-medium">{plane.name}</span> |
| 182 | + <Badge variant={getStatusBadgeVariant(plane.result.status)} className="w-16 justify-center justify-self-end">{getStatusLabel(plane.result.status)}</Badge> |
| 183 | + <span className="text-right text-xs tabular-nums text-muted-foreground">{plane.result.total === 0 ? "N/A" : `${plane.result.passed}/${plane.result.total}`}</span> |
| 184 | + </div> |
| 185 | + </AccordionTrigger> |
| 186 | + <AccordionContent> |
| 187 | + <p className="mb-2 text-xs text-muted-foreground">{plane.summary}</p> |
| 188 | + {plane.tests.length === 0 ? ( |
| 189 | + <p className="py-1 text-xs italic text-muted-foreground">No applicable test results were included in the report.</p> |
| 190 | + ) : ( |
| 191 | + <div className="space-y-1 py-1"> |
| 192 | + {plane.tests.map((test) => ( |
| 193 | + <div key={test.TestId} className="flex items-center gap-2 py-1 text-xs"> |
| 194 | + {getTestStatusIcon(test.TestStatus)} |
| 195 | + <span className="font-mono text-muted-foreground">{test.TestId}</span> |
| 196 | + <span className="flex-1">{test.TestTitle}</span> |
| 197 | + <span className="shrink-0 text-muted-foreground">{test.TestStatus}</span> |
| 198 | + </div> |
| 199 | + ))} |
| 200 | + </div> |
| 201 | + )} |
| 202 | + </AccordionContent> |
| 203 | + </AccordionItem> |
| 204 | + ))} |
| 205 | + </Accordion> |
| 206 | + </div> |
| 207 | + </div> |
| 208 | + </div> |
| 209 | + ); |
| 210 | +} |
0 commit comments