45 lines
2.8 KiB
TypeScript
45 lines
2.8 KiB
TypeScript
import { useId, useMemo } from 'react';
|
|
import { useApp } from '../../app/AppProvider';
|
|
import type { AssessmentContextResponse } from '../../api/assessor-api-contract';
|
|
import { contextHeatmap, contextHeatmapFindingCount, contextSeverityLabels } from '../../assessment-context/context-heatmap';
|
|
import { useAssessmentContext } from './use-assessment-context';
|
|
import './context-heatmap.css';
|
|
|
|
/** Existing scoped Context request is shared with Work and the Finding Reader. */
|
|
export function ClientContextHeatmap({ onOpenCluster }: { onOpenCluster(clusterId: string): void }) {
|
|
const app = useApp();
|
|
const state = useAssessmentContext(Boolean(app.dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, app.dataset);
|
|
if (state.status === 'off' || state.status === 'unavailable') return null;
|
|
if (state.status === 'loading') return <section className="context-heatmap" aria-label="Context heatmap" aria-busy="true">
|
|
<h2>Context heatmap</h2><p role="status">Loading Context…</p>
|
|
</section>;
|
|
if (state.status === 'error') return <section className="context-heatmap" aria-label="Context heatmap">
|
|
<h2>Context heatmap</h2><p role="alert">Context could not be loaded. Reopen the client to retry.</p>
|
|
</section>;
|
|
return <ContextHeatmap context={state.context} onOpenCluster={onOpenCluster} />;
|
|
}
|
|
|
|
export function ContextHeatmap({ context, onOpenCluster }: { context: AssessmentContextResponse; onOpenCluster(clusterId: string): void }) {
|
|
const id = useId();
|
|
const cells = useMemo(() => contextHeatmap(context), [context]);
|
|
const findingCount = useMemo(() => contextHeatmapFindingCount(context), [context]);
|
|
|
|
return <section className="context-heatmap" aria-labelledby={`${id}-title`}>
|
|
<header className="context-heatmap__header">
|
|
<h2 id={`${id}-title`}>Context heatmap</h2>
|
|
<span className="context-heatmap__total" aria-label="Unique findings represented by the heatmap">Findings: {findingCount.toLocaleString('en-US')}</span>
|
|
</header>
|
|
<div className="context-heatmap__grid" aria-label="Context clusters">
|
|
{cells.map(({ cluster: item, reference, hasEvidence, scannerDeviations, gpoDeviations }) => <button
|
|
key={item.clusterId} type="button" className="context-heatmap__cell"
|
|
data-cluster={item.clusterId} data-severity={hasEvidence ? reference.severity : 'neutral'}
|
|
aria-label={`Open ${item.label.en} findings in Work (${scannerDeviations + gpoDeviations})`}
|
|
onClick={() => onOpenCluster(item.clusterId)}>
|
|
<span className="context-heatmap__category">Base Severity · {contextSeverityLabels[reference.severity]}</span>
|
|
<strong>{item.label.en}</strong>
|
|
<span className="context-heatmap__amount">{(scannerDeviations + gpoDeviations).toLocaleString('en-US')} {scannerDeviations + gpoDeviations === 1 ? 'finding' : 'findings'}</span>
|
|
</button>)}
|
|
</div>
|
|
</section>;
|
|
}
|