274 lines
24 KiB
TypeScript
274 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||
import { inspectionToMarkdown } from "@/lib/inspect/report";
|
||
import { analyzeHeaders, type HeaderMap } from "@/lib/inspect/headers";
|
||
import { statusForHttp } from "@/lib/inspect/http-status";
|
||
import type { Finding, InspectionResult } from "@/lib/inspect/types";
|
||
import { ReportActions } from "./components/report/report-actions";
|
||
import { DnsEvidence, HttpEvidence, TlsEvidence } from "./components/report/evidence-views";
|
||
import {
|
||
CloseIcon,
|
||
Gauge,
|
||
type FindingFilter,
|
||
type Tab,
|
||
collectorLabel,
|
||
collectorMeta,
|
||
compact,
|
||
downloadText,
|
||
finiteNumber,
|
||
formatMxRecords,
|
||
findingIcon,
|
||
formatInspectedAt,
|
||
isRecord,
|
||
severityLabel,
|
||
tabs,
|
||
terminalHttpsResult,
|
||
toneForScore,
|
||
} from "./components/report/report-utils";
|
||
|
||
function headerRows(http: Record<string, unknown>) {
|
||
const terminal = terminalHttpsResult(http);
|
||
return terminal && isRecord(terminal.headers) ? terminal.headers as HeaderMap : null;
|
||
}
|
||
|
||
export function ReportView({ result, source, isLoading = false, requestedHost, requestError, onReinspect, onInspectAnother }: {
|
||
result: InspectionResult;
|
||
source: "demo" | "live";
|
||
isLoading?: boolean;
|
||
requestedHost: string | null;
|
||
requestError: { requestedHost: string; message: string } | null;
|
||
onReinspect: (host: string) => void;
|
||
onInspectAnother: (host: string) => void;
|
||
}) {
|
||
const isDemo = source === "demo";
|
||
const [tab, setTab] = useState<Tab>("overview");
|
||
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(null);
|
||
const [showAllFindings, setShowAllFindings] = useState(false);
|
||
const [showAllAddresses, setShowAllAddresses] = useState(false);
|
||
const [showAllIssues, setShowAllIssues] = useState(false);
|
||
const [filter, setFilter] = useState<FindingFilter>("all");
|
||
const [wrapRaw, setWrapRaw] = useState(false);
|
||
const [highlightedEvidence, setHighlightedEvidence] = useState<string | null>(null);
|
||
const [inspectAnotherOpen, setInspectAnotherOpen] = useState(false);
|
||
const [nextHost, setNextHost] = useState("");
|
||
const [statusMessage, setStatusMessage] = useState("");
|
||
const [manualCopy, setManualCopy] = useState<{ label: string; value: string } | null>(null);
|
||
const manualCopyRef = useRef<HTMLTextAreaElement | null>(null);
|
||
const dialogRef = useRef<HTMLElement | null>(null);
|
||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||
const originatingFindingRef = useRef<HTMLButtonElement | null>(null);
|
||
const findings = useMemo(() => {
|
||
const severityRank: Record<Finding["severity"], number> = { critical: 0, warning: 1, info: 2, good: 3 };
|
||
return [...result.findings].sort((left, right) => severityRank[left.severity] - severityRank[right.severity] || left.scoreImpact - right.scoreImpact || left.id.localeCompare(right.id));
|
||
}, [result.findings]);
|
||
const filteredFindings = useMemo(() => filter === "all" ? findings : findings.filter((finding) => finding.severity === filter), [findings, filter]);
|
||
const displayFindings = showAllFindings ? filteredFindings : filteredFindings.slice(0, 4);
|
||
const severityCounts = useMemo(() => (["critical", "warning", "info"] as const)
|
||
.map((severity) => ({ severity, count: findings.filter((finding) => finding.severity === severity).length }))
|
||
.filter(({ count }) => count > 0), [findings]);
|
||
const selectedFinding = findings.find((finding) => finding.id === selectedFindingId) ?? null;
|
||
const dns = result.dns as Record<string, unknown>;
|
||
const tls = result.tls as Record<string, unknown>;
|
||
const http = result.http as Record<string, unknown>;
|
||
const headers = headerRows(http);
|
||
const httpsChain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : [];
|
||
const terminal = terminalHttpsResult(http);
|
||
const dnsCollectorStatus = collectorMeta(dns).status;
|
||
const tlsCollectorStatus = collectorMeta(tls).status;
|
||
const httpCollectorStatus = collectorMeta(http).status;
|
||
const tlsVerified = tls.authorized === true && tls.hostnameValid === true;
|
||
const tlsStatus = tls.error ? "TLS probe incomplete" : tlsVerified ? "Verified certificate" : "Certificate needs attention";
|
||
const httpTerminalStatus = statusForHttp(terminal?.status);
|
||
const httpStatus = httpCollectorStatus === "ok"
|
||
? httpTerminalStatus
|
||
: { label: httpCollectorStatus === "failed" ? "HTTP probe failed" : "HTTP evidence partial", tone: httpCollectorStatus === "failed" ? "red" as const : "muted" as const };
|
||
const scoreTone = (category: "overall" | "dns" | "tls" | "http", value: number) => {
|
||
const complete = category === "overall"
|
||
? result.completeness.status === "complete"
|
||
: ({ dns: dnsCollectorStatus, tls: tlsCollectorStatus, http: httpCollectorStatus }[category] === "ok");
|
||
return complete ? toneForScore(value) : "muted" as const;
|
||
};
|
||
const addresses = Array.isArray(dns.addresses) ? dns.addresses.map((address) => String(address)).filter(Boolean) : [];
|
||
const responses = [http.http, ...httpsChain].filter(isRecord).filter((hop) => finiteNumber(hop.status));
|
||
const redirectCount = responses.filter((hop) => Number(hop.status) >= 300 && Number(hop.status) < 400).length;
|
||
|
||
useEffect(() => {
|
||
if (manualCopy) manualCopyRef.current?.focus();
|
||
}, [manualCopy]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedFinding) return;
|
||
const previousOverflow = document.body.style.overflow;
|
||
const backgroundElements = [...document.querySelectorAll<HTMLElement>(".app-topbar, .cert-main > *:not(.drawer-backdrop)")];
|
||
const previousInert = backgroundElements.map((element) => element.hasAttribute("inert"));
|
||
backgroundElements.forEach((element) => element.setAttribute("inert", ""));
|
||
document.body.style.overflow = "hidden";
|
||
const focusTarget = window.setTimeout(() => closeRef.current?.focus(), 0);
|
||
const dialog = dialogRef.current;
|
||
const closeOnKey = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
setSelectedFindingId(null);
|
||
return;
|
||
}
|
||
if (event.key !== "Tab" || !dialog) return;
|
||
const focusable = [...dialog.querySelectorAll<HTMLElement>("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")].filter((element) => !element.hasAttribute("disabled"));
|
||
if (!focusable.length) return;
|
||
const first = focusable[0];
|
||
const last = focusable[focusable.length - 1];
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
};
|
||
dialog?.addEventListener("keydown", closeOnKey);
|
||
return () => {
|
||
window.clearTimeout(focusTarget);
|
||
dialog?.removeEventListener("keydown", closeOnKey);
|
||
backgroundElements.forEach((element, index) => {
|
||
if (!previousInert[index]) element.removeAttribute("inert");
|
||
});
|
||
document.body.style.overflow = previousOverflow;
|
||
if (originatingFindingRef.current?.isConnected) originatingFindingRef.current.focus();
|
||
else document.getElementById("raw-tab")?.focus();
|
||
};
|
||
}, [selectedFinding]);
|
||
|
||
function announce(message: string) {
|
||
setStatusMessage(message);
|
||
}
|
||
async function copyText(label: string, value: string) {
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
announce(label + " copied");
|
||
return;
|
||
}
|
||
} catch { /* Try the selection-based browser fallback below. */ }
|
||
const field = document.createElement("textarea");
|
||
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||
field.value = value;
|
||
field.readOnly = true;
|
||
field.style.position = "fixed";
|
||
field.style.opacity = "0";
|
||
document.body.appendChild(field);
|
||
field.select();
|
||
let copied = false;
|
||
try { copied = document.execCommand("copy"); } catch { /* Manual copy remains available. */ }
|
||
field.remove();
|
||
previousFocus?.focus();
|
||
if (copied) {
|
||
announce(label + " copied");
|
||
} else {
|
||
setManualCopy({ label, value });
|
||
announce(label + " ready to copy manually");
|
||
}
|
||
}
|
||
async function share() {
|
||
const text = inspectionToMarkdown(result);
|
||
try {
|
||
if (navigator.share) {
|
||
await navigator.share({ title: "CertScope — " + result.host, text });
|
||
announce("Report shared");
|
||
} else await copyText("Report", text);
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === "AbortError") announce("Share cancelled");
|
||
else await copyText("Report", text);
|
||
}
|
||
}
|
||
function exportJson() {
|
||
downloadText(result.host + "-certscope.json", JSON.stringify(result, null, 2), "application/json");
|
||
announce("JSON export downloaded");
|
||
}
|
||
function exportMarkdown() {
|
||
downloadText(result.host + "-certscope.md", inspectionToMarkdown(result), "text/markdown");
|
||
announce("Markdown export downloaded");
|
||
}
|
||
function switchTab(next: Tab) {
|
||
setTab(next);
|
||
}
|
||
function submitAnotherHost(event: FormEvent) {
|
||
event.preventDefault();
|
||
if (!nextHost.trim()) return;
|
||
onInspectAnother(nextHost.trim());
|
||
}
|
||
function handleTabKey(event: React.KeyboardEvent<HTMLButtonElement>, index: number) {
|
||
const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
|
||
if (event.key === "Home") {
|
||
event.preventDefault();
|
||
switchTab(tabs[0]);
|
||
document.getElementById(tabs[0] + "-tab")?.focus();
|
||
return;
|
||
}
|
||
if (event.key === "End") {
|
||
event.preventDefault();
|
||
const last = tabs.at(-1)!;
|
||
switchTab(last);
|
||
document.getElementById(last + "-tab")?.focus();
|
||
return;
|
||
}
|
||
if (!direction) return;
|
||
event.preventDefault();
|
||
const nextIndex = (index + direction + tabs.length) % tabs.length;
|
||
switchTab(tabs[nextIndex]);
|
||
document.getElementById(tabs[nextIndex] + "-tab")?.focus();
|
||
}
|
||
function evidenceTargetFor(finding: Finding) {
|
||
const pointer = finding.evidence.join(" ");
|
||
if (pointer.includes("tls.daysRemaining") || pointer.includes("tls.validTo") || pointer.includes("tls.validFrom")) return { tab: "tls" as const, id: "tls-certificate" };
|
||
if (pointer.includes("tls.")) return { tab: "tls" as const, id: "tls-certificate" };
|
||
if (pointer.includes("dns.dmarc") || pointer.includes("dns.spf")) return { tab: "dns" as const, id: "dns-mail-posture" };
|
||
if (pointer.includes("dns.")) return { tab: "dns" as const, id: "dns-records" };
|
||
if (pointer.includes("headers") || pointer.includes("header")) return { tab: "http" as const, id: "http-header-posture" };
|
||
if (pointer.includes("httpsChain") || pointer.includes("http.http")) return { tab: "http" as const, id: "http-redirect-chain" };
|
||
return { tab: "raw" as const, id: "raw-evidence" };
|
||
}
|
||
function viewEvidence(finding: Finding) {
|
||
const target = evidenceTargetFor(finding);
|
||
setSelectedFindingId(null);
|
||
switchTab(target.tab);
|
||
window.setTimeout(() => {
|
||
const element = document.getElementById(target.id);
|
||
element?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
element?.focus();
|
||
setHighlightedEvidence(target.id);
|
||
window.setTimeout(() => setHighlightedEvidence((current) => current === target.id ? null : current), 2_000);
|
||
}, 0);
|
||
}
|
||
|
||
|
||
return <>
|
||
<section className="report-workspace" aria-label="Inspection report">
|
||
<h1 className="sr-only">CertScope inspection report for {result.host}</h1>
|
||
<header className="report-context-bar"><div className="report-identity"><p>{isDemo ? "Deterministic demo report" : "Live inspection report"}</p><h2>{result.host}</h2></div><dl className="report-context-meta"><div><dt>Coverage</dt><dd><span className={"status-badge " + result.completeness.status}>{result.completeness.status === "complete" ? "Complete" : result.completeness.status === "partial" ? "Partial" : "Incomplete"}</span> {result.completeness.complete}/{result.completeness.total} collectors</dd></div><div><dt>Addresses</dt><dd className="address-list">{addresses.length ? <>{(showAllAddresses ? addresses : addresses.slice(0, 4)).join(" · ")}{addresses.length > 4 && <button type="button" className="inline-expand" onClick={() => setShowAllAddresses((show) => !show)}>{showAllAddresses ? "Show fewer addresses" : `+${addresses.length - 4} more`}</button>}</> : "Unavailable"}</dd></div><div><dt>Last inspected</dt><dd title={result.inspectedAt}>{formatInspectedAt(result.inspectedAt)}</dd></div></dl>{inspectAnotherOpen && <form className="workspace-host-form" onSubmit={submitAnotherHost}><label htmlFor="workspace-hostname">Another public hostname</label><input id="workspace-hostname" value={nextHost} onChange={(event) => setNextHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} /><button type="submit" disabled={isLoading || !nextHost.trim()}>{isLoading ? "Inspecting…" : "Inspect"}</button></form>}</header>
|
||
<div className="score-row">
|
||
{(() => { const tone = scoreTone("overall", result.score.overall); return <div className={"metric-card overall " + tone}><div><span>Overall posture</span><strong>{result.score.overall}<small>/ 100</small></strong><em>{result.completeness.status === "complete" ? `(DNS ${result.score.categories.dns} + TLS ${result.score.categories.tls} + HTTP ${result.score.categories.http}) ÷ 3 = ${result.score.overall} (rounded)` : "Review incomplete evidence before acting"}</em></div><Gauge value={result.score.overall} tone={tone} /></div>; })()}
|
||
<div className="category-score-grid">{[["DNS", result.score.categories.dns, result.completeness.collectors.dns === "ok" ? "DNS evidence complete" : "DNS evidence partial"], ["TLS", result.score.categories.tls, tlsStatus], ["HTTP", result.score.categories.http, httpStatus.label]].map(([label, value, description]) => { const tone = scoreTone(String(label).toLowerCase() as "dns" | "tls" | "http", Number(value)); return <div className={"metric-card " + String(label).toLowerCase() + " " + tone} key={String(label)}><div><span>{String(label)}</span><strong>{Number(value)}<small>/100</small></strong><em>{String(description)}</em></div><Gauge value={Number(value)} tone={tone} /></div>; })}</div>
|
||
</div>
|
||
|
||
<div className="report-status-row"><span className={"status-badge " + result.completeness.status}>{result.completeness.status === "complete" ? "Complete inspection" : result.completeness.status === "partial" ? "Partial inspection" : "Inspection incomplete"}</span><span>{result.completeness.complete}/{result.completeness.total} collectors complete</span><span className="provenance-badge">{isDemo ? "Deterministic demo" : "Live inspection"}</span>{isLoading && requestedHost && <span role="status">{requestedHost === result.host ? "Re-inspecting" : "Inspecting"} {requestedHost}…</span>}</div>
|
||
{requestError && <div className="error-banner previous-report-banner" role="alert"><strong>Previous report remains displayed</strong><span>{requestError.message}</span><small>The {isDemo ? "deterministic demo" : "live"} report for {result.host} is still available while {requestError.requestedHost} could not be inspected.</small></div>}
|
||
{result.completeness.issues.length > 0 && <p className={"completeness-note " + result.completeness.status}><strong>Inspection incomplete</strong>{(showAllIssues ? result.completeness.issues : result.completeness.issues.slice(0, 3)).join(" · ")}{result.completeness.issues.length > 3 && <button type="button" className="inline-expand" onClick={() => setShowAllIssues((show) => !show)}>{showAllIssues ? "Show fewer issues" : `+${result.completeness.issues.length - 3} more`}</button>} Remaining evidence is still valid.</p>}
|
||
|
||
<div className="report-tabs-bar"><nav aria-label="Report sections" role="tablist">{tabs.map((item, index) => <button type="button" role="tab" aria-selected={tab === item} aria-controls={"panel-" + item} id={item + "-tab"} className={tab === item ? "active" : ""} key={item} onClick={() => switchTab(item)} onKeyDown={(event) => handleTabKey(event, index)}>{item === "raw" ? "Raw" : item.toUpperCase()}</button>)}</nav><ReportActions loading={isLoading} onReinspect={() => { announce("Re-inspection started"); onReinspect(result.host); }} onInspectAnother={() => setInspectAnotherOpen((open) => !open)} onShare={() => void share()} onExportJson={exportJson} onExportMarkdown={exportMarkdown} /></div>
|
||
<p className="action-status" role="status" aria-live="polite">{statusMessage}</p>
|
||
{manualCopy && <div className="manual-copy-panel" role="region" aria-label={`Copy ${manualCopy.label} manually`}><div><strong>{manualCopy.label} ready to copy</strong><button type="button" onClick={() => setManualCopy(null)}>Close</button></div><p>Use Ctrl+C (or Copy on your device) to copy the selected text.</p><textarea ref={manualCopyRef} readOnly value={manualCopy.value} onFocus={(event) => event.currentTarget.select()} aria-label={`${manualCopy.label} text`} /></div>}
|
||
|
||
{tab === "overview" && <div id="panel-overview" role="tabpanel" aria-labelledby="overview-tab">
|
||
<section className="findings-panel" aria-labelledby="findings-title"><header><div><h2 id="findings-title">Findings</h2><span>{findings.length} total</span><p className="severity-counts" aria-label="Finding severity counts">{severityCounts.map(({ severity, count }) => <span className={"pill " + severity} key={severity}>{count} {severityLabel(severity).toLowerCase()}{count === 1 ? "" : "s"}</span>)}</p></div><button type="button" onClick={() => switchTab("raw")}>Open raw report →</button></header><div className="finding-filters" aria-label="Filter findings">{(["all", "critical", "warning", "info"] as const).map((item) => <button type="button" key={item} className={filter === item ? "active" : ""} aria-pressed={filter === item} onClick={() => { setFilter(item); setShowAllFindings(true); }}>{item === "all" ? "All" : severityLabel(item)}</button>)}</div>{displayFindings.length ? <div className="findings-grid">{displayFindings.map((finding) => <button type="button" className={"finding-card " + finding.severity} key={finding.id} onClick={(event) => { originatingFindingRef.current = event.currentTarget; setSelectedFindingId(finding.id); }} aria-label={"Open diagnostic details for " + finding.title}><div className={"finding-icon " + finding.severity}>{findingIcon(finding.severity)}</div><div className="finding-body"><div className="finding-title"><strong>{finding.title}</strong><span className={"pill " + finding.severity}>{severityLabel(finding.severity)}</span><b className="finding-impact">{finding.scoreImpact}</b></div><p>{finding.summary}</p><small>{finding.category.toUpperCase()} · View diagnostic and remediation</small></div><span className="finding-chevron" aria-hidden="true">›</span></button>)}</div> : <p className="empty-findings">No findings match this filter.</p>}{filteredFindings.length > 4 && <button type="button" className="show-all-button" onClick={() => setShowAllFindings((show) => !show)}>{showAllFindings ? "Show fewer findings" : "Show all " + filteredFindings.length + " findings"}</button>}</section>
|
||
<section className="detail-grid" aria-label="Evidence summary"><article className="detail-card dns-card"><header><h3>DNS summary</h3><span className="status-badge">{collectorLabel(collectorMeta(dns).status)}</span></header><dl className="summary-list">{[["A", dns.a], ["AAAA", dns.aaaa], ["MX", dns.mx], ["NS", dns.ns], ["SPF", dns.spf], ["DMARC", dns.dmarc]].map(([key, value]) => <div className="summary-row" key={String(key)}><dt>{String(key)}</dt><dd>{key === "MX" ? formatMxRecords(value) : compact(value)}</dd></div>)}</dl><button type="button" className="card-link" onClick={() => switchTab("dns")}>View DNS evidence →</button></article><article className="detail-card tls-card"><header><h3>TLS summary</h3><span className={"status-badge " + (tlsVerified ? "complete" : "partial")}>{tlsStatus}</span></header><dl className="summary-list"><div><dt>Protocol</dt><dd>{compact(tls.protocol)}</dd></div><div><dt>Endpoint</dt><dd>{compact(tls.address)}</dd></div><div><dt>Hostname match</dt><dd>{tls.hostnameValid === true ? "Yes" : tls.hostnameValid === false ? "No" : "Unavailable"}</dd></div><div><dt>Days remaining</dt><dd>{finiteNumber(tls.daysRemaining) ? tls.daysRemaining : "Unavailable"}</dd></div></dl><button type="button" className="card-link" onClick={() => switchTab("tls")}>View TLS evidence →</button></article><article className="detail-card http-card"><header><h3>HTTP summary</h3><span className={"status-badge " + httpStatus.tone}>{httpStatus.label}</span></header><dl className="summary-list"><div><dt>HTTPS endpoint</dt><dd>{compact(http.finalUrl)}</dd></div><div><dt>Redirects</dt><dd>{redirectCount} of {responses.length} responses</dd></div><div><dt>HSTS</dt><dd>{headers ? analyzeHeaders(headers).hsts.status : "Unavailable"}</dd></div><div><dt>Content-Security-Policy</dt><dd>{headers ? analyzeHeaders(headers).csp.status : "Unavailable"}</dd></div></dl><button type="button" className="card-link" onClick={() => switchTab("http")}>View HTTP evidence →</button></article></section>
|
||
</div>}
|
||
{tab === "dns" && <section id="panel-dns" role="tabpanel" aria-labelledby="dns-tab" className="single-detail"><header><div><h2>DNS evidence</h2><p>Structured record data and collector health.</p></div></header><DnsEvidence dns={dns} highlightedEvidence={highlightedEvidence} onCopy={copyText} /></section>}
|
||
{tab === "tls" && <section id="panel-tls" role="tabpanel" aria-labelledby="tls-tab" className="single-detail"><header><div><h2>TLS evidence</h2><p>Certificate identity, validity and validated connection attempts.</p></div></header><TlsEvidence tls={tls} highlightedEvidence={highlightedEvidence} /></section>}
|
||
{tab === "http" && <section id="panel-http" role="tabpanel" aria-labelledby="http-tab" className="single-detail"><header><div><h2>HTTP evidence</h2><p>Redirect behavior, response semantics and parsed security headers.</p></div></header><HttpEvidence http={http} highlightedEvidence={highlightedEvidence} /></section>}
|
||
{tab === "raw" && <section id="panel-raw" role="tabpanel" aria-labelledby="raw-tab" className="single-detail"><header><div><h2>Raw normalized report</h2><p>Complete JSON representation used by the diagnostics and exports.</p></div><div className="raw-actions"><button type="button" onClick={() => void copyText("Raw JSON", JSON.stringify(result, null, 2))}>Copy JSON</button><button type="button" onClick={exportJson}>Download JSON</button><button type="button" aria-pressed={wrapRaw} onClick={() => setWrapRaw((wrap) => !wrap)}>{wrapRaw ? "Disable line wrap" : "Wrap lines"}</button></div></header><pre id="raw-evidence" tabIndex={-1} className={(wrapRaw ? "wrap " : "") + (highlightedEvidence === "raw-evidence" ? "evidence-highlight" : "")}>{JSON.stringify(result, null, 2)}</pre></section>}
|
||
</section>
|
||
|
||
{selectedFinding && <div className="drawer-backdrop" role="presentation" onMouseDown={() => setSelectedFindingId(null)}><aside ref={dialogRef} className="diagnostic-drawer" role="dialog" aria-modal="true" aria-labelledby="diagnostic-title" aria-describedby="diagnostic-summary" tabIndex={-1} onMouseDown={(event) => event.stopPropagation()}><header><div><p className="drawer-kicker">Diagnostic detail</p><h2 id="diagnostic-title">{selectedFinding.title}</h2></div><button ref={closeRef} type="button" className="drawer-close" aria-label="Close diagnostic details" onClick={() => setSelectedFindingId(null)}><CloseIcon /></button></header><span className={"pill " + selectedFinding.severity}>{severityLabel(selectedFinding.severity)}</span><p id="diagnostic-summary" className="drawer-summary">{selectedFinding.summary}</p><dl className="drawer-facts"><div><dt>Category</dt><dd>{selectedFinding.category.toUpperCase()}</dd></div><div><dt>Score impact</dt><dd>{selectedFinding.scoreImpact > 0 ? "+" : ""}{selectedFinding.scoreImpact}</dd></div></dl><section><h3>Evidence pointers</h3><ul className="evidence-pointers">{selectedFinding.evidence.length ? selectedFinding.evidence.map((pointer) => <li key={pointer}><code>{pointer}</code></li>) : <li>No evidence pointer was recorded.</li>}</ul><p className="drawer-note">View evidence opens the structured location when a stable pointer is available, otherwise Raw.</p></section><section><h3>Recommended action</h3><div className="remedy-box">{selectedFinding.remediation ?? "No action required."}</div></section><div className="drawer-actions"><button type="button" className="drawer-evidence-button" onClick={() => viewEvidence(selectedFinding)}>View evidence</button><button type="button" className="drawer-raw-button" onClick={() => { switchTab("raw"); setSelectedFindingId(null); }}>Open raw evidence</button></div></aside></div>}
|
||
</>;
|
||
}
|