316 lines
20 KiB
TypeScript
316 lines
20 KiB
TypeScript
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>;
|
||
}
|