Assessor_2.0/src/features/dashboard/EnvironmentEvidenceTables.tsx

316 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import type { JsonValue } from '../../domain/canonical-model';
import type { EnvironmentSnapshot, ScannerReportSection, ScannerReportTable } from '../../domain/environment-context';
import { environmentEvidenceForGroup, environmentNativeHeading, environmentSectionTitle, environmentSectionProse, environmentSectionReference, environmentTableTitle, type EnvironmentEvidenceGroup } from './environment-result-evidence';
const PAGE_SIZE = 100;
const TABLE_PAGE_SIZES = [10, 25, 100] as const;
const TABLE_RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'] as const;
type TableResizeDirection = typeof TABLE_RESIZE_DIRECTIONS[number];
const TABLE_VIEWPORT_MARGIN = 8;
const TABLE_MIN_WIDTH = 360;
const TABLE_MIN_HEIGHT = 260;
function valueText(value: JsonValue | undefined): string {
if (value === undefined) return '—';
if (value === null) return 'null';
if (typeof value === 'string') return value === '' ? '""' : value;
return JSON.stringify(value);
}
function SourceTableDialog({ table, title, context, onClose, onOpenLink }: {
table: ScannerReportTable; title: string; context: string;
onClose(): void; onOpenLink?: (targetId: string) => void;
}) {
const titleId = useId();
const dialogRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const resizeRef = useRef<{ pointerId: number; direction: TableResizeDirection; startX: number; startY: number;
left: number; top: number; width: number; height: number } | null>(null);
const [geometry, setGeometry] = useState<CSSProperties | null>(null);
const [query, setQuery] = useState('');
const [pageSize, setPageSize] = useState<number>(25);
const [page, setPage] = useState(0);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const filteredRows = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
const indexed = table.rows.map((row, rowIndex) => ({ row, rowIndex }));
return needle ? indexed.filter(({ row }) => row.some(value => valueText(value).toLocaleLowerCase().includes(needle))) : indexed;
}, [query, table.rows]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const start = page * pageSize;
const shownRows = filteredRows.slice(start, start + pageSize);
const startResize = (direction: TableResizeDirection) => (event: ReactPointerEvent<HTMLSpanElement>) => {
const bounds = dialogRef.current?.getBoundingClientRect();
if (!bounds) return;
resizeRef.current = { pointerId: event.pointerId, direction, startX: event.clientX, startY: event.clientY,
left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const moveResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
const resize = resizeRef.current;
if (!resize || resize.pointerId !== event.pointerId) return;
const dx = event.clientX - resize.startX;
const dy = event.clientY - resize.startY;
let { left, top, width, height } = resize;
const minWidth = Math.min(TABLE_MIN_WIDTH, window.innerWidth - TABLE_VIEWPORT_MARGIN * 2);
const minHeight = Math.min(TABLE_MIN_HEIGHT, window.innerHeight - TABLE_VIEWPORT_MARGIN * 2);
if (resize.direction.includes('e')) width = Math.max(minWidth, Math.min(resize.width + dx, window.innerWidth - resize.left - TABLE_VIEWPORT_MARGIN));
if (resize.direction.includes('s')) height = Math.max(minHeight, Math.min(resize.height + dy, window.innerHeight - resize.top - TABLE_VIEWPORT_MARGIN));
if (resize.direction.includes('w')) {
width = Math.max(minWidth, Math.min(resize.width - dx, resize.left + resize.width - TABLE_VIEWPORT_MARGIN));
left = resize.left + resize.width - width;
}
if (resize.direction.includes('n')) {
height = Math.max(minHeight, Math.min(resize.height - dy, resize.top + resize.height - TABLE_VIEWPORT_MARGIN));
top = resize.top + resize.height - height;
}
setGeometry({ left, top, width, height });
};
const stopResize = (event: ReactPointerEvent<HTMLSpanElement>) => {
if (resizeRef.current?.pointerId !== event.pointerId) return;
resizeRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
useEffect(() => {
const onViewportResize = () => {
setGeometry(current => {
if (!current) return current;
const width = Math.min(Number(current.width), window.innerWidth - TABLE_VIEWPORT_MARGIN * 2);
const height = Math.min(Number(current.height), window.innerHeight - TABLE_VIEWPORT_MARGIN * 2);
return { width, height,
left: Math.max(TABLE_VIEWPORT_MARGIN, Math.min(Number(current.left), window.innerWidth - width - TABLE_VIEWPORT_MARGIN)),
top: Math.max(TABLE_VIEWPORT_MARGIN, Math.min(Number(current.top), window.innerHeight - height - TABLE_VIEWPORT_MARGIN)) };
});
};
window.addEventListener('resize', onViewportResize);
return () => window.removeEventListener('resize', onViewportResize);
}, []);
useEffect(() => { searchRef.current?.focus(); }, []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
return;
}
if (event.key === 'Tab' && dialogRef.current) {
const focusable = [...dialogRef.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')];
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
event.stopPropagation();
};
return createPortal(<div className="client-env-table-backdrop" onKeyDown={onKeyDown}
onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section ref={dialogRef} className={`client-env-table-dialog client-env-table-dialog--resizable client-env-evidence${geometry ? ' is-resized' : ''}`}
style={geometry ?? undefined} role="dialog" aria-modal="true" aria-labelledby={titleId}>
<header className="client-env-table-dialog-head">
<div><p>{context}</p><h2 id={titleId}>{title}</h2></div>
<button type="button" aria-label={`Close ${title} table`} onClick={onClose}>×</button>
</header>
<div className="client-env-table-dialog-body">
<div className="client-env-table-toolbar">
<span>{table.rows.length.toLocaleString('en-US')} rows</span>
<input ref={searchRef} type="search" aria-label={`Search ${title} table`} placeholder="Search table"
value={query} onChange={event => { setQuery(event.target.value); setPage(0); }}/>
</div>
{width ? <div className="client-env-evidence-scroll client-env-table-scroll"><table>
<thead><tr>{Array.from({ length: width }, (_, index) =>
<th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}</tr></thead>
<tbody>{shownRows.map(({ row, rowIndex }) => <tr key={rowIndex}>
{Array.from({ length: width }, (_, index) => {
const targetId = table.cellLinks?.find(link => link.rowIndex === rowIndex && link.columnIndex === index)?.targetId;
return <td key={index}>{targetId && onOpenLink
? <button type="button" className="client-env-table-cell-link" onClick={() => onOpenLink(targetId)}>{valueText(row[index])}</button>
: valueText(row[index])}</td>;
})}
</tr>)}</tbody>
</table></div> : <p>No columns retained in this source table.</p>}
{width > 0 && filteredRows.length === 0 && <p>No rows match this search.</p>}
<div className="client-env-table-pagination">
<span>{filteredRows.length ? `Showing ${start + 1}–${Math.min(start + pageSize, filteredRows.length)} of ${filteredRows.length}` : 'Showing 0 of 0'} rows</span>
<label>Rows per page <select value={pageSize} onChange={event => { setPageSize(Number(event.target.value)); setPage(0); }}>
{TABLE_PAGE_SIZES.map(size => <option key={size} value={size}>{size}</option>)}
</select></label>
<div><button type="button" className="btn" disabled={page === 0} onClick={() => setPage(value => value - 1)}>Previous</button>
<span>{page + 1} / {pageCount}</span>
<button type="button" className="btn" disabled={page + 1 >= pageCount} onClick={() => setPage(value => value + 1)}>Next</button></div>
</div>
</div>
{TABLE_RESIZE_DIRECTIONS.map(direction => <span key={direction}
className={`results-window-resize results-window-resize-${direction}`} aria-hidden="true"
onPointerDown={startResize(direction)} onPointerMove={moveResize} onPointerUp={stopResize} onPointerCancel={stopResize}/>)}
</section>
</div>, document.body);
}
function SourceInfoDialog({ title, context, prose, interactiveGraph, children, onClose }: {
title: string; context: string; prose?: string; interactiveGraph?: boolean; children?: ReactNode; onClose(): void;
}) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => { closeRef.current?.focus(); }, []);
return createPortal(<div className="client-env-table-backdrop"
onKeyDown={event => {
if (event.key === 'Escape') { event.stopPropagation(); onClose(); }
if (event.key === 'Tab' && dialogRef.current) {
const focusable = [...dialogRef.current.querySelectorAll<HTMLElement>('button:not(:disabled), input, select, summary')];
const first = focusable[0]; const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
event.stopPropagation();
}}
onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section ref={dialogRef} className="client-env-table-dialog client-env-evidence" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<header className="client-env-table-dialog-head"><div><p>{context}</p><h2 id={titleId}>{title}</h2></div>
<button ref={closeRef} type="button" aria-label={`Close ${title} detail`} onClick={onClose}>×</button></header>
<div className="client-env-table-dialog-body">
{prose && <p>{prose}</p>}
{interactiveGraph && <p>The interactive graph is available in the original PingCastle report.</p>}
{children}
</div>
</section>
</div>, document.body);
}
function EvidenceTable({ table, title, onOpenLink }: {
table: ScannerReportTable; title: string;
onOpenLink(targetId: string): void;
}) {
const [query, setQuery] = useState('');
const [pageSize, setPageSize] = useState<number>(25);
const [page, setPage] = useState(0);
const width = table.rows.reduce((maximum, row) => Math.max(maximum, row.length), table.headers.length);
const needle = query.trim().toLocaleLowerCase();
const filteredRows = needle ? table.rows.map((row, rowIndex) => ({ row, rowIndex }))
.filter(({ row }) => row.some(value => valueText(value).toLocaleLowerCase().includes(needle)))
: table.rows.map((row, rowIndex) => ({ row, rowIndex }));
const start = page * pageSize;
const shownRows = filteredRows.slice(start, start + pageSize);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pageSize));
return <section className="client-env-evidence-table">
{title && <h5>{title}</h5>}
<div className="client-env-table-toolbar">
<input type="search" aria-label={title ? `Search ${title} table` : 'Search table'} placeholder="Search table" value={query}
onChange={event => { setQuery(event.target.value); setPage(0); }}/></div>
{width > 0 && <div className="client-env-evidence-scroll client-env-table-inline-scroll"><table><thead><tr>
{Array.from({ length: width }, (_, index) => <th scope="col" key={index}>{table.headers[index] || `Column ${index + 1}`}</th>)}
</tr></thead><tbody>{shownRows.map(({ row, rowIndex }) => <tr key={rowIndex}>
{Array.from({ length: width }, (_, columnIndex) => {
const targetId = table.cellLinks?.find(link => link.rowIndex === rowIndex && link.columnIndex === columnIndex)?.targetId;
return <td key={columnIndex}>{targetId
? <button type="button" className="client-env-table-cell-link" aria-haspopup="dialog" onClick={() => onOpenLink(targetId)}>{valueText(row[columnIndex])}</button>
: valueText(row[columnIndex])}</td>;
})}</tr>)}</tbody></table></div>}
{width > 0 && !filteredRows.length && <p>No rows match this search.</p>}
{width > 0 && <div className="client-env-table-pagination"><span>{filteredRows.length
? `Showing ${start + 1}–${Math.min(start + pageSize, filteredRows.length)} of ${filteredRows.length}` : 'Showing 0 of 0'} rows</span>
<label>Rows per page <select value={pageSize} onChange={event => { setPageSize(Number(event.target.value)); setPage(0); }}>
{TABLE_PAGE_SIZES.map(size => <option key={size} value={size}>{size}</option>)}
</select></label><div><button type="button" className="btn" disabled={page === 0} onClick={() => setPage(value => value - 1)}>Previous</button>
<span>{page + 1} / {pageCount}</span><button type="button" className="btn" disabled={page + 1 >= pageCount}
onClick={() => setPage(value => value + 1)}>Next</button></div></div>}
</section>;
}
function EvidenceSection({ section, snapshot, linkedSections }: {
section: ScannerReportSection; snapshot: EnvironmentSnapshot;
linkedSections?: Map<string, ScannerReportSection>;
}) {
const title = environmentSectionTitle(snapshot, section);
const prose = environmentSectionProse(section, title);
const titles = section.tables.map(table => environmentTableTitle(snapshot, section, table));
const hasUnstructuredTables = section.tables.some(table => !table.presentation);
const linkedSection = (targetId: string) => linkedSections?.get(environmentSectionReference(section, targetId));
const [modalStack, setModalStack] = useState<string[]>([]);
const [openInlineIds, setOpenInlineIds] = useState<string[]>([]);
const triggerRef = useRef<HTMLElement | null>(null);
const openModal = (targetId: string) => { if (!modalStack.length) triggerRef.current = document.activeElement as HTMLElement; setModalStack(ids => [...ids, targetId]); };
const openModalId = modalStack.at(-1);
const modalIndexes = openModalId ? section.tables.flatMap((table, index) => table.targetId === openModalId ? [index] : []) : [];
const modalIndex = modalIndexes.length === 1 ? modalIndexes[0] : -1;
const modalDetail = section.modalDetails?.find(detail => detail.targetId === openModalId);
const linkedChild = openModalId ? linkedSection(openModalId) : undefined;
const linkedChildTitle = environmentNativeHeading(section.detailLinks?.find(link => link.targetId === openModalId)?.label)
|| (linkedChild && environmentSectionTitle(snapshot, linkedChild)) || title;
const closeModal = () => { setModalStack(ids => ids.slice(0, -1)); if (modalStack.length === 1) triggerRef.current?.focus(); };
const renderTable = (table: ScannerReportTable, index: number) => {
return <EvidenceTable table={table} title={titles[index]}
onOpenLink={openModal} key={`${table.sourceTableIndex ?? index}:${table.name}`}/>;
};
return <section className="client-env-evidence-section">
<h4>{title}</h4>
{prose && !hasUnstructuredTables && <p className="client-env-evidence-prose">{prose}</p>}
{section.tables.map((table, index) => table.presentation === 'modal'
|| table.presentation === 'linked-inline' && section.detailLinks?.some(link => link.targetId === table.targetId)
? null : renderTable(table, index))}
{section.detailLinks?.map(link => {
const open = openInlineIds.includes(link.targetId);
const linkedIndexes = section.tables.flatMap((table, index) => table.targetId === link.targetId ? [index] : []);
return <div className="client-env-evidence-linked" key={link.targetId}>
<button type="button" className="client-env-table-detail-link" aria-expanded={open}
onClick={() => setOpenInlineIds(ids => open ? ids.filter(id => id !== link.targetId) : [...ids, link.targetId])}>{environmentNativeHeading(link.label) || title}</button>
{open && <div className="client-env-evidence-linked-body">
{link.prose && <p>{link.prose}</p>}
{linkedIndexes.map(index => renderTable(section.tables[index], index))}
{linkedSection(link.targetId)
&& <EvidenceSection section={linkedSection(link.targetId)!} snapshot={snapshot}
linkedSections={linkedSections}/>}
</div>}
</div>;
})}
{modalIndex >= 0 && <SourceTableDialog table={section.tables[modalIndex]}
title={titles[modalIndex] || title}
context={title}
onClose={closeModal} onOpenLink={openModal}/>}
{modalIndexes.length > 1 && <SourceInfoDialog title={titles[modalIndexes[0]] || title} context={title} onClose={closeModal}>
{modalIndexes.map(index => renderTable(section.tables[index], index))}
</SourceInfoDialog>}
{modalIndex < 0 && modalDetail && <SourceInfoDialog title={environmentNativeHeading(modalDetail.title) || title} context={title}
prose={modalDetail.prose} interactiveGraph={modalDetail.interactiveGraph} onClose={closeModal}/>}
{modalIndexes.length === 0 && !modalDetail && linkedChild && <SourceInfoDialog title={linkedChildTitle} context={title} onClose={closeModal}>
<EvidenceSection section={linkedChild} snapshot={snapshot} linkedSections={linkedSections}/>
</SourceInfoDialog>}
</section>;
}
export function EnvironmentEvidenceTables({ snapshot, group }: { snapshot?: EnvironmentSnapshot; group: EnvironmentEvidenceGroup }) {
const [visibleSections, setVisibleSections] = useState(PAGE_SIZE);
if (!snapshot) return <p className="client-env-evidence-empty">No retained Client Information available for this selection.</p>;
const { sections } = environmentEvidenceForGroup(snapshot, group);
const linkedSections = new Map<string, ScannerReportSection>();
for (const section of sections) {
const key = environmentSectionReference(section, section.sectionKey);
if (sections.filter(candidate => environmentSectionReference(candidate, candidate.sectionKey) === key).length === 1)
linkedSections.set(key, section);
}
const linkedKeys = new Set(sections.flatMap(section => [
...(section.detailLinks?.map(link => environmentSectionReference(section, link.targetId)) ?? []),
...section.tables.flatMap(table => table.cellLinks?.map(link => environmentSectionReference(section, link.targetId)) ?? [])
]));
const topSections = sections.filter(section => {
const key = environmentSectionReference(section, section.sectionKey);
return !linkedKeys.has(key) || !linkedSections.has(key);
});
if (!sections.length) return <p className="client-env-evidence-empty">No original report sections for this category.</p>;
return <div className="client-env-evidence">
{sections.length > 0 && <div className="client-env-evidence-block">
{topSections.slice(0, visibleSections).map(section => <EvidenceSection section={section} snapshot={snapshot}
linkedSections={linkedSections} key={section.id}/>)}
{visibleSections < topSections.length && <button type="button" className="btn" onClick={() => setVisibleSections(value => value + PAGE_SIZE)}>
Show more sections ({visibleSections.toLocaleString('en-US')} of {topSections.length.toLocaleString('en-US')})
</button>}</div>}
</div>;
}