Assessor_2.0/src/app/App.tsx

215 lines
18 KiB
TypeScript

import { trackActivity, showActivityFrame } from '../platform/activity-store';
import { ActivityCenter } from '../components/ActivityCenter';
import { EnginesControl } from '../components/EnginesControl';
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 { workRecordIdsForResult } from '../features/results-browser/work-record-lookup';
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 { SidebarSecurityDomains } from '../features/sidebar/SidebarSecurityDomains';
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';
import { installTableRowMarking } from './table-row-marking';
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': 'Data Flow',
'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', 'Data Flow'],
['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();
useEffect(() => installTableRowMarking(document), []);
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 openWorkDefault = useCallback(() => {
const hasIncomingFilter = Boolean(app.search.trim() || Object.keys(app.facetFilters).length
|| app.pathFindingIds.length || app.actionOnly);
if (!hasIncomingFilter) app.resetFilters();
openWork();
}, [app.actionOnly, app.facetFilters, app.pathFindingIds, app.resetFilters, app.search, openWork]);
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 runLocalTask = (label: string, work: () => unknown | Promise<unknown>) => {
void trackActivity('Core Engine', label, async () => { await showActivityFrame(); await work(); }).catch(() => undefined);
};
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={() => runLocalTask('Preparing workspace snapshot', () => { 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={() => runLocalTask('Preparing and copying current table', () => 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={() => runLocalTask('Preparing current sheet CSV export', () => 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>
<EnginesControl/>
<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 />}{(activeArea === 'dashboard' || activeArea === 'graph') && <EnginesControl/>}</span>{headerContext}</div>}
{activeArea === 'graph' && <div id="pathfinder-controls"/>}
</div>
</header>
<ActivityCenter/>
<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' ? openWorkDefault() : setActiveArea(area)}><AreaIcon area={area}/><span>{label}</span></button>)}</nav>
{activeArea === 'work' && <SidebarSecurityDomains 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" aria-expanded="true"><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" aria-expanded="false"><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, activeProjectName);
}}
onProjectSelected={(project, scanRunIds, assessmentIds) => {
setActiveProjectName(project?.name);
if (project) {
setActiveArea('dashboard');
void app.openProject(project.id, scanRunIds, assessmentIds, project.name);
} 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}
workRecordIds={row => workRecordIdsForResult(dataset, row)}
onOpenInWork={recordIds => { app.setPathFindingIds(recordIds); openWork(); }}/>}
</section>
</section>
</main>
<ImportWorkflowDialog/>
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)}/>}
</div>;
}