195 lines
16 KiB
TypeScript
195 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { RetainedAppState, useApp } from './AppProvider';
|
|
import { WORKSPACE_BAND_ID } from './workspace-band';
|
|
import { DataHub } from '../features/project-import/DataHub';
|
|
import { DashboardPanel } from '../features/dashboard/DashboardPanel';
|
|
import { DbStructurePanel } from '../features/db-structure/DbStructurePanel';
|
|
import { ResultsBrowser } from '../features/results-browser/ResultsBrowser';
|
|
import { SearchFilterBar, SearchTextControl, SheetAndSearchControls, WorkTimeslotControl } from '../features/search-filter/SearchFilterBar';
|
|
import { useFilteredRecords } from '../features/search-filter/use-filtered-records';
|
|
import { DatasetViewer } from '../features/dataset-viewer/DatasetViewer';
|
|
import { ObsidianGraphView } from '../features/mitre-graph/ObsidianGraphView';
|
|
import { ActionlistControls } from '../features/actionlist/ActionlistControls';
|
|
import { ImportWorkflowDialog } from '../features/import-workflow/ImportWorkflowDialog';
|
|
import { RESULT_DOCK_SLOT_ID } from '../features/linked-results/result-window-layout';
|
|
import { rowsToSpreadsheetSafeCsv } from '../exports/csv-export';
|
|
import { downloadText } from '../exports/text-download';
|
|
import { exportSnapshot } from '../exports/snapshot-export';
|
|
import { browserDownloadAdapter } from '../platform/browser/browser-download-adapter';
|
|
import assessorLogo from '../assets/branding/assessor-icon-transparent.png';
|
|
import { TriangleIcon } from '../components/TriangleIcon';
|
|
import { SidebarSeverityMetrics } from '../features/sidebar/SidebarSeverityMetrics';
|
|
import { ViewBookmarks } from '../features/view-bookmarks/ViewBookmarks';
|
|
import { pathSnapshotFindingIds, type PathSnapshotState, type ViewBookmark } from '../features/view-bookmarks/view-bookmarks';
|
|
import { PathfinderFilterTrail } from '../features/mitre-graph/PathfinderFilterTrail';
|
|
import { ASSESSOR_PRODUCT_VERSION } from '../domain/assessor-release';
|
|
import { hasSelectedTimeslotScope } from './work-scope';
|
|
import { AboutDialog } from '../features/about/AboutDialog';
|
|
|
|
type AppArea = 'db-structure' | 'data-hub' | 'dashboard' | 'graph' | 'work' | 'results';
|
|
|
|
function AreaIcon({ area }: { area: AppArea }) {
|
|
if (area === 'dashboard') return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"/></svg>;
|
|
if (area === 'db-structure') return <svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.6" fill="none" stroke="currentColor" strokeWidth="1.7"/><path d="M5 5v4.7c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6V5" fill="none" stroke="currentColor" strokeWidth="1.7"/><path d="M5 12.3V17c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6v-4.7" fill="none" stroke="currentColor" strokeWidth="1.7"/></svg>;
|
|
if (area === 'data-hub') return <svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="3" fill="none" stroke="currentColor" strokeWidth="1.7"/><path d="M5 5v7c0 1.7 3.1 3 7 3s7-1.3 7-3V5M5 12v7c0 1.7 3.1 3 7 3s7-1.3 7-3v-7" fill="none" stroke="currentColor" strokeWidth="1.7"/></svg>;
|
|
if (area === 'graph') return <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.7"/><circle cx="12" cy="5" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.7"/><circle cx="19" cy="12" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.7"/><circle cx="12" cy="19" r="2.2" fill="none" stroke="currentColor" strokeWidth="1.7"/><path d="m6.6 10.4 3.8-3.8m3.2 0 3.8 3.8m0 3.2-3.8 3.8m-3.2 0-3.8-3.8" fill="none" stroke="currentColor" strokeWidth="1.7"/></svg>;
|
|
if (area === 'results') return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16l-6 8v6l-4-2v-4z" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round"/></svg>;
|
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v14H4zM4 10h16M9 10v9" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round"/></svg>;
|
|
}
|
|
|
|
const AREA_TOPBAR_TITLE: Record<AppArea, string> = {
|
|
'db-structure': 'DB Structure',
|
|
'data-hub': 'Data Hub',
|
|
dashboard: 'Client Info',
|
|
graph: 'Pathfinder',
|
|
work: 'Work',
|
|
results: 'Result DB'
|
|
};
|
|
|
|
/* Reihenfolge der Hauptnavigation. Der erste Eintrag liegt auf der Oberkante
|
|
des Panels und geht dort farblich in es ueber; nur an dieser einen Ecke kann
|
|
das Panel nicht gerundet sein. Die Liste steht deshalb an einer Stelle,
|
|
damit die Ecke der Reihenfolge folgt und nicht einem Bereichsnamen im CSS. */
|
|
const NAV_AREAS = [
|
|
['db-structure', 'DB Structure'],
|
|
['data-hub', 'Datahub'],
|
|
['dashboard', 'Client Info'],
|
|
['work', 'Work'],
|
|
['results', 'Result DB'],
|
|
['graph', 'Pathfinder']
|
|
] as const satisfies readonly (readonly [AppArea, string])[];
|
|
|
|
export function App() {
|
|
const app = useApp();
|
|
const [activeArea, setActiveArea] = useState<AppArea>('data-hub');
|
|
const [activeProjectName, setActiveProjectName] = useState<string>();
|
|
const [dataHubClientView, setDataHubClientView] = useState(false);
|
|
const [dataHubDetailClientName, setDataHubDetailClientName] = useState<string>();
|
|
const [pathSnapshotRequest, setPathSnapshotRequest] = useState<{ id: string; path: PathSnapshotState }>();
|
|
const [aboutOpen, setAboutOpen] = useState(false);
|
|
const [visitedGraphScope, setVisitedGraphScope] = useState<string>();
|
|
const graphVisited = visitedGraphScope === app.workScopeId;
|
|
useEffect(() => { if (activeArea === 'graph') setVisitedGraphScope(app.workScopeId); }, [activeArea, app.workScopeId]);
|
|
const clearPathSnapshotRequest = useCallback(() => setPathSnapshotRequest(undefined), []);
|
|
const { dataset, activeSheet, reviews, snapshot, theme, setTheme, delimiter, setDelimiter, view, setView, actionOnly, sidebarCollapsed, toggleSidebar, toggleWorkspace, backendStatus, targetProjectId } = app;
|
|
const openWork = useCallback(() => {
|
|
setView('records');
|
|
setActiveArea('work');
|
|
}, [setView]);
|
|
|
|
|
|
const marked = useMemo(() => Object.values(reviews).filter(review => review.include).length, [reviews]);
|
|
const visible = useFilteredRecords().length;
|
|
const backendLabel = backendStatus === 'connected' ? 'Server connection available' : backendStatus === 'checking' ? 'Checking server connection' : 'No server connection';
|
|
const hasClientTimeslotScope = !targetProjectId
|
|
|| hasSelectedTimeslotScope(app.activeScanRunIds, app.activeAssessmentIds);
|
|
const headerClientName = (activeArea === 'data-hub' && dataHubClientView ? dataHubDetailClientName : activeProjectName)?.trim();
|
|
const handleDataHubDetailViewChanged = useCallback((isDetailView: boolean, clientName?: string) => {
|
|
setDataHubClientView(isDetailView);
|
|
setDataHubDetailClientName(isDetailView ? clientName : undefined);
|
|
}, []);
|
|
const connectionIndicator = <span className={`connection-indicator connection-${backendStatus}`} role="status" aria-label={backendLabel} title={backendLabel}><i className="connection-state-square is-offline" aria-hidden="true"/><i className="connection-state-square is-online" aria-hidden="true"/></span>;
|
|
const headerContext = <div className="app-header-context">{connectionIndicator}{headerClientName && activeArea !== 'dashboard' && <span className="app-header-client-name" title={headerClientName}>{headerClientName}</span>}</div>;
|
|
const dashboardClientContext = activeArea === 'dashboard' && headerClientName
|
|
? <span className="area-client-context-inline"><span className="area-client-marker"><TriangleIcon direction="right" /></span><span className="area-client-name" title={headerClientName}>{headerClientName}</span></span>
|
|
: null;
|
|
|
|
const readerToolbar = <div className="reader-toolbar">
|
|
<div className="live-summary" aria-live="polite"><span>Shown <b>{visible.toLocaleString('en-US')}</b></span><span>Marked <b>{marked.toLocaleString('en-US')}</b></span></div>
|
|
<div className="toolbar">
|
|
<button className="btn" type="button" disabled={!dataset} onClick={() => { const value = snapshot(); if (value && dataset) exportSnapshot(value, `${dataset.fileName.replace(/\.[^.]+$/, '')}-copy.assessor-snapshot.json`, browserDownloadAdapter); }}>Snapshot</button>
|
|
<select className="select delimiter-select" value={delimiter} onChange={event => setDelimiter(event.target.value)} title="CSV delimiter"><option value="auto">CSV: Auto</option><option value=",">Comma</option><option value=";">Semicolon</option><option value="\t">Tab</option><option value="|">Pipe</option></select>
|
|
<button className="icon-btn" type="button" disabled={!activeSheet} title="Copy current table" aria-label="Copy current table" onClick={() => activeSheet && navigator.clipboard.writeText(rowsToSpreadsheetSafeCsv(activeSheet.rows, delimiter))}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 8h11v13H8zM5 16H3V3h11v2" fill="none" stroke="currentColor" strokeWidth="1.8"/></svg></button>
|
|
<button className="icon-btn" type="button" disabled={!activeSheet} title="Export current sheet as CSV" aria-label="Export current sheet as CSV" onClick={() => activeSheet && downloadText(browserDownloadAdapter, `${activeSheet.name}.csv`, `\ufeff${rowsToSpreadsheetSafeCsv(activeSheet.rows, delimiter)}`, 'text/csv;charset=utf-8')}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 20h16" fill="none" stroke="currentColor" strokeWidth="1.8"/></svg></button>
|
|
|
|
<button className={`btn workspace-btn${marked ? ' has-items' : ''}${actionOnly ? ' active' : ''}`} type="button" disabled={!marked} aria-pressed={actionOnly} onClick={toggleWorkspace}>My Workspace</button>
|
|
<div className="view-tabs" role="tablist" aria-label="View">{([['records', 'Reports'], ['columns', 'Matrix'], ['table', 'Grid'], ['raw', 'Raw Data']] as const).map(([value, label]) => <button className={`tab-btn${view === value ? ' active' : ''}`} key={value} type="button" onClick={() => setView(value)}>{label}</button>)}</div>
|
|
<ActionlistControls/>
|
|
<SheetAndSearchControls/>
|
|
<WorkTimeslotControl/>
|
|
</div>
|
|
</div>;
|
|
|
|
return <div className={`app-shell app-shell-${activeArea}${sidebarCollapsed ? ' sidebar-collapsed' : ''}`}>
|
|
<header className="topbar">
|
|
<button className="brand" type="button" aria-label="About ASSESSOR" title="About ASSESSOR" onClick={() => setAboutOpen(true)}><span className="brand-logo" aria-hidden="true"><img src={assessorLogo} alt=""/></span><span className="brand-copy"><h1>ASSESSOR</h1><span className="brand-version">{ASSESSOR_PRODUCT_VERSION}</span></span></button>
|
|
<div className={`app-header-panel app-header-panel-${activeArea}`} id={activeArea === 'graph' ? WORKSPACE_BAND_ID : undefined}>
|
|
{activeArea === 'work' ? <>
|
|
<div className="work-header-row">{readerToolbar}{headerContext}</div>
|
|
<div className="workbar"><SearchFilterBar/></div>
|
|
<div className="status-line">
|
|
<ViewBookmarks onApplyPathSnapshot={(bookmark: ViewBookmark) => {
|
|
if (!bookmark.path) return;
|
|
app.setPathFindingIds(pathSnapshotFindingIds(bookmark.path));
|
|
setPathSnapshotRequest({ id: bookmark.id, path: bookmark.path });
|
|
}} />
|
|
{/* Filterfeld und angedockter Verbund bilden eine Gruppe, damit die
|
|
Leiste unmittelbar am Feld steht statt am rechten Rand. */}
|
|
<div className="status-line-tools">
|
|
<SearchTextControl />
|
|
{/* Einhaengepunkt des angedockten Result-Verbunds, siehe DatasetViewer.
|
|
Bewusst ein div: `.status-line > span` kuerzt Text per Ellipsis und
|
|
schneidet mit `overflow: hidden` die Bedienelemente ab. */}
|
|
<div className="result-dock-slot" id={RESULT_DOCK_SLOT_ID} />
|
|
</div>
|
|
</div>
|
|
</> : <div className="area-topbar"><span className="area-topbar-heading"><strong>{activeArea === 'data-hub' && dataHubClientView ? 'Client' : AREA_TOPBAR_TITLE[activeArea]}</strong>{dashboardClientContext}{activeArea === 'graph' && <PathfinderFilterTrail />}</span>{headerContext}</div>}
|
|
</div>
|
|
</header>
|
|
<main>
|
|
<aside><div className="app-sidebar">
|
|
<nav className="sidebar-primary-actions" aria-label="Main navigation">{NAV_AREAS.map(([area, label]) => <button className={`sidebar-tab-btn${activeArea === area ? ' active' : ''}`} key={area} type="button" disabled={area !== 'data-hub' && area !== 'db-structure' && !targetProjectId} onClick={() => area === 'work' ? openWork() : setActiveArea(area)}><AreaIcon area={area}/><span>{label}</span></button>)}</nav>
|
|
{activeArea === 'work' && <SidebarSeverityMetrics sheet={activeSheet}/>}
|
|
{/* Die Sidebar traegt ausschliesslich Navigation. Der Serverzustand steht
|
|
einheitlich als kompakte Rot-/Gruen-Anzeige im Bereichsheader. */}
|
|
<section className="sidebar-theme-panel"><div className="theme-switcher" role="group" aria-label="Controls and color scheme">
|
|
<button type="button" className="sidebar-toggle-inline" onClick={toggleSidebar} title="Collapse sidebar" aria-label="Collapse sidebar"><TriangleIcon direction="left" /></button>
|
|
<button type="button" className={`theme-switcher-btn${theme === 'dark' ? ' active' : ''}`} onClick={() => setTheme('dark')} title="Dark Mode" aria-label="Dark Mode">◔</button>
|
|
<button type="button" className={`theme-switcher-btn${theme === 'system' ? ' active' : ''}`} onClick={() => setTheme('system')} title="System mode" aria-label="System mode">▣</button>
|
|
<button type="button" className={`theme-switcher-btn${theme === 'light' ? ' active' : ''}`} onClick={() => setTheme('light')} title="Light Mode" aria-label="Light Mode">☀</button>
|
|
</div></section>
|
|
</div></aside>
|
|
{sidebarCollapsed && <button className="sidebar-toggle" type="button" onClick={toggleSidebar} title="Expand sidebar" aria-label="Expand sidebar"><TriangleIcon direction="right" /></button>}
|
|
<section className={`workspace workspace-${activeArea}`}>
|
|
<section className={`viewer app-area app-area-${activeArea}${activeArea === NAV_AREAS[0][0] ? ' is-joined-top' : ''}${activeArea === 'work' ? ` view-${view}` : ''}`}>
|
|
{activeArea === 'work' && <DatasetViewer/>}
|
|
{dataset && hasClientTimeslotScope && (graphVisited || activeArea === 'graph') && <div className="pathfinder-view-host" hidden={activeArea !== 'graph'}>
|
|
<RetainedAppState key={app.workScopeId} active={activeArea === 'graph'}>
|
|
<ObsidianGraphView
|
|
active={activeArea === 'graph'}
|
|
onOpenFinding={openWork}
|
|
pathSnapshotRequest={pathSnapshotRequest}
|
|
onPathSnapshotApplied={clearPathSnapshotRequest}
|
|
/>
|
|
</RetainedAppState>
|
|
</div>}
|
|
{activeArea === 'graph' && dataset && !hasClientTimeslotScope && <div className="datahub-empty"><h3>No timeslot selected</h3><p>Select a timeslot in Work to build its graph.</p></div>}
|
|
{activeArea === 'data-hub' && (
|
|
<DataHub
|
|
activeProjectId={targetProjectId}
|
|
backendStatus={backendStatus}
|
|
onDetailViewChanged={handleDataHubDetailViewChanged}
|
|
onProjectDataChanged={async projectId => {
|
|
await app.openProject(projectId, projectId === targetProjectId ? app.activeScanRunIds : undefined,
|
|
projectId === targetProjectId ? app.activeAssessmentIds : undefined);
|
|
}}
|
|
onProjectSelected={(project, scanRunIds, assessmentIds) => {
|
|
setActiveProjectName(project?.name);
|
|
if (project) {
|
|
setActiveArea('dashboard');
|
|
void app.openProject(project.id, scanRunIds, assessmentIds);
|
|
} else app.setTargetProjectId(undefined);
|
|
}}
|
|
/>
|
|
)}
|
|
{activeArea === 'dashboard' && <DashboardPanel onOpenWork={openWork}/>}
|
|
{activeArea === 'db-structure' && <DbStructurePanel backendStatus={backendStatus}/>}
|
|
{activeArea === 'results' && <ResultsBrowser backendStatus={backendStatus} projectId={targetProjectId} scanRunIds={app.activeScanRunIds} assessmentIds={app.activeAssessmentIds}/>}
|
|
</section>
|
|
</section>
|
|
</main>
|
|
<ImportWorkflowDialog/>
|
|
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)}/>}
|
|
</div>;
|
|
}
|