feat(comparison): Compare tab, Work Change facet and Finding Reader section (2.26.0)
Register src/features/comparison as the Comparison Engine UI path and wire its extension points: the Engines popup entry, the Client Info Compare tab (A/B selection, score delta over comparable sources, change counts, source table), the Work Change facet before Source: Scan (only after the tab loaded a comparison of the open timeslot) and a collapsed Finding Reader Compare section with both states and the retained GPO values side by side. The engine now carries those values. The Data Flow diagram shows the engine; product version 2.26.0.
This commit is contained in:
parent
1dd2164a85
commit
522d0b47f7
|
|
@ -17,7 +17,7 @@ export const ENGINE_PATHS = [
|
||||||
['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts',
|
['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts',
|
||||||
'tests/postgres/report-read-only-runtime.test.ts']],
|
'tests/postgres/report-read-only-runtime.test.ts']],
|
||||||
['Compliance Engine', ['src/compliance/', 'server/compliance/', 'tests/compliance/']],
|
['Compliance Engine', ['src/compliance/', 'server/compliance/', 'tests/compliance/']],
|
||||||
['Comparison Engine', ['src/comparison/', 'server/comparison/', 'tests/comparison/']],
|
['Comparison Engine', ['src/comparison/', 'src/features/comparison/', 'server/comparison/', 'tests/comparison/']],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CORE_AREAS = [
|
export const CORE_AREAS = [
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine)
|
## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine)
|
||||||
|
|
||||||
Status: **steps 1, 2 and 3a done and verified; step 3b partly done (2026-10-13); 3b extension-point wiring and step 4 open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
|
Status: **steps 1, 2, 3a and 3b done (2026-10-13); step 4 (engine storage) and Security Domain deltas open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
|
||||||
|
|
||||||
UI decision 2026-10-13 (mockups A and B in the local, git-ignored `artifacts/compare-engine/`; synthetic Timeslot A): entry is a Client Info tab `Compare` with A/B timeslot selection, change counts, Security Domain and source deltas, each count opening Work; Work receives a `Change` facet and change badges, and the Finding Reader a `Compare` section with A and B values side by side. A retained value change of a still deviating GPO setting is shown neutral (`Value changed`), neither better nor worse. Mockup C (better/worse bridge) is not selected.
|
UI decision 2026-10-13 (mockups A and B in the local, git-ignored `artifacts/compare-engine/`; synthetic Timeslot A): entry is a Client Info tab `Compare` with A/B timeslot selection, change counts, Security Domain and source deltas, each count opening Work; Work receives a `Change` facet and change badges, and the Finding Reader a `Compare` section with A and B values side by side. A retained value change of a still deviating GPO setting is shown neutral (`Value changed`), neither better nor worse. Mockup C (better/worse bridge) is not selected.
|
||||||
|
|
||||||
|
|
@ -18,7 +18,9 @@ Step 3a (engine paths only, no Core release): `src/comparison/timeslot-compariso
|
||||||
|
|
||||||
Step 3b, first part (Core release Presentation, Platform): engine read port `src/comparison/comparison-read-port.ts`; Platform adapter `server/timeslot-comparison-input.ts` builds each timeslot as Work reads it (scanner models scoped to the selected runs, GPO verdicts through `currentPolicyEvaluationsForWork` and `buildPolicyWorkResults`, GPO observation time only from every contributing backup's `backupCreatedAt`); route `GET /api/v1/projects/:id/timeslot-comparison` (baseline/target assessment and scan-run IDs; 400 for a missing timeslot, a shared scan run or explicit dates in the wrong order, 404 for a foreign assessment, 409 on a concurrent change, 503 when switched off; read-only); `AssessorApiClient.getTimeslotComparison<T>` (engine-free) and the activity label `Comparing timeslots`. Engine UI written and synthetically tested but **parked, not in the repository** (git-ignored `artifacts/compare-engine/parked-ui/`): `ComparisonTab` (A/B selection, B defaults to the open timeslot, A to the one before it, only earlier timeslots as A; Overall Score A/B with a labelled delta over comparable sources; change facts that call `onOpenChange`; source table with comparison basis), `useTimeslotComparison` (timeslots grouped like Data Hub) and its four interaction tests. It cannot live in `src/comparison/`, which `tests/local-only-check.mjs` keeps platform-neutral (no React); like the Context Engine's `src/features/context-layer/`, it needs a registered engine UI path (proposed `src/features/comparison/`, a Governance change to the area map). Security Domain deltas are not in it yet. Not done, needs `Governance, Presentation, Core Tests`: the engine UI path, the Engines popup entry (`tests/components/engines-control.test.tsx` pins the engine list), the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section (each a new pinned extension point in `tests/architecture/core-engine-boundary.test.ts`), and the Data Flow engine diagram (`tests/features/db-structure-flow.test.tsx`). No version change while unwired. Verified: 18 engine tests (3 route tests among them); import gate 528/528; main suite 2,465 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass.
|
Step 3b, first part (Core release Presentation, Platform): engine read port `src/comparison/comparison-read-port.ts`; Platform adapter `server/timeslot-comparison-input.ts` builds each timeslot as Work reads it (scanner models scoped to the selected runs, GPO verdicts through `currentPolicyEvaluationsForWork` and `buildPolicyWorkResults`, GPO observation time only from every contributing backup's `backupCreatedAt`); route `GET /api/v1/projects/:id/timeslot-comparison` (baseline/target assessment and scan-run IDs; 400 for a missing timeslot, a shared scan run or explicit dates in the wrong order, 404 for a foreign assessment, 409 on a concurrent change, 503 when switched off; read-only); `AssessorApiClient.getTimeslotComparison<T>` (engine-free) and the activity label `Comparing timeslots`. Engine UI written and synthetically tested but **parked, not in the repository** (git-ignored `artifacts/compare-engine/parked-ui/`): `ComparisonTab` (A/B selection, B defaults to the open timeslot, A to the one before it, only earlier timeslots as A; Overall Score A/B with a labelled delta over comparable sources; change facts that call `onOpenChange`; source table with comparison basis), `useTimeslotComparison` (timeslots grouped like Data Hub) and its four interaction tests. It cannot live in `src/comparison/`, which `tests/local-only-check.mjs` keeps platform-neutral (no React); like the Context Engine's `src/features/context-layer/`, it needs a registered engine UI path (proposed `src/features/comparison/`, a Governance change to the area map). Security Domain deltas are not in it yet. Not done, needs `Governance, Presentation, Core Tests`: the engine UI path, the Engines popup entry (`tests/components/engines-control.test.tsx` pins the engine list), the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section (each a new pinned extension point in `tests/architecture/core-engine-boundary.test.ts`), and the Data Flow engine diagram (`tests/features/db-structure-flow.test.tsx`). No version change while unwired. Verified: 18 engine tests (3 route tests among them); import gate 528/528; main suite 2,465 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass.
|
||||||
|
|
||||||
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis; step 3b read port, timeslot route and API client; Compare tab parked.
|
Step 3b, second part (Core release Governance, Presentation, Core Tests, Platform; the Data Flow diagram part finished under a renewed Presentation, Core Tests, Platform release after an earlier release had expired mid-turn): the engine UI path `src/features/comparison/` is registered (area map, AGENTS.md, onboarding) and holds the formerly parked `ComparisonTab`, `useTimeslotComparison`, `comparison-work-facets` and `FindingComparison`. Extension points (pinned in the boundary test): Engines popup entry `Comparison Engine` (default on); Client Info tab `Compare` after `Compliance`; Work facet `Change` before `Source: Scan` in the shared filter pipeline (filter-records, work-facets, use-filtered-records, SearchFilterBar, ActionlistControls, SidebarSecurityDomains), present only after the Compare tab loaded a comparison whose B is the open timeslot, so opening Work never starts a comparison; a change count opens Work with that facet value (`Resolved` with status All, all others Deviation) and is clickable only when timeslot B holds a record for it; Finding Reader section `Compare` after Context, collapsed, with both states and, for GPO settings, both retained configured values as Work displays them (the engine now carries them). The Data Flow engine diagram shows the Comparison Engine and its two relationships. Product version 2.26.0 (backward-compatible capability). Synthetic test client `compare test` (2026-06-10 and 2026-09-15) created through the regular import API from generated synthetic CIS-CAT reports (resolved, unchanged, regressed, new and absent rules) plus the synthetic Maester fixture in B only; the generated files are in git-ignored `artifacts/compare-engine/test-client/`. Verified: import gate 528/528; main suite 2,474 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass. Not yet verified in the running app: it was started outside this session and still runs 2.25.0.
|
||||||
|
|
||||||
|
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis; step 3b read port, timeslot route, API client, Compare tab, Work Change facet, Finding Reader section and engine diagram; version 2.26.0.
|
||||||
|
|
||||||
## Results cluster foreground correction (2026-10-13, Phase 7 Presentation, 2.25.6)
|
## Results cluster foreground correction (2026-10-13, Phase 7 Presentation, 2.25.6)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ Documentation under `docs/` is not gated.
|
||||||
| **Context Engine** | `src/assessment-context/`, `src/features/context-layer/` | Gives every deviation a context cluster, in a fixed order of tiers: *catalogued* (catalogue rule, CIS section, declared ATT&CK technique), *recognized* (distinctive terms, technical anchors) or *estimated* (local TF-IDF similarity, category fallback). Deterministic, no AI. Shown as badges, the `Context Cluster` facet, the Finding Reader section and the Client Info heatmap. A context statement says "favours", never "exploitable". | none (in memory) |
|
| **Context Engine** | `src/assessment-context/`, `src/features/context-layer/` | Gives every deviation a context cluster, in a fixed order of tiers: *catalogued* (catalogue rule, CIS section, declared ATT&CK technique), *recognized* (distinctive terms, technical anchors) or *estimated* (local TF-IDF similarity, category fallback). Deterministic, no AI. Shown as badges, the `Context Cluster` facet, the Finding Reader section and the Client Info heatmap. A context statement says "favours", never "exploitable". | none (in memory) |
|
||||||
| **Report Engine** | `src/reporting/`, `server/reporting/` | Builds the Management Report (German, offline, no LLM, requires published Context results) and the Technical Report from one shared projection. | own file archive, separate read-only DB pool |
|
| **Report Engine** | `src/reporting/`, `server/reporting/` | Builds the Management Report (German, offline, no LLM, requires published Context results) and the Technical Report from one shared projection. | own file archive, separate read-only DB pool |
|
||||||
| **Compliance Engine** | `src/compliance/`, `server/compliance/` | Maps findings to regulatory controls; ISO/IEC 27001/27002 is the first framework. Owns **all AI integration**: ISO mapping and the AI overview. | own tables in PostgreSQL, Qdrant index |
|
| **Compliance Engine** | `src/compliance/`, `server/compliance/` | Maps findings to regulatory controls; ISO/IEC 27001/27002 is the first framework. Owns **all AI integration**: ISO mapping and the AI overview. | own tables in PostgreSQL, Qdrant index |
|
||||||
| **Comparison Engine** | `src/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). In development; no UI yet. | none yet (own schema planned) |
|
| **Comparison Engine** | `src/comparison/`, `src/features/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). Shown in the Client Info `Compare` tab. | none yet (own schema planned) |
|
||||||
|
|
||||||
**Engine rules**:
|
**Engine rules**:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.25.0",
|
"version": "2.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.25.0",
|
"version": "2.26.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "assessor",
|
"name": "assessor",
|
||||||
"version": "2.25.0",
|
"version": "2.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ export const ENGINES = [
|
||||||
{ id: 'context', label: 'Context Engine', required: false },
|
{ id: 'context', label: 'Context Engine', required: false },
|
||||||
{ id: 'report', label: 'Report Engine', required: false },
|
{ id: 'report', label: 'Report Engine', required: false },
|
||||||
{ id: 'remediation', label: 'Remediation Engine', required: false },
|
{ id: 'remediation', label: 'Remediation Engine', required: false },
|
||||||
{ id: 'compliance', label: 'Compliance Engine', required: false }
|
{ id: 'compliance', label: 'Compliance Engine', required: false },
|
||||||
|
{ id: 'comparison', label: 'Comparison Engine', required: false }
|
||||||
] as const;
|
] as const;
|
||||||
export type EngineId = typeof ENGINES[number]['id'];
|
export type EngineId = typeof ENGINES[number]['id'];
|
||||||
export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1';
|
export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1';
|
||||||
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true });
|
const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
|
||||||
type Preferences = Readonly<Record<EngineId, boolean>>;
|
type Preferences = Readonly<Record<EngineId, boolean>>;
|
||||||
let snapshot: Preferences | undefined;
|
let snapshot: Preferences | undefined;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
@ -20,7 +21,7 @@ export function readEnginePreferences(): Preferences {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}');
|
const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}');
|
||||||
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false, remediation: saved?.remediation !== false,
|
snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false, remediation: saved?.remediation !== false,
|
||||||
compliance: saved?.compliance !== false });
|
compliance: saved?.compliance !== false, comparison: saved?.comparison !== false });
|
||||||
} catch { snapshot = defaults; }
|
} catch { snapshot = defaults; }
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ interface TimeslotSetting {
|
||||||
verdict?: SettingVerdict;
|
verdict?: SettingVerdict;
|
||||||
baselineIds: string;
|
baselineIds: string;
|
||||||
actualValues: string;
|
actualValues: string;
|
||||||
|
/** The retained configured values as Work displays them; empty when none is retained. */
|
||||||
|
displayValues: string[];
|
||||||
resultIds: string[];
|
resultIds: string[];
|
||||||
ambiguous: boolean;
|
ambiguous: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +40,9 @@ export interface GpoSettingDecision {
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
baselineResultIds: string[];
|
baselineResultIds: string[];
|
||||||
targetResultIds: string[];
|
targetResultIds: string[];
|
||||||
|
/** Retained configured values of both timeslots as Work displays them, for a side-by-side reading. */
|
||||||
|
baselineValues: string[];
|
||||||
|
targetValues: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GpoTargetChange {
|
export interface GpoTargetChange {
|
||||||
|
|
@ -82,6 +87,10 @@ function rollUp(results: PolicyWorkResult[]): Map<string, TimeslotSetting> {
|
||||||
baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()),
|
baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()),
|
||||||
actualValues: canonicalJson([...new Set(decisive.flatMap(item =>
|
actualValues: canonicalJson([...new Set(decisive.flatMap(item =>
|
||||||
item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()),
|
item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()),
|
||||||
|
displayValues: [...new Set(decisive.flatMap(item => {
|
||||||
|
const actual = item.comparison?.comparator.actual;
|
||||||
|
return actual === undefined ? [] : [typeof actual === 'object' && actual !== null ? JSON.stringify(actual) : String(actual)];
|
||||||
|
}))].sort(),
|
||||||
resultIds: group.map(item => item.id).sort(),
|
resultIds: group.map(item => item.id).sort(),
|
||||||
ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1
|
ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1
|
||||||
});
|
});
|
||||||
|
|
@ -112,7 +121,8 @@ export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimes
|
||||||
valueChanged = false): GpoSettingDecision => ({
|
valueChanged = false): GpoSettingDecision => ({
|
||||||
settingKey: item.key, scope: item.scope, identity: item.identity, baselineVerdict: 'Fail',
|
settingKey: item.key, scope: item.scope, identity: item.identity, baselineVerdict: 'Fail',
|
||||||
...(other?.verdict ? { targetVerdict: other.verdict } : {}), state, valueChanged, reasons,
|
...(other?.verdict ? { targetVerdict: other.verdict } : {}), state, valueChanged, reasons,
|
||||||
baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? []
|
baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? [],
|
||||||
|
baselineValues: item.displayValues, targetValues: other?.displayValues ?? []
|
||||||
});
|
});
|
||||||
if (sourceReasons.length) return {
|
if (sourceReasons.length) return {
|
||||||
version, state: 'not-comparable', reasons: sourceReasons,
|
version, state: 'not-comparable', reasons: sourceReasons,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export interface TimeslotChangeEntry {
|
||||||
counterpartIds: string[];
|
counterpartIds: string[];
|
||||||
identityKey?: string;
|
identityKey?: string;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
|
/** GPO settings only: retained configured values of A and B as Work displays them. */
|
||||||
|
values?: { baseline: string[]; target: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeslotSourceScore {
|
export interface TimeslotSourceScore {
|
||||||
|
|
@ -103,7 +105,7 @@ function gpoChanges(gpo: GpoTimeslotComparison, target?: GpoTimeslotInput): Time
|
||||||
sourceId: GPO_SOURCE_ID,
|
sourceId: GPO_SOURCE_ID,
|
||||||
change: item.state === 'confirmed-open' && item.valueChanged ? 'value-changed' : changeOf[item.state],
|
change: item.state === 'confirmed-open' && item.valueChanged ? 'value-changed' : changeOf[item.state],
|
||||||
side: 'baseline', recordIds: item.baselineResultIds, counterpartIds: item.targetResultIds,
|
side: 'baseline', recordIds: item.baselineResultIds, counterpartIds: item.targetResultIds,
|
||||||
identityKey: item.settingKey, reasons: item.reasons
|
identityKey: item.settingKey, reasons: item.reasons, values: { baseline: item.baselineValues, target: item.targetValues }
|
||||||
})),
|
})),
|
||||||
...gpo.newDeviations.map((item): TimeslotChangeEntry => ({
|
...gpo.newDeviations.map((item): TimeslotChangeEntry => ({
|
||||||
sourceId: GPO_SOURCE_ID, change: 'new', side: 'target', recordIds: item.targetResultIds, counterpartIds: [],
|
sourceId: GPO_SOURCE_ID, change: 'new', side: 'target', recordIds: item.targetResultIds, counterpartIds: [],
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { WorkPackagesWindow } from '../../remediation/WorkPackagesWindow';
|
||||||
import { UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
import { UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
||||||
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
|
import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display';
|
||||||
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
|
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
|
||||||
|
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
|
||||||
|
|
||||||
export function ActionlistControls() {
|
export function ActionlistControls() {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
|
|
@ -24,6 +25,7 @@ export function ActionlistControls() {
|
||||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||||
const packageOverview = remediation.status === 'ready' ? remediation.overview : undefined;
|
const packageOverview = remediation.status === 'ready' ? remediation.overview : undefined;
|
||||||
const complianceFacets = useComplianceWorkFacets();
|
const complianceFacets = useComplianceWorkFacets();
|
||||||
|
const comparisonFacets = useComparisonWorkFacets();
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const [packagesOpen, setPackagesOpen] = useState(false);
|
const [packagesOpen, setPackagesOpen] = useState(false);
|
||||||
const exportView = async (format: 'csv' | 'md' | 'html') => {
|
const exportView = async (format: 'csv' | 'md' | 'html') => {
|
||||||
|
|
@ -31,7 +33,7 @@ export function ActionlistControls() {
|
||||||
const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`);
|
const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`);
|
||||||
try {
|
try {
|
||||||
await showActivityFrame();
|
await showActivityFrame();
|
||||||
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets });
|
const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets, comparisonFacets });
|
||||||
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
|
const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter;
|
||||||
const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
|
const filters = Object.entries(app.facetFilters).map(([id, filter]) => {
|
||||||
const facet = facets.find(item => item.id === id);
|
const facet = facets.find(item => item.id === id);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog';
|
||||||
|
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
|
||||||
|
import { useTimeslotComparison, type ComparableTimeslot } from './use-timeslot-comparison';
|
||||||
|
import { changesWithWorkRecords } from './comparison-work-facets';
|
||||||
|
import './comparison.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId?: string;
|
||||||
|
/** Assessments of the timeslot open in Client Info; it is the default timeslot B. */
|
||||||
|
assessmentIds?: readonly string[];
|
||||||
|
/** Opens Work with the change facet; the extension point decides how. */
|
||||||
|
onOpenChange?(change: TimeslotChange, baseline: ComparableTimeslot, target: ComparableTimeslot): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display order of the change facts; better first, then worse, then neutral and unclear states. */
|
||||||
|
export const CHANGE_FACTS: Array<{ change: TimeslotChange; label: string; tone: 'better' | 'worse' | 'neutral'; glyph: string }> = [
|
||||||
|
{ change: 'resolved', label: 'Resolved', tone: 'better', glyph: '▼' },
|
||||||
|
{ change: 'new', label: 'New', tone: 'worse', glyph: '▲' },
|
||||||
|
{ change: 'regressed', label: 'Regressed', tone: 'worse', glyph: '▲' },
|
||||||
|
{ change: 'value-changed', label: 'Value changed', tone: 'neutral', glyph: '≠' },
|
||||||
|
{ change: 'unchanged', label: 'Unchanged', tone: 'neutral', glyph: '=' },
|
||||||
|
{ change: 'unverified', label: 'Unverified, still open', tone: 'neutral', glyph: '?' },
|
||||||
|
{ change: 'not-comparable', label: 'Not comparable', tone: 'neutral', glyph: '○' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REASONS: Record<string, string> = {
|
||||||
|
'baseline-source-missing': 'Not in timeslot A',
|
||||||
|
'target-source-missing': 'Not in timeslot B',
|
||||||
|
'baseline-source-ambiguous': 'Several runs in timeslot A',
|
||||||
|
'target-source-ambiguous': 'Several runs in timeslot B',
|
||||||
|
'no-comparable-scan-pair': 'No comparable scan pair',
|
||||||
|
'scan-scope-changed': 'Assessed scope changed',
|
||||||
|
'comparison-scope-changed': 'Assessed scope changed',
|
||||||
|
'processing-contract-incompatible': 'Processed with different import versions',
|
||||||
|
'processing-contract-unknown': 'Import version unknown',
|
||||||
|
'newer-observation-not-verified': 'No newer scan or backup time',
|
||||||
|
'no-shared-decided-settings': 'No setting decided in both timeslots',
|
||||||
|
'comparison-profile-unavailable': 'Scanner not yet comparable',
|
||||||
|
'comparison-profile-missing': 'Scanner not yet comparable'
|
||||||
|
};
|
||||||
|
export const reasonText = (reasons: string[]) => [...new Set(reasons.map(reason => REASONS[reason] ?? reason))].join(' · ');
|
||||||
|
|
||||||
|
export function sourceName(sourceId: string): string {
|
||||||
|
if (sourceId === GPO_SOURCE_ID) return 'GPOs';
|
||||||
|
return toolProfileDefinitions.find(item => item.id === sourceId)?.name ?? sourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const date = (value: string) => {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
};
|
||||||
|
const rounded = (value?: number) => value === undefined ? undefined : Math.round(value);
|
||||||
|
|
||||||
|
function Delta({ before, after }: { before?: number; after?: number }) {
|
||||||
|
if (before === undefined || after === undefined) return <span className="comparison-delta is-neutral">—</span>;
|
||||||
|
const change = after - before;
|
||||||
|
if (change === 0) return <span className="comparison-delta is-neutral">= No change</span>;
|
||||||
|
return change > 0
|
||||||
|
? <span className="comparison-delta is-better">▲ +{change} Better</span>
|
||||||
|
: <span className="comparison-delta is-worse">▼ −{Math.abs(change)} Worse</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceRows(report: TimeslotComparisonReport) {
|
||||||
|
return report.sources.map(source => {
|
||||||
|
const changes = report.changes.filter(item => item.sourceId === source.sourceId);
|
||||||
|
const count = (change: TimeslotChange) => changes.filter(item => item.change === change).length;
|
||||||
|
const score = report.score.sources.find(item => item.sourceId === source.sourceId);
|
||||||
|
return { source, count, score };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client Info `Compare` tab content of the Comparison Engine (mockup A, user decision 2026-10-13). */
|
||||||
|
export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: Props) {
|
||||||
|
const id = useId();
|
||||||
|
const comparison = useTimeslotComparison(projectId, assessmentIds);
|
||||||
|
const { state, baseline, target, earlier, timeslots, targetIsOpen } = comparison;
|
||||||
|
const report = state.status === 'ready' ? state.report : undefined;
|
||||||
|
// Work shows the open timeslot; only changes with a record there can open Work.
|
||||||
|
const openable = report && targetIsOpen && onOpenChange ? changesWithWorkRecords(report) : new Set<TimeslotChange>();
|
||||||
|
const option = (item: ComparableTimeslot) => <option key={item.id} value={item.id}>{date(item.date)}</option>;
|
||||||
|
|
||||||
|
return <section className="comparison-tab" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
|
||||||
|
<header className="comparison-tab__header">
|
||||||
|
<h2 id={`${id}-title`}>Compare</h2>
|
||||||
|
{timeslots.length > 1 && target && <div className="comparison-tab__timeslots">
|
||||||
|
<label>A
|
||||||
|
<select className="comparison-tab__select" value={baseline?.id ?? ''} disabled={!earlier.length}
|
||||||
|
onChange={event => comparison.selectBaseline(event.target.value)}>{earlier.map(option)}</select>
|
||||||
|
</label>
|
||||||
|
<span aria-hidden="true" className="comparison-tab__arrow">→</span>
|
||||||
|
<label>B
|
||||||
|
<select className="comparison-tab__select" value={target.id} onChange={event => comparison.selectTarget(event.target.value)}>
|
||||||
|
{timeslots.map(option)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>}
|
||||||
|
<span className="comparison-tab__note">Read-only comparison · verdicts and scores stay unchanged</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{state.status === 'loading' && <p className="comparison-tab__status">Comparing timeslots…</p>}
|
||||||
|
{state.status === 'error' && <p className="comparison-tab__status" role="alert">{state.message}</p>}
|
||||||
|
{state.status === 'unavailable' && <p className="comparison-tab__status">
|
||||||
|
{state.reason === 'single-timeslot' ? 'This client has only one timeslot. A comparison needs two.' : 'There is no earlier timeslot to compare with.'}
|
||||||
|
</p>}
|
||||||
|
|
||||||
|
{report && baseline && target && <>
|
||||||
|
<div className="comparison-tab__grid">
|
||||||
|
<section className="comparison-tile" aria-labelledby={`${id}-score`}>
|
||||||
|
<h3 id={`${id}-score`}>Overall Score</h3>
|
||||||
|
<dl className="comparison-score">
|
||||||
|
<div><dt>A · {date(baseline.date)}</dt><dd>{rounded(report.score.baselineScore) ?? '—'}</dd></div>
|
||||||
|
<div><dt>B · {date(target.date)}</dt><dd>{rounded(report.score.targetScore) ?? '—'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<Delta before={rounded(report.score.baselineScore)} after={rounded(report.score.targetScore)}/>
|
||||||
|
<p className="comparison-tile__note">{report.score.change === undefined
|
||||||
|
? 'No source has a comparable score in both timeslots.'
|
||||||
|
: `Comparable sources only: ${report.score.sources.filter(item => item.change !== undefined).map(item => sourceName(item.sourceId)).join(', ')}.`}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="comparison-tile comparison-tile--wide" aria-labelledby={`${id}-changes`}>
|
||||||
|
<h3 id={`${id}-changes`}>Changes</h3>
|
||||||
|
{!targetIsOpen && <p className="comparison-tile__note">Open timeslot B in Client Info to show these changes in Work.</p>}
|
||||||
|
<div className="comparison-facts">
|
||||||
|
{CHANGE_FACTS.map(fact => {
|
||||||
|
const count = fact.change === 'not-comparable'
|
||||||
|
? report.summary['not-comparable'] + report.summary.targetNotComparable : report.summary[fact.change];
|
||||||
|
return <button key={fact.change} type="button" className="comparison-fact" disabled={!count || !openable.has(fact.change)}
|
||||||
|
onClick={() => onOpenChange?.(fact.change, baseline, target)}>
|
||||||
|
<span className="comparison-fact__label">{fact.label}</span>
|
||||||
|
<span className={`comparison-fact__value is-${fact.tone}`}><span aria-hidden="true">{fact.glyph} </span>{count.toLocaleString('en-US')}</span>
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="comparison-tile" aria-labelledby={`${id}-sources`}>
|
||||||
|
<h3 id={`${id}-sources`}>Sources</h3>
|
||||||
|
<div className="comparison-table-scroll">
|
||||||
|
<table className="client-information-table comparison-table">
|
||||||
|
<thead><tr><th>Source</th><th>Resolved</th><th>New</th><th>Regressed</th><th>Value changed</th><th>Unverified</th>
|
||||||
|
<th>Not comparable</th><th>Score A</th><th>Score B</th><th>Change</th><th>Comparison basis</th></tr></thead>
|
||||||
|
<tbody>{sourceRows(report).map(({ source, count, score }) => <tr key={source.sourceId}>
|
||||||
|
<td>{sourceName(source.sourceId)}</td>
|
||||||
|
<td>{count('resolved')}</td><td>{count('new')}</td><td>{count('regressed')}</td><td>{count('value-changed')}</td>
|
||||||
|
<td>{count('unverified')}</td><td>{count('not-comparable')}</td>
|
||||||
|
<td>{rounded(score?.baselineScore) ?? '—'}</td><td>{rounded(score?.targetScore) ?? '—'}</td>
|
||||||
|
<td><Delta before={rounded(score?.baselineScore)} after={rounded(score?.targetScore)}/></td>
|
||||||
|
<td>{source.state === 'compared' ? (score?.reasons.length ? reasonText(score.reasons) : 'Compared') : reasonText(source.reasons)}</td>
|
||||||
|
</tr>)}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { TriangleIcon } from '../../components/TriangleIcon';
|
||||||
|
import type { FindingRecord } from '../../domain/finding';
|
||||||
|
import { policyComparisonDisplayLines } from '../../profiles/group-policy/presentation';
|
||||||
|
import type { TimeslotChange, TimeslotChangeEntry } from '../../comparison/timeslot-comparison-report';
|
||||||
|
import { useOptionalApp } from '../../app/AppProvider';
|
||||||
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
|
import { comparisonWorkFacetIndex } from './comparison-work-facets';
|
||||||
|
import { usePublishedComparison } from './use-timeslot-comparison';
|
||||||
|
import { CHANGE_FACTS, date, reasonText } from './ComparisonTab';
|
||||||
|
|
||||||
|
/** What each change says about timeslot A and B; never a re-derived verdict, only the comparison's statement. */
|
||||||
|
const STATES: Record<TimeslotChange, { a: string; b: string }> = {
|
||||||
|
resolved: { a: 'Deviation', b: 'Passed' },
|
||||||
|
unchanged: { a: 'Deviation', b: 'Deviation' },
|
||||||
|
'value-changed': { a: 'Deviation', b: 'Deviation' },
|
||||||
|
unverified: { a: 'Deviation', b: 'Not decisive' },
|
||||||
|
'not-applicable': { a: 'Deviation', b: 'Not applicable' },
|
||||||
|
'not-comparable': { a: 'Not comparable', b: 'Not comparable' },
|
||||||
|
new: { a: 'No deviation', b: 'Deviation' },
|
||||||
|
regressed: { a: 'Passed', b: 'Deviation' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function Values({ values }: { values: string[] }) {
|
||||||
|
if (!values.length) return <>—</>;
|
||||||
|
return <>{values.map((value, index) => <p className="policy-guidance-text" key={index}>{policyComparisonDisplayLines(value).map((line, lineIndex) =>
|
||||||
|
<Fragment key={lineIndex}>{lineIndex > 0 && '\n'}{line.emphasized ? <strong>{line.text}</strong> : line.text}</Fragment>)}</p>)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finding Reader `Compare` section of the Comparison Engine (mockup B, user decision 2026-10-13): the change of
|
||||||
|
* this finding against timeslot A, both states and, for a GPO setting, both retained values side by side.
|
||||||
|
* Collapsed like the Context section; present only while a comparison of the open timeslot is loaded.
|
||||||
|
*/
|
||||||
|
export function FindingComparison({ record }: { record: FindingRecord }) {
|
||||||
|
// Like the Context section, the reader also renders without AppProvider; then no comparison applies.
|
||||||
|
const app = useOptionalApp();
|
||||||
|
const enabled = useEnginePreferences().comparison;
|
||||||
|
const comparison = usePublishedComparison(app?.targetProjectId, app?.activeAssessmentIds ?? []);
|
||||||
|
const index = enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
|
||||||
|
const entry: TimeslotChangeEntry | undefined = index?.entryOf(record);
|
||||||
|
if (!index || !entry) return null;
|
||||||
|
const fact = CHANGE_FACTS.find(item => item.change === entry.change);
|
||||||
|
const { baseline, target } = index.comparison;
|
||||||
|
const state = STATES[entry.change];
|
||||||
|
const reasons = entry.reasons.length ? reasonText(entry.reasons) : '';
|
||||||
|
return <details className="finding-optional-detail finding-comparison">
|
||||||
|
<summary><TriangleIcon direction="right"/>Compare <span className="finding-comparison__scope">A · {date(baseline.date)} → B · {date(target.date)}</span></summary>
|
||||||
|
<p className="finding-comparison__change">
|
||||||
|
<span className={`comparison-fact__value is-${fact?.tone ?? 'neutral'}`}><span aria-hidden="true">{fact?.glyph} </span>{fact?.label ?? entry.change}</span>
|
||||||
|
{entry.change === 'value-changed' && <span className="finding-comparison__note">Still a deviation; the configured value differs from timeslot A.</span>}
|
||||||
|
</p>
|
||||||
|
<div className="comparison-table-scroll">
|
||||||
|
<table className="client-information-table comparison-table">
|
||||||
|
<thead><tr><th></th><th>A · {date(baseline.date)}</th><th>B · {date(target.date)}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Result</td><td>{state.a}</td><td>{state.b}</td></tr>
|
||||||
|
{entry.values && <tr><td>Configured value</td><td><Values values={entry.values.baseline}/></td><td><Values values={entry.values.target}/></td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{reasons && <p className="finding-comparison__note">{reasons}</p>}
|
||||||
|
<p className="finding-comparison__note">The comparison reads both timeslots and changes no verdict or score.</p>
|
||||||
|
</details>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { useApp } from '../../app/AppProvider';
|
||||||
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
|
import type { FindingRecord } from '../../domain/finding';
|
||||||
|
import { gpoWorkSettingIdentity } from '../results-browser/work-record-lookup';
|
||||||
|
import type { DynamicFacet } from '../search-filter/dynamic-facets';
|
||||||
|
import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
|
||||||
|
import { usePublishedComparison, type PublishedComparison } from './use-timeslot-comparison';
|
||||||
|
|
||||||
|
/** Work facet of the Comparison Engine; present only while a comparison of the open timeslot B is loaded. */
|
||||||
|
export const COMPARISON_CHANGE_FACET_ID = 'comparison:change';
|
||||||
|
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
||||||
|
|
||||||
|
/** Facet order: better first, then worse, then neutral and unclear states. */
|
||||||
|
export const CHANGE_LABELS: Array<{ change: TimeslotChange; label: string }> = [
|
||||||
|
{ change: 'resolved', label: 'Resolved' },
|
||||||
|
{ change: 'new', label: 'New' },
|
||||||
|
{ change: 'regressed', label: 'Regressed' },
|
||||||
|
{ change: 'value-changed', label: 'Value changed' },
|
||||||
|
{ change: 'unchanged', label: 'Unchanged' },
|
||||||
|
{ change: 'unverified', label: 'Unverified, still open' },
|
||||||
|
{ change: 'not-applicable', label: 'Not applicable' },
|
||||||
|
{ change: 'not-comparable', label: 'Not comparable' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ComparisonWorkFacetIndex {
|
||||||
|
facets: DynamicFacet[];
|
||||||
|
/** The change of a Work record of timeslot B, if the comparison names one. */
|
||||||
|
changeOf(record: FindingRecord): TimeslotChange | undefined;
|
||||||
|
/** The full comparison entry behind that change, for the Finding Reader. */
|
||||||
|
entryOf(record: FindingRecord): TimeslotChangeEntry | undefined;
|
||||||
|
comparison: PublishedComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpoKey = (scope: unknown, identity: unknown) => JSON.stringify([scope, identity]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the report onto Work records of timeslot B: scanner records by occurrence (a current record, or the
|
||||||
|
* B counterpart of an A deviation), GPO settings by scope and technical identity, like Result DB drill-downs.
|
||||||
|
* A deviation of A without a B record has no Work row in B and is therefore not offered.
|
||||||
|
*/
|
||||||
|
function entryIndex(report: TimeslotComparisonReport) {
|
||||||
|
const byOccurrence = new Map<string, TimeslotChangeEntry>();
|
||||||
|
const bySetting = new Map<string, TimeslotChangeEntry>();
|
||||||
|
for (const entry of report.changes) {
|
||||||
|
if (entry.sourceId === GPO_SOURCE_ID) {
|
||||||
|
if (entry.identityKey && (entry.side === 'target' || entry.counterpartIds.length)) bySetting.set(entry.identityKey, entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry);
|
||||||
|
}
|
||||||
|
return (record: FindingRecord): TimeslotChangeEntry | undefined => {
|
||||||
|
if (record.findingOccurrenceId) return byOccurrence.get(record.findingOccurrenceId);
|
||||||
|
const identity = gpoWorkSettingIdentity(record.id);
|
||||||
|
return identity ? bySetting.get(gpoKey(identity.scope, identity.settingId)) : undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes that have at least one Work record in timeslot B, so the Compare tab can open them in Work. */
|
||||||
|
export function changesWithWorkRecords(report: TimeslotComparisonReport): Set<TimeslotChange> {
|
||||||
|
return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change));
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexes = new WeakMap<PublishedComparison, ComparisonWorkFacetIndex>();
|
||||||
|
export function comparisonWorkFacetIndex(comparison: PublishedComparison): ComparisonWorkFacetIndex {
|
||||||
|
const existing = indexes.get(comparison);
|
||||||
|
if (existing) return existing;
|
||||||
|
const entryOf = entryIndex(comparison.report);
|
||||||
|
const changeOf = (record: FindingRecord) => entryOf(record)?.change;
|
||||||
|
const date = new Date(comparison.baseline.date);
|
||||||
|
const label = Number.isNaN(date.getTime()) ? comparison.baseline.date
|
||||||
|
: date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
const index: ComparisonWorkFacetIndex = {
|
||||||
|
comparison,
|
||||||
|
changeOf,
|
||||||
|
entryOf,
|
||||||
|
facets: [{
|
||||||
|
id: COMPARISON_CHANGE_FACET_ID,
|
||||||
|
label: 'Change',
|
||||||
|
source: 'derived',
|
||||||
|
kind: 'category',
|
||||||
|
description: `Comparison Engine: change of each finding and GPO setting against timeslot A (${label}). It does not change results.`,
|
||||||
|
values: CHANGE_LABELS.map(item => ({ value: item.change, label: item.label, count: 0 })),
|
||||||
|
valuesFor: record => { const change = changeOf(record); return change ? [change] : []; }
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
indexes.set(comparison, index);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the `Change` facet directly before the permanent final `Source: Scan` control. */
|
||||||
|
export function withComparisonFacets(facets: DynamicFacet[], index: ComparisonWorkFacetIndex | undefined): DynamicFacet[] {
|
||||||
|
if (!index) return facets;
|
||||||
|
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
||||||
|
return sourceScan < 0 ? [...facets, ...index.facets]
|
||||||
|
: [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The facet index for Work, or undefined while the engine is off or no comparison of the open timeslot is loaded. */
|
||||||
|
export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined {
|
||||||
|
const app = useApp();
|
||||||
|
const enabled = useEnginePreferences().comparison;
|
||||||
|
const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []);
|
||||||
|
return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/* Comparison Engine UI. Shared tokens only; better and worse always pair their colour with a glyph and text. */
|
||||||
|
|
||||||
|
.comparison-tab { display: grid; gap: var(--segment-gap); margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; }
|
||||||
|
.comparison-tab__header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); }
|
||||||
|
.comparison-tab__header > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||||
|
.comparison-tab__timeslots { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
|
||||||
|
.comparison-tab__timeslots label { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); }
|
||||||
|
.comparison-tab__select { height: var(--btn-h); padding: 0 var(--space-2); border: var(--bw-hairline) solid var(--border-default); border-radius: var(--btn-radius); background: var(--surface-input); color: var(--text-primary); font: var(--fw-semibold) var(--type-button-size) var(--font-ui); }
|
||||||
|
.comparison-tab__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-hairline); }
|
||||||
|
.comparison-tab__arrow { color: var(--text-secondary); }
|
||||||
|
.comparison-tab__note { margin-inline-start: auto; font-size: var(--type-meta-size); color: var(--text-secondary); }
|
||||||
|
.comparison-tab__status { margin: 0; font-size: var(--type-body-size); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.comparison-tab__grid { display: grid; grid-template-columns: minmax(var(--tile-environment-min-width), 1fr) minmax(0, 2fr); gap: var(--segment-gap); align-items: stretch; }
|
||||||
|
@media (max-width: 900px) { .comparison-tab__grid { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
.comparison-tile { min-width: 0; padding: var(--tile-inset); border: var(--bw-hairline) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); }
|
||||||
|
.comparison-tile > h3 { margin: 0 0 var(--space-3); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||||
|
.comparison-tile__note { margin: var(--space-3) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.comparison-score { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 var(--space-2); }
|
||||||
|
.comparison-score > div { padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); }
|
||||||
|
.comparison-score dt { font-size: var(--type-table-body-size); color: var(--text-secondary); }
|
||||||
|
.comparison-score dd { margin: 0; font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.comparison-delta { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.comparison-delta.is-better, .comparison-fact__value.is-better { color: var(--outcome-pass); }
|
||||||
|
.comparison-delta.is-worse, .comparison-fact__value.is-worse { color: var(--outcome-fail); }
|
||||||
|
.comparison-delta.is-neutral, .comparison-fact__value.is-neutral { color: var(--text-primary); }
|
||||||
|
|
||||||
|
.comparison-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
|
||||||
|
.comparison-fact { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.comparison-fact:hover:not(:disabled) { background: var(--tile-surface-hover); }
|
||||||
|
.comparison-fact:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
|
.comparison-fact:disabled { cursor: default; }
|
||||||
|
.comparison-fact__label { font-size: var(--type-table-body-size); color: var(--text-secondary); }
|
||||||
|
.comparison-fact__value { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.comparison-table-scroll { overflow-x: auto; }
|
||||||
|
.comparison-table td { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* Finding Reader `Compare` section, collapsed like Context. */
|
||||||
|
.finding-comparison__scope { margin-inline-start: var(--space-2); font-size: var(--type-meta-size); font-weight: var(--fw-regular); color: var(--text-secondary); }
|
||||||
|
.finding-comparison__change { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); margin: var(--space-2) 0; }
|
||||||
|
.finding-comparison__note { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); }
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
||||||
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
|
import { groupProjectImportFiles, type ScanImportGroup } from '../project-import/datahub-import-groups';
|
||||||
|
import type { TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report';
|
||||||
|
|
||||||
|
const api = new AssessorApiClient();
|
||||||
|
|
||||||
|
export interface ComparableTimeslot {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
assessmentIds: string[];
|
||||||
|
scanRunIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeslotComparisonState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'error'; message: string }
|
||||||
|
| { status: 'unavailable'; reason: 'single-timeslot' | 'no-earlier-timeslot' }
|
||||||
|
| { status: 'ready'; report: TimeslotComparisonReport };
|
||||||
|
|
||||||
|
/** The comparison the Compare tab last loaded for the timeslot open in Client Info; Work reads it for its facet. */
|
||||||
|
export interface PublishedComparison {
|
||||||
|
projectId: string;
|
||||||
|
/** Sorted assessment IDs of timeslot B, the open timeslot. */
|
||||||
|
targetAssessmentIds: string[];
|
||||||
|
baseline: ComparableTimeslot;
|
||||||
|
target: ComparableTimeslot;
|
||||||
|
report: TimeslotComparisonReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (values: readonly string[]) => [...values].sort().join('\u0000');
|
||||||
|
const sameSet = (left: readonly string[], right: readonly string[]) => key(left) === key(right);
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let storeVersion = 0;
|
||||||
|
const notify = () => { storeVersion++; listeners.forEach(listener => listener()); };
|
||||||
|
const version = () => storeVersion;
|
||||||
|
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
|
||||||
|
// Timeslot A chosen per client and timeslot B, shared by the Compare tab and Work.
|
||||||
|
const baselineChoices = new Map<string, string>();
|
||||||
|
let published: PublishedComparison | undefined;
|
||||||
|
|
||||||
|
export function publishedComparison(): PublishedComparison | undefined { return published; }
|
||||||
|
/** The published comparison while it belongs to this client and open timeslot; never a comparison of another scope. */
|
||||||
|
export function usePublishedComparison(projectId: string | undefined, openAssessmentIds: readonly string[]): PublishedComparison | undefined {
|
||||||
|
const current = useSyncExternalStore(subscribe, publishedComparison, publishedComparison);
|
||||||
|
return current && current.projectId === projectId && sameSet(current.targetAssessmentIds, openAssessmentIds) ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test support. */
|
||||||
|
export function resetTimeslotComparisonRequestsForTests(): void {
|
||||||
|
reports.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparable(group: ScanImportGroup): ComparableTimeslot {
|
||||||
|
return { id: group.id, date: group.fileDate, assessmentIds: [...group.assessmentIds].sort(), scanRunIds: [...group.scanRunIds].sort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeslotRequests = new Map<string, Promise<ComparableTimeslot[]>>();
|
||||||
|
/** Oldest first, the order in which a comparison reads A before B. Reloaded for every newly opened scope. */
|
||||||
|
function loadTimeslots(projectId: string, scope: string): Promise<ComparableTimeslot[]> {
|
||||||
|
const requestKey = `${projectId}\u0000${scope}`;
|
||||||
|
let request = timeslotRequests.get(requestKey);
|
||||||
|
if (!request) {
|
||||||
|
request = Promise.all([
|
||||||
|
api.listProjectImportFiles(projectId), api.listProjectAssessmentHistory(projectId), api.listProjectGpoBackupStatus(projectId)
|
||||||
|
]).then(([files, history, gpo]) => groupProjectImportFiles(files, history.assessments, history.scanRuns, gpo.records)
|
||||||
|
.filter(group => group.assessmentIds.length)
|
||||||
|
.map(comparable)
|
||||||
|
.sort((left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id)));
|
||||||
|
timeslotRequests.set(requestKey, request);
|
||||||
|
while (timeslotRequests.size > 8) timeslotRequests.delete(timeslotRequests.keys().next().value!);
|
||||||
|
request.catch(() => timeslotRequests.delete(requestKey));
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reports = new Map<string, Promise<TimeslotComparisonReport>>();
|
||||||
|
function requestReport(projectId: string, baseline: ComparableTimeslot, target: ComparableTimeslot) {
|
||||||
|
const reportKey = JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]);
|
||||||
|
let request = reports.get(reportKey);
|
||||||
|
if (!request) {
|
||||||
|
request = api.getTimeslotComparison<TimeslotComparisonReport>(projectId, baseline, target);
|
||||||
|
reports.set(reportKey, request);
|
||||||
|
while (reports.size > 8) reports.delete(reports.keys().next().value!);
|
||||||
|
request.catch(() => reports.delete(reportKey));
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeslots of the open client and the explicit A/B report. B defaults to the timeslot open in Client
|
||||||
|
* Info, A to the timeslot directly before it; both stay selectable, A always before B. A report whose B is
|
||||||
|
* the open timeslot is published for the Work `Change` facet.
|
||||||
|
*/
|
||||||
|
export function useTimeslotComparison(projectId: string | undefined, openAssessmentIds: readonly string[]) {
|
||||||
|
const openKey = key(openAssessmentIds);
|
||||||
|
const [timeslots, setTimeslots] = useState<{ scope: string; items?: ComparableTimeslot[]; error?: string }>();
|
||||||
|
const [targetChoice, setTargetChoice] = useState<{ scope: string; id: string }>();
|
||||||
|
const [result, setResult] = useState<{ key: string; state: TimeslotComparisonState }>();
|
||||||
|
const scope = `${projectId}\u0000${openKey}`;
|
||||||
|
useSyncExternalStore(subscribe, version, version);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
let current = true;
|
||||||
|
loadTimeslots(projectId, openKey).then(items => { if (current) setTimeslots({ scope, items }); },
|
||||||
|
error => { if (current) setTimeslots({ scope, error: error instanceof Error ? error.message : 'Timeslots could not be loaded.' }); });
|
||||||
|
return () => { current = false; };
|
||||||
|
}, [scope]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const items = timeslots?.scope === scope ? timeslots.items : undefined;
|
||||||
|
const open = useMemo(() => items?.find(item => sameSet(item.assessmentIds, openAssessmentIds)), [items, openKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
const target = items?.find(item => targetChoice?.scope === scope && item.id === targetChoice.id) ?? open ?? items?.[items.length - 1];
|
||||||
|
const earlier = items && target ? items.slice(0, items.indexOf(target)) : [];
|
||||||
|
const choiceKey = target ? JSON.stringify([projectId, target.id]) : '';
|
||||||
|
const baseline = earlier.find(item => item.id === baselineChoices.get(choiceKey)) ?? earlier[earlier.length - 1];
|
||||||
|
const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.id, target.id]) : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || !baseline || !target) return;
|
||||||
|
let current = true;
|
||||||
|
requestReport(projectId, baseline, target).then(report => {
|
||||||
|
if (!current) return;
|
||||||
|
setResult({ key: requestKey, state: { status: 'ready', report } });
|
||||||
|
if (target === open) {
|
||||||
|
published = { projectId, targetAssessmentIds: [...target.assessmentIds], baseline, target, report };
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
}, error => { if (current) setResult({ key: requestKey, state: { status: 'error', message: error instanceof Error ? error.message : 'The comparison could not be loaded.' } }); });
|
||||||
|
return () => { current = false; };
|
||||||
|
}, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
let state: TimeslotComparisonState;
|
||||||
|
if (timeslots?.scope === scope && timeslots.error) state = { status: 'error', message: timeslots.error };
|
||||||
|
else if (!items) state = { status: 'loading' };
|
||||||
|
else if (items.length < 2) state = { status: 'unavailable', reason: 'single-timeslot' };
|
||||||
|
else if (!baseline) state = { status: 'unavailable', reason: 'no-earlier-timeslot' };
|
||||||
|
else state = result?.key === requestKey ? result.state : { status: 'loading' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
state, timeslots: items ?? [], baseline, target, earlier,
|
||||||
|
/** True while B is the timeslot open in Client Info, so Work shows the same records. */
|
||||||
|
targetIsOpen: Boolean(target && target === open),
|
||||||
|
selectBaseline: (id: string) => { if (choiceKey) { baselineChoices.set(choiceKey, id); notify(); } },
|
||||||
|
selectTarget: (id: string) => setTargetChoice({ scope, id })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,8 @@ import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurity
|
||||||
import { managementCategoryTexts } from '../../reporting/management-report-texts';
|
import { managementCategoryTexts } from '../../reporting/management-report-texts';
|
||||||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||||||
import { ClientIsoControls, preloadClientComplianceOverview } from '../../compliance/ui/ClientIsoControls';
|
import { ClientIsoControls, preloadClientComplianceOverview } from '../../compliance/ui/ClientIsoControls';
|
||||||
|
import { ComparisonTab } from '../comparison/ComparisonTab';
|
||||||
|
import { COMPARISON_CHANGE_FACET_ID } from '../comparison/comparison-work-facets';
|
||||||
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
|
||||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||||
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
import { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||||||
|
|
@ -1256,6 +1258,17 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
setView('columns');
|
setView('columns');
|
||||||
onOpenWork();
|
onOpenWork();
|
||||||
};
|
};
|
||||||
|
// Comparison Engine tab (user decision 2026-10-13): the open timeslot is B, an earlier timeslot A.
|
||||||
|
const comparisonContent = engines.comparison && app.backendStatus === 'connected' && app.targetProjectId
|
||||||
|
? <div className="dashboard-page client-command-center"><ComparisonTab projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds}
|
||||||
|
onOpenChange={change => jump(() => {
|
||||||
|
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||||
|
if (targetSheet) app.setActiveSheet(targetSheet.id);
|
||||||
|
// A resolved finding is Passed in timeslot B; every other change keeps the standard Deviation status.
|
||||||
|
setStatusFilter(change === 'resolved' ? 'all' : 'deviation');
|
||||||
|
setFacetFilter(COMPARISON_CHANGE_FACET_ID, { values: [change] });
|
||||||
|
}, false)}/></div>
|
||||||
|
: null;
|
||||||
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
|
// Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work.
|
||||||
const complianceContent = showCompliance ? <ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
|
const complianceContent = showCompliance ? <ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
|
||||||
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
|
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
|
||||||
|
|
@ -1607,6 +1620,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
content: <ClientWorkPackagesPanel key={reportScopeKey}/> }] : []),
|
content: <ClientWorkPackagesPanel key={reportScopeKey}/> }] : []),
|
||||||
...(defenderContent ? [{ id: 'defender', label: 'Microsoft Defender for IoT', content: defenderContent, lazy: true }] : []),
|
...(defenderContent ? [{ id: 'defender', label: 'Microsoft Defender for IoT', content: defenderContent, lazy: true }] : []),
|
||||||
...(complianceContent ? [{ id: 'compliance', label: 'Compliance', content: complianceContent, lazy: true }] : []),
|
...(complianceContent ? [{ id: 'compliance', label: 'Compliance', content: complianceContent, lazy: true }] : []),
|
||||||
|
...(comparisonContent ? [{ id: 'compare', label: 'Compare', content: comparisonContent, lazy: true }] : []),
|
||||||
...(showAiOverview ? [{ id: 'ai', label: 'AI Overview', content: <AiOverviewPanel controller={aiOverview} />, lazy: true }] : [])]} />
|
...(showAiOverview ? [{ id: 'ai', label: 'AI Overview', content: <AiOverviewPanel controller={aiOverview} />, lazy: true }] : [])]} />
|
||||||
{scoringOpen && <ScoringMethodology sources={scoringOpen === 'security' ? securityAnalysis.sourceScores : sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(null)} />}</>
|
{scoringOpen && <ScoringMethodology sources={scoringOpen === 'security' ? securityAnalysis.sourceScores : sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(null)} />}</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ const nodes = [
|
||||||
{ id: 'compliance', engine: true, name: 'Compliance Engine', caption: 'Regulatory control mapping · optional', description: 'Maps findings and GPO baseline comparisons to regulatory controls; ISO/IEC 27001/27002 is the first framework. Reads Core results through the read-only Core port and writes only its own tables. Proposals never change verdicts, scores or client records.' },
|
{ id: 'compliance', engine: true, name: 'Compliance Engine', caption: 'Regulatory control mapping · optional', description: 'Maps findings and GPO baseline comparisons to regulatory controls; ISO/IEC 27001/27002 is the first framework. Reads Core results through the read-only Core port and writes only its own tables. Proposals never change verdicts, scores or client records.' },
|
||||||
{ id: 'compliance-tables', name: 'Compliance tables', caption: 'Mappings · decisions · batches', description: 'Mapping runs, accept/reject decisions and batches in three engine-owned tables of the canonical PostgreSQL. Written through a separate small pool; engine writes never advance the Core revision.' },
|
{ id: 'compliance-tables', name: 'Compliance tables', caption: 'Mappings · decisions · batches', description: 'Mapping runs, accept/reject decisions and batches in three engine-owned tables of the canonical PostgreSQL. Written through a separate small pool; engine writes never advance the Core revision.' },
|
||||||
{ id: 'retrieval', name: 'Retrieval & model', caption: 'Qdrant index · LiteLLM', description: 'The rebuildable Qdrant index of the uploaded control corpus and the configured LiteLLM model. They receive privacy-safe finding text and corpus evidence, never assets or customer values.' },
|
{ id: 'retrieval', name: 'Retrieval & model', caption: 'Qdrant index · LiteLLM', description: 'The rebuildable Qdrant index of the uploaded control corpus and the configured LiteLLM model. They receive privacy-safe finding text and corpus evidence, never assets or customer values.' },
|
||||||
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' }
|
{ id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' },
|
||||||
|
{ id: 'comparison', engine: true, name: 'Comparison Engine', caption: 'Timeslot comparison · optional', description: 'Compares two timeslots A and B of one client: verified resolved, still open, unverified, new and regressed findings, neutral GPO value changes and a score delta over sources comparable in both. Reads both timeslots as Work does through the read-only Core port; stores nothing yet. Verdicts, scores, reviews and Result DB stay unchanged.' },
|
||||||
|
{ id: 'comparison-ui', name: 'Comparison in the UI', caption: 'Client Info · Work · Finding Reader', description: 'The Client Info Compare tab, the Work Change facet and the Finding Reader Compare section appear through existing UI extension points. They never override technical results or client records.' }
|
||||||
];
|
];
|
||||||
const relationships = [
|
const relationships = [
|
||||||
{ from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' },
|
{ from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' },
|
||||||
|
|
@ -39,6 +41,8 @@ const relationships = [
|
||||||
{ from: 'port', to: 'compliance', label: 'Read only', detail: 'Single Result DB row by client, filtered Result DB pages, client, assessment and scan-run lists', path: 'M710 620 V750' },
|
{ from: 'port', to: 'compliance', label: 'Read only', detail: 'Single Result DB row by client, filtered Result DB pages, client, assessment and scan-run lists', path: 'M710 620 V750' },
|
||||||
{ from: 'compliance', to: 'compliance-tables', label: 'Own data', detail: 'Mapping runs, decisions and batches; never Core tables', path: 'M600 800 H530', bidirectional: true },
|
{ from: 'compliance', to: 'compliance-tables', label: 'Own data', detail: 'Mapping runs, decisions and batches; never Core tables', path: 'M600 800 H530', bidirectional: true },
|
||||||
{ from: 'compliance', to: 'retrieval', label: 'Retrieval', detail: 'Privacy-safe query, retrieved control evidence and model proposals', path: 'M820 800 H890', bidirectional: true },
|
{ from: 'compliance', to: 'retrieval', label: 'Retrieval', detail: 'Privacy-safe query, retrieved control evidence and model proposals', path: 'M820 800 H890', bidirectional: true },
|
||||||
|
{ from: 'port', to: 'comparison', label: 'Read only', detail: 'Scanner runs and rolled-up GPO verdicts of both selected timeslots, read as Work reads them', path: 'M810 620 V710 H1160 V1050 H1180' },
|
||||||
|
{ from: 'comparison', to: 'comparison-ui', label: 'Changes', detail: 'Change categories and score delta through existing UI extension points', path: 'M1180 1080 H1110' },
|
||||||
{ from: 'compliance', to: 'compliance-ui', label: 'Proposals', detail: 'Grounded control proposals and decisions through existing UI extension points', path: 'M710 850 V900 H130 V850' }
|
{ from: 'compliance', to: 'compliance-ui', label: 'Proposals', detail: 'Grounded control proposals and decisions through existing UI extension points', path: 'M710 850 V900 H130 V850' }
|
||||||
];
|
];
|
||||||
const nodeName = (id: string) => nodes.find(node => node.id === id)!.name;
|
const nodeName = (id: string) => nodes.find(node => node.id === id)!.name;
|
||||||
|
|
@ -53,7 +57,7 @@ export function EngineDataFlow() {
|
||||||
<header className="db-engine-flow__heading"><h3>Engines & relationships</h3><span>Solid: implemented · Dashed: planned</span></header>
|
<header className="db-engine-flow__heading"><h3>Engines & relationships</h3><span>Solid: implemented · Dashed: planned</span></header>
|
||||||
<div className="db-engine-flow__scroll" role="region" aria-label="Interactive engine diagram" tabIndex={0}>
|
<div className="db-engine-flow__scroll" role="region" aria-label="Interactive engine diagram" tabIndex={0}>
|
||||||
<div className="db-engine-flow__map">
|
<div className="db-engine-flow__map">
|
||||||
<svg className="db-engine-flow__edges" width="1450" height="940" aria-hidden="true">
|
<svg className="db-engine-flow__edges" width="1450" height="1130" aria-hidden="true">
|
||||||
<defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs>
|
<defs><marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" /></marker></defs>
|
||||||
{relationships.map(edge => <path key={`${edge.from}-${edge.to}`} d={edge.path} markerEnd={`url(#${markerId})`} markerStart={edge.bidirectional ? `url(#${markerId})` : undefined}
|
{relationships.map(edge => <path key={`${edge.from}-${edge.to}`} d={edge.path} markerEnd={`url(#${markerId})`} markerStart={edge.bidirectional ? `url(#${markerId})` : undefined}
|
||||||
className={`${edge.planned ? 'is-planned' : ''} ${connections.includes(edge) ? 'is-related' : ''}`} />)}
|
className={`${edge.planned ? 'is-planned' : ''} ${connections.includes(edge) ? 'is-related' : ''}`} />)}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import type { BackendStatus } from '../../app/app-state';
|
||||||
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
|
import { RegulatoryMappingDialog } from '../../compliance/ui/RegulatoryMappingPanel';
|
||||||
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
|
import { FindingIsoControls } from '../../compliance/ui/FindingIsoControls';
|
||||||
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
|
import { FindingBsiRequirements } from '../../compliance/ui/FindingBsiRequirements';
|
||||||
|
import { FindingComparison } from '../comparison/FindingComparison';
|
||||||
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
|
import { regulatoryMappingResultId, useRegulatoryMapping } from '../../compliance/ui/use-regulatory-mapping';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -572,6 +573,8 @@ export function FindingReader({ record, title, review: storedReview, previousPol
|
||||||
&& <PolicyBaselineComparison comparisons={record.policyBaselineComparisons!} search={search}/>}
|
&& <PolicyBaselineComparison comparisons={record.policyBaselineComparisons!} search={search}/>}
|
||||||
{/* Context layer (Phase 13E): for every finding after the technical evidence, only while switched on; it never changes the result above. */}
|
{/* Context layer (Phase 13E): for every finding after the technical evidence, only while switched on; it never changes the result above. */}
|
||||||
<FindingContext record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
|
<FindingContext record={record} isPolicySetting={resolvedToolId === 'group-policy'}/>
|
||||||
|
{/* Comparison Engine: this finding against timeslot A, only while a comparison of the open timeslot is loaded. */}
|
||||||
|
<FindingComparison record={record}/>
|
||||||
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
|
{summary.map(field => <div className={/^remediation(?: guidance| recommendation)?$/.test(normalizeLabel(field.label)) ? 'reader-remediation' : undefined} key={`${field.index}-${field.label}`}><h4>{highlightText(field.label, search)}</h4>{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id'
|
||||||
? <p><code className="mono">{highlightText(field.value, search)}</code></p>
|
? <p><code className="mono">{highlightText(field.value, search)}</code></p>
|
||||||
: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
|
: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details'
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { DynamicFilterControls } from './DynamicFilterControls';
|
||||||
import { selectWorkFacets } from './work-facets';
|
import { selectWorkFacets } from './work-facets';
|
||||||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||||
import { COMPLIANCE_FACET_IDS, useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
|
import { COMPLIANCE_FACET_IDS, useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
|
||||||
|
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
|
||||||
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
|
||||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||||
import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine';
|
||||||
|
|
@ -98,11 +99,12 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps
|
||||||
const remediation = useWorkPackages();
|
const remediation = useWorkPackages();
|
||||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||||
const complianceFacets = useComplianceWorkFacets();
|
const complianceFacets = useComplianceWorkFacets();
|
||||||
|
const comparisonFacets = useComparisonWorkFacets();
|
||||||
const countedFacets = useMemo(
|
const countedFacets = useMemo(
|
||||||
() => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, {
|
() => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, {
|
||||||
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets
|
activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets, comparisonFacets
|
||||||
})),
|
})),
|
||||||
[actionOnly, activeProfile, activeSheet, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
|
[actionOnly, activeProfile, activeSheet, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter]
|
||||||
);
|
);
|
||||||
const visibleFacets = useMemo(
|
const visibleFacets = useMemo(
|
||||||
() => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)),
|
() => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache'
|
||||||
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
|
import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet';
|
||||||
import { withBusinessImpactFacet } from './business-impact-controls';
|
import { withBusinessImpactFacet } from './business-impact-controls';
|
||||||
import { withComplianceFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
|
import { withComplianceFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
|
||||||
|
import { withComparisonFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets';
|
||||||
import { readEnginePreferences } from '../../app/engine-preferences';
|
import { readEnginePreferences } from '../../app/engine-preferences';
|
||||||
import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index';
|
import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index';
|
||||||
|
|
||||||
|
|
@ -33,6 +34,8 @@ export interface RecordFilterOptions {
|
||||||
workPackages?: WorkPackageIndex;
|
workPackages?: WorkPackageIndex;
|
||||||
/** Compliance Engine ISO control and ISO theme facets; absent while the engine is off. */
|
/** Compliance Engine ISO control and ISO theme facets; absent while the engine is off. */
|
||||||
complianceFacets?: ComplianceWorkFacetIndex;
|
complianceFacets?: ComplianceWorkFacetIndex;
|
||||||
|
/** Comparison Engine `Change` facet; absent while the engine is off or no comparison is loaded. */
|
||||||
|
comparisonFacets?: ComparisonWorkFacetIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedRecords = createSheetDerivationCache<FindingRecord[]>();
|
const cachedRecords = createSheetDerivationCache<FindingRecord[]>();
|
||||||
|
|
@ -42,7 +45,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown
|
||||||
return [options.activeProfile, options.dataset, options.reviews, options.search,
|
return [options.activeProfile, options.dataset, options.reviews, options.search,
|
||||||
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
|
options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly,
|
||||||
options.pathFindingIds?.length ? options.pathFindingIds : undefined,
|
options.pathFindingIds?.length ? options.pathFindingIds : undefined,
|
||||||
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, readEnginePreferences()];
|
JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, options.comparisonFacets, readEnginePreferences()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
|
export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] {
|
||||||
|
|
@ -58,9 +61,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions
|
||||||
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
|
const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US');
|
||||||
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
|
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
|
||||||
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
|
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
|
||||||
const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
||||||
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
|
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
|
||||||
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets);
|
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets);
|
||||||
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
|
const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined;
|
||||||
const filtered = sheet.analysis.records.filter(record => {
|
const filtered = sheet.analysis.records.filter(record => {
|
||||||
if (pathFindingIds && !pathFindingIds.has(record.id)) return false;
|
if (pathFindingIds && !pathFindingIds.has(record.id)) return false;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useContextClusterIndex } from '../context-layer/use-context-cluster-ind
|
||||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||||
import { useRelatedFindings } from '../related-findings/RelatedFindings';
|
import { useRelatedFindings } from '../related-findings/RelatedFindings';
|
||||||
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
|
import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets';
|
||||||
|
import { useComparisonWorkFacets } from '../comparison/comparison-work-facets';
|
||||||
|
|
||||||
type App = ReturnType<typeof useApp>;
|
type App = ReturnType<typeof useApp>;
|
||||||
function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] {
|
function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] {
|
||||||
|
|
@ -25,6 +26,7 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str
|
||||||
const remediation = useWorkPackages();
|
const remediation = useWorkPackages();
|
||||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||||
const complianceFacets = useComplianceWorkFacets();
|
const complianceFacets = useComplianceWorkFacets();
|
||||||
|
const comparisonFacets = useComparisonWorkFacets();
|
||||||
const effectiveStatusFilter = statusFilterOverride ?? statusFilter;
|
const effectiveStatusFilter = statusFilterOverride ?? statusFilter;
|
||||||
const options = useMemo(() => ({
|
const options = useMemo(() => ({
|
||||||
activeProfile: activeProfile!,
|
activeProfile: activeProfile!,
|
||||||
|
|
@ -37,8 +39,8 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str
|
||||||
pathFindingIds,
|
pathFindingIds,
|
||||||
actionOnly,
|
actionOnly,
|
||||||
reviews,
|
reviews,
|
||||||
contextClusters, workPackages, complianceFacets
|
contextClusters, workPackages, complianceFacets, comparisonFacets
|
||||||
} as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
|
} as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]);
|
||||||
return [activeSheet, options];
|
return [activeSheet, options];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { withBusinessImpactFacet } from './business-impact-controls';
|
||||||
import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index';
|
import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index';
|
||||||
import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display';
|
import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display';
|
||||||
import { withComplianceFacets } from '../../compliance/ui/compliance-work-facets';
|
import { withComplianceFacets } from '../../compliance/ui/compliance-work-facets';
|
||||||
|
import { withComparisonFacets } from '../comparison/comparison-work-facets';
|
||||||
|
|
||||||
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
|
export type WorkFacetOptions = Omit<RecordFilterOptions, 'sortColumn' | 'sortDirection'>;
|
||||||
const cache = createSheetDerivationCache<DynamicFacet[]>();
|
const cache = createSheetDerivationCache<DynamicFacet[]>();
|
||||||
|
|
@ -25,9 +26,9 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF
|
||||||
return cache(sheet, recordFilterKeys(query), () => {
|
return cache(sheet, recordFilterKeys(query), () => {
|
||||||
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
|
const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews);
|
||||||
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
|
const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews);
|
||||||
const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy'
|
||||||
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
|
? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy')
|
||||||
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets);
|
: baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets);
|
||||||
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
|
const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters });
|
||||||
// The engine retains its complete vocabulary; the Work control offers only reachable clusters.
|
// The engine retains its complete vocabulary; the Work control offers only reachable clusters.
|
||||||
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'
|
const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster'
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { selectWorkFacets } from '../search-filter/work-facets';
|
||||||
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
|
import type { ContextClusterIndex } from '../context-layer/context-cluster-facet';
|
||||||
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
import { useContextClusterIndex } from '../context-layer/use-context-cluster-index';
|
||||||
import { useComplianceWorkFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
|
import { useComplianceWorkFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets';
|
||||||
|
import { useComparisonWorkFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets';
|
||||||
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
|
import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls';
|
||||||
import { useEnginePreferences } from '../../app/engine-preferences';
|
import { useEnginePreferences } from '../../app/engine-preferences';
|
||||||
import { useWorkPackages } from '../../remediation/use-work-packages';
|
import { useWorkPackages } from '../../remediation/use-work-packages';
|
||||||
|
|
@ -32,6 +33,7 @@ interface SecurityDomainOptions {
|
||||||
contextClusters?: ContextClusterIndex;
|
contextClusters?: ContextClusterIndex;
|
||||||
workPackages?: WorkPackageIndex;
|
workPackages?: WorkPackageIndex;
|
||||||
complianceFacets?: ComplianceWorkFacetIndex;
|
complianceFacets?: ComplianceWorkFacetIndex;
|
||||||
|
comparisonFacets?: ComparisonWorkFacetIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarSecurityDomain {
|
export interface SidebarSecurityDomain {
|
||||||
|
|
@ -47,11 +49,11 @@ export interface SidebarSecurityDomainSummary {
|
||||||
|
|
||||||
/** Uses the exact shared Client Info facet, ordering and self-excluding counts. */
|
/** Uses the exact shared Client Info facet, ordering and self-excluding counts. */
|
||||||
export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined {
|
export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined {
|
||||||
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets } = options;
|
const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets } = options;
|
||||||
if (!sheet) return undefined;
|
if (!sheet) return undefined;
|
||||||
|
|
||||||
const facets = selectWorkFacets(sheet, {
|
const facets = selectWorkFacets(sheet, {
|
||||||
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets
|
dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets
|
||||||
});
|
});
|
||||||
const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID);
|
const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID);
|
||||||
if (!domainFacet) return undefined;
|
if (!domainFacet) return undefined;
|
||||||
|
|
@ -86,15 +88,16 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
|
||||||
const remediation = useWorkPackages();
|
const remediation = useWorkPackages();
|
||||||
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
const workPackages = remediation.status === 'ready' ? remediation.index : undefined;
|
||||||
const complianceFacets = useComplianceWorkFacets();
|
const complianceFacets = useComplianceWorkFacets();
|
||||||
|
const comparisonFacets = useComparisonWorkFacets();
|
||||||
const engines = useEnginePreferences();
|
const engines = useEnginePreferences();
|
||||||
const impactCounts = useMemo(() => {
|
const impactCounts = useMemo(() => {
|
||||||
if (!engines.report || !engines.context || !contextClusters) return undefined;
|
if (!engines.report || !engines.context || !contextClusters) return undefined;
|
||||||
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
|
const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters,
|
||||||
pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
|
pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID);
|
||||||
if (!facet) return undefined;
|
if (!facet) return undefined;
|
||||||
const counts = new Map(facet.values.map(value => [value.value, value.count]));
|
const counts = new Map(facet.values.map(value => [value.value, value.count]));
|
||||||
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
|
return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 }));
|
||||||
}, [engines.report, engines.context, complianceFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
|
}, [engines.report, engines.context, complianceFacets, comparisonFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]);
|
||||||
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
|
const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({
|
||||||
sheet,
|
sheet,
|
||||||
dataset,
|
dataset,
|
||||||
|
|
@ -105,8 +108,8 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi
|
||||||
pathFindingIds,
|
pathFindingIds,
|
||||||
search,
|
search,
|
||||||
actionOnly,
|
actionOnly,
|
||||||
contextClusters, workPackages, complianceFacets
|
contextClusters, workPackages, complianceFacets, comparisonFacets
|
||||||
})), [actionOnly, activeProfile, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
|
})), [actionOnly, activeProfile, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]);
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
|
|
||||||
const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters,
|
const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters,
|
||||||
|
|
|
||||||
|
|
@ -1956,7 +1956,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
||||||
.db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
|
.db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
|
||||||
.db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
.db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||||
.db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); }
|
.db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); }
|
||||||
.db-engine-flow__map { position: relative; width: 1450px; height: 940px; margin: 0 auto; }
|
.db-engine-flow__map { position: relative; width: 1450px; height: 1130px; margin: 0 auto; }
|
||||||
.db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
.db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||||
.db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; }
|
.db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; }
|
||||||
.db-engine-flow__edges > path.is-related { stroke: var(--marking-color); }
|
.db-engine-flow__edges > path.is-related { stroke: var(--marking-color); }
|
||||||
|
|
@ -1987,6 +1987,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
||||||
.db-engine-flow__node[data-node="remediation"] { left: 1180px; top: 290px; }
|
.db-engine-flow__node[data-node="remediation"] { left: 1180px; top: 290px; }
|
||||||
.db-engine-flow__node[data-node="remediation-tables"] { left: 1180px; top: 60px; }
|
.db-engine-flow__node[data-node="remediation-tables"] { left: 1180px; top: 60px; }
|
||||||
.db-engine-flow__node[data-node="remediation-ui"] { left: 1180px; top: 750px; }
|
.db-engine-flow__node[data-node="remediation-ui"] { left: 1180px; top: 750px; }
|
||||||
|
.db-engine-flow__node[data-node="comparison"] { left: 1180px; top: 1000px; }
|
||||||
|
.db-engine-flow__node[data-node="comparison-ui"] { left: 890px; top: 1000px; }
|
||||||
.db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; }
|
.db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; }
|
||||||
.db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; }
|
.db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; }
|
||||||
.db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; }
|
.db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; }
|
||||||
|
|
@ -2004,6 +2006,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var
|
||||||
.db-engine-flow__edge-label[data-edge="context-remediation"] { left: 1000px; top: 250px; }
|
.db-engine-flow__edge-label[data-edge="context-remediation"] { left: 1000px; top: 250px; }
|
||||||
.db-engine-flow__edge-label[data-edge="remediation-remediation-tables"] { left: 1290px; top: 225px; }
|
.db-engine-flow__edge-label[data-edge="remediation-remediation-tables"] { left: 1290px; top: 225px; }
|
||||||
.db-engine-flow__edge-label[data-edge="remediation-remediation-ui"] { left: 1430px; top: 570px; }
|
.db-engine-flow__edge-label[data-edge="remediation-remediation-ui"] { left: 1430px; top: 570px; }
|
||||||
|
.db-engine-flow__edge-label[data-edge="port-comparison"] { left: 1160px; top: 930px; }
|
||||||
|
.db-engine-flow__edge-label[data-edge="comparison-comparison-ui"] { left: 1145px; top: 1060px; }
|
||||||
.db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); }
|
.db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); }
|
||||||
.db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
.db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
|
||||||
.db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }
|
.db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; }
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor
|
||||||
|
|
||||||
describe('ASSESSOR version in the brand header', () => {
|
describe('ASSESSOR version in the brand header', () => {
|
||||||
it('shows the centrally maintained product version and opens About from the brand', () => {
|
it('shows the centrally maintained product version and opens About from the brand', () => {
|
||||||
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.25.0');
|
expect(ASSESSOR_PRODUCT_VERSION).toBe('2.26.0');
|
||||||
expect(app).toContain("'db-structure': 'Data Flow'");
|
expect(app).toContain("'db-structure': 'Data Flow'");
|
||||||
expect(app).toContain("['db-structure', 'Data Flow']");
|
expect(app).toContain("['db-structure', 'Data Flow']");
|
||||||
expect(app).not.toContain("'DB Structure'");
|
expect(app).not.toContain("'DB Structure'");
|
||||||
|
|
|
||||||
|
|
@ -54,28 +54,28 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join
|
||||||
* Pinned Core -> Engine dependencies: the documented extension points (
|
* Pinned Core -> Engine dependencies: the documented extension points (
|
||||||
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
|
* `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster
|
||||||
* readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload,
|
* readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload,
|
||||||
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets), the
|
* the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets; the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section), the
|
||||||
* type-only API contract and client, and the server wiring of the engines.
|
* type-only API contract and client, and the server wiring of the engines.
|
||||||
*/
|
*/
|
||||||
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
|
const EXTENSION_POINTS: Record<string, { engine: string | readonly string[]; typeOnly?: boolean }> = {
|
||||||
'src/features/related-findings/RelatedFindings.tsx': { engine: 'Remediation Engine' },
|
'src/features/related-findings/RelatedFindings.tsx': { engine: 'Remediation Engine' },
|
||||||
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
|
'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] },
|
||||||
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine'] },
|
'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/dashboard/ClientWorkPackagesPanel.tsx': { engine: ['Remediation Engine', 'Context Engine'] },
|
'src/features/dashboard/ClientWorkPackagesPanel.tsx': { engine: ['Remediation Engine', 'Context Engine'] },
|
||||||
'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'], typeOnly: true },
|
'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'], typeOnly: true },
|
||||||
'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true },
|
'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true },
|
||||||
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine'] },
|
'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' },
|
'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' },
|
||||||
'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' },
|
'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' },
|
||||||
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
|
'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
|
'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' },
|
||||||
'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' },
|
'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' },
|
||||||
'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' },
|
'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' },
|
||||||
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
|
'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
|
'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
|
'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
|
'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] },
|
'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] },
|
||||||
'server/assessor-http-server.ts': { engine: 'any' },
|
'server/assessor-http-server.ts': { engine: 'any' },
|
||||||
'server/index.ts': { engine: ['Report Engine', 'Remediation Engine', 'Compliance Engine'] }
|
'server/index.ts': { engine: ['Report Engine', 'Remediation Engine', 'Compliance Engine'] }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model';
|
||||||
|
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
|
||||||
|
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
|
||||||
|
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
|
||||||
|
|
||||||
|
// Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a
|
||||||
|
// labelled delta, change facts that open Work and a source table. Synthetic data only.
|
||||||
|
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
|
||||||
|
const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at });
|
||||||
|
const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
|
||||||
|
importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
|
||||||
|
const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' };
|
||||||
|
|
||||||
|
const report: TimeslotComparisonReport = {
|
||||||
|
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'],
|
||||||
|
sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }],
|
||||||
|
changes: [
|
||||||
|
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] },
|
||||||
|
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] },
|
||||||
|
{ sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] }
|
||||||
|
],
|
||||||
|
summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 },
|
||||||
|
score: {
|
||||||
|
baselineScore: 52.4, targetScore: 55.2, change: 2.8,
|
||||||
|
sources: [
|
||||||
|
{ sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 },
|
||||||
|
{ sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] }
|
||||||
|
],
|
||||||
|
excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }]
|
||||||
|
},
|
||||||
|
scanner: {} as TimeslotComparisonReport['scanner']
|
||||||
|
};
|
||||||
|
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
beforeEach(() => {
|
||||||
|
resetTimeslotComparisonRequestsForTests();
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
|
||||||
|
assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)],
|
||||||
|
scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)]
|
||||||
|
} as never);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
|
||||||
|
host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
|
||||||
|
|
||||||
|
describe('Comparison Engine Compare tab', () => {
|
||||||
|
it('compares the open timeslot with the one before it and opens Work from a change', async () => {
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']} onOpenChange={onOpenChange}/>));
|
||||||
|
await settle();
|
||||||
|
expect(request).toHaveBeenCalledWith('dummy-project',
|
||||||
|
expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
|
||||||
|
const selects = [...host.querySelectorAll('select')];
|
||||||
|
expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']);
|
||||||
|
expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']);
|
||||||
|
expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better');
|
||||||
|
const facts = [...host.querySelectorAll<HTMLButtonElement>('.comparison-fact')];
|
||||||
|
const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!;
|
||||||
|
expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse');
|
||||||
|
expect(fact('Not comparable').textContent).toContain('1');
|
||||||
|
expect(fact('New').disabled).toBe(true);
|
||||||
|
await act(async () => fact('Resolved').click());
|
||||||
|
expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' }));
|
||||||
|
const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester'));
|
||||||
|
expect(maester?.textContent).toContain('Not in timeslot A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains that the oldest timeslot has nothing before it', async () => {
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
|
||||||
|
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
|
||||||
|
await settle();
|
||||||
|
expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10');
|
||||||
|
expect(host.textContent).toContain('no earlier timeslot');
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only earlier timeslots as A and requests the new pair on change', async () => {
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
|
||||||
|
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-c']}/>));
|
||||||
|
await settle();
|
||||||
|
const [baseline] = [...host.querySelectorAll('select')];
|
||||||
|
expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']);
|
||||||
|
await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||||
|
await settle();
|
||||||
|
expect(request).toHaveBeenLastCalledWith('dummy-project',
|
||||||
|
expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains a client with a single timeslot instead of comparing', async () => {
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
|
||||||
|
assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)]
|
||||||
|
} as never);
|
||||||
|
const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison');
|
||||||
|
await act(async () => root.render(<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-a']}/>));
|
||||||
|
await settle();
|
||||||
|
expect(host.textContent).toContain('only one timeslot');
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { FindingRecord } from '../../src/domain/finding';
|
||||||
|
import type { DynamicFacet } from '../../src/features/search-filter/dynamic-facets';
|
||||||
|
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
|
||||||
|
import {
|
||||||
|
changesWithWorkRecords,
|
||||||
|
comparisonWorkFacetIndex,
|
||||||
|
COMPARISON_CHANGE_FACET_ID,
|
||||||
|
withComparisonFacets
|
||||||
|
} from '../../src/features/comparison/comparison-work-facets';
|
||||||
|
import type { PublishedComparison } from '../../src/features/comparison/use-timeslot-comparison';
|
||||||
|
|
||||||
|
// The Work `Change` facet maps the comparison onto Work records of timeslot B. Synthetic records only.
|
||||||
|
const gpoRecordId = (identity: string) =>
|
||||||
|
`gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`;
|
||||||
|
const record = (id: string, findingOccurrenceId?: string) => ({ id, ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as FindingRecord;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
changes: [
|
||||||
|
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] },
|
||||||
|
{ sourceId: 'cis-cat', change: 'unverified', side: 'baseline', recordIds: ['a-2'], counterpartIds: [], reasons: [] },
|
||||||
|
{ sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['b-3'], counterpartIds: [], reasons: [] },
|
||||||
|
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'],
|
||||||
|
identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: [] },
|
||||||
|
{ sourceId: 'group-policy', change: 'new', side: 'target', recordIds: ['gb-2'], counterpartIds: [],
|
||||||
|
identityKey: JSON.stringify(['computer', 'SeEnableDelegationPrivilege']), reasons: [] }
|
||||||
|
]
|
||||||
|
} as unknown as TimeslotComparisonReport;
|
||||||
|
const timeslot = (id: string, date: string) => ({ id, date, assessmentIds: [id], scanRunIds: [] });
|
||||||
|
const published: PublishedComparison = {
|
||||||
|
projectId: 'dummy-project', targetAssessmentIds: ['b'], baseline: timeslot('a', '2026-01-10'), target: timeslot('b', '2026-02-10'), report
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Comparison Engine Work Change facet', () => {
|
||||||
|
it('assigns each timeslot B record its change and leaves unrelated records without one', () => {
|
||||||
|
const index = comparisonWorkFacetIndex(published);
|
||||||
|
expect(index.changeOf(record('work-b-1', 'b-1'))).toBe('resolved');
|
||||||
|
expect(index.changeOf(record('work-b-3', 'b-3'))).toBe('regressed');
|
||||||
|
expect(index.changeOf(record(gpoRecordId('MinimumPasswordLength')))).toBe('value-changed');
|
||||||
|
expect(index.changeOf(record(gpoRecordId('SeEnableDelegationPrivilege')))).toBe('new');
|
||||||
|
expect(index.changeOf(record('work-other', 'b-9'))).toBeUndefined();
|
||||||
|
expect(index.facets[0].valuesFor?.(record('work-b-1', 'b-1'))).toEqual(['resolved']);
|
||||||
|
expect(index.facets[0]).toMatchObject({ id: COMPARISON_CHANGE_FACET_ID, label: 'Change' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers only changes with a Work record in timeslot B', () => {
|
||||||
|
expect([...changesWithWorkRecords(report)].sort()).toEqual(['new', 'regressed', 'resolved', 'value-changed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the facet directly before the permanent final Source: Scan control', () => {
|
||||||
|
const facet = (id: string) => ({ id, label: id, source: 'derived', kind: 'category', values: [] }) as unknown as DynamicFacet;
|
||||||
|
const facets = withComparisonFacets([facet('status'), facet('provenance:source-scan')], comparisonWorkFacetIndex(published));
|
||||||
|
expect(facets.map(item => item.id)).toEqual(['status', COMPARISON_CHANGE_FACET_ID, 'provenance:source-scan']);
|
||||||
|
expect(withComparisonFacets([facet('status')], undefined).map(item => item.id)).toEqual(['status']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
||||||
|
import type { Assessment, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
|
||||||
|
import type { FindingRecord } from '../../src/domain/finding';
|
||||||
|
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
|
||||||
|
import { ComparisonTab } from '../../src/features/comparison/ComparisonTab';
|
||||||
|
import { FindingComparison } from '../../src/features/comparison/FindingComparison';
|
||||||
|
import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison';
|
||||||
|
import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
|
||||||
|
|
||||||
|
// Finding Reader `Compare` section: present once the Compare tab loaded a comparison of the open timeslot B,
|
||||||
|
// with both states and the GPO values side by side; absent for an unrelated record or with the engine off.
|
||||||
|
vi.mock('../../src/app/AppProvider', () => ({
|
||||||
|
useOptionalApp: () => ({ targetProjectId: 'dummy-project', activeAssessmentIds: ['assessment-b'] })
|
||||||
|
}));
|
||||||
|
|
||||||
|
const at = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z' };
|
||||||
|
const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt });
|
||||||
|
const run = (id: string, assessmentId: string, time: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: time });
|
||||||
|
const file = (runId: string, time: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1,
|
||||||
|
importedAt: time, lastModifiedAt: time, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord;
|
||||||
|
const gpoRecordId = (identity: string) => `gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`;
|
||||||
|
const record = (id: string, findingOccurrenceId?: string) => ({ id, fields: [], ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as unknown as FindingRecord;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-a'], targetRunIds: ['run-b'], sources: [],
|
||||||
|
changes: [
|
||||||
|
{ sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] },
|
||||||
|
{ sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'],
|
||||||
|
identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: ['same-setting-current-deviation'],
|
||||||
|
values: { baseline: ['{"kind":"number","value":7}'], target: ['{"kind":"number","value":10}'] } }
|
||||||
|
],
|
||||||
|
summary: { resolved: 1, unchanged: 0, 'value-changed': 1, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 },
|
||||||
|
score: { sources: [], excludedSources: [] }, scanner: {}
|
||||||
|
} as unknown as TimeslotComparisonReport;
|
||||||
|
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear(); resetEnginePreferencesForTests(); resetTimeslotComparisonRequestsForTests();
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', at.a), file('run-b', at.b)]);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({
|
||||||
|
assessments: [assessment('assessment-a', at.a), assessment('assessment-b', at.b)],
|
||||||
|
scanRuns: [run('run-a', 'assessment-a', at.a), run('run-b', 'assessment-b', at.b)]
|
||||||
|
} as never);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never);
|
||||||
|
vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report);
|
||||||
|
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount()); host.remove();
|
||||||
|
vi.restoreAllMocks(); resetEnginePreferencesForTests();
|
||||||
|
});
|
||||||
|
const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); });
|
||||||
|
|
||||||
|
it('shows the comparison of a finding and of a GPO setting after the Compare tab loaded it', async () => {
|
||||||
|
await act(async () => root.render(<>
|
||||||
|
<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-b']}/>
|
||||||
|
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
|
||||||
|
<div data-testid="gpo"><FindingComparison record={record(gpoRecordId('MinimumPasswordLength'))}/></div>
|
||||||
|
<div data-testid="other"><FindingComparison record={record('work-b-9', 'b-9')}/></div>
|
||||||
|
</>));
|
||||||
|
await settle();
|
||||||
|
const section = (id: string) => host.querySelector(`[data-testid="${id}"] .finding-comparison`);
|
||||||
|
expect(section('scanner')?.hasAttribute('open')).toBe(false);
|
||||||
|
expect(section('scanner')?.textContent).toContain('Resolved');
|
||||||
|
expect(section('scanner')?.querySelector('tbody tr')?.textContent).toBe('ResultDeviationPassed');
|
||||||
|
const gpo = section('gpo')!;
|
||||||
|
expect(gpo.textContent).toContain('Value changed');
|
||||||
|
expect(gpo.textContent).toContain('Still a deviation');
|
||||||
|
const valueRow = [...gpo.querySelectorAll('tbody tr')].find(row => row.textContent?.startsWith('Configured value'));
|
||||||
|
expect(valueRow?.querySelectorAll('td')[1].textContent).toContain('7');
|
||||||
|
expect(valueRow?.querySelectorAll('td')[2].textContent).toContain('10');
|
||||||
|
expect(section('other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing while the Comparison Engine is off', async () => {
|
||||||
|
setEngineEnabled('comparison', false);
|
||||||
|
await act(async () => root.render(<>
|
||||||
|
<ComparisonTab projectId="dummy-project" assessmentIds={['assessment-b']}/>
|
||||||
|
<div data-testid="scanner"><FindingComparison record={record('work-b-1', 'b-1')}/></div>
|
||||||
|
</>));
|
||||||
|
await settle();
|
||||||
|
expect(host.querySelector('.finding-comparison')).toBeNull();
|
||||||
|
});
|
||||||
|
|
@ -38,6 +38,10 @@ describe('GPO timeslot comparison', () => {
|
||||||
);
|
);
|
||||||
const byIdentity = Object.fromEntries(result.decisions.map(item => [item.identity, item]));
|
const byIdentity = Object.fromEntries(result.decisions.map(item => [item.identity, item]));
|
||||||
expect(byIdentity.MinimumPasswordLength).toMatchObject({ state: 'confirmed-open', valueChanged: true, targetResultIds: ['b-1'] });
|
expect(byIdentity.MinimumPasswordLength).toMatchObject({ state: 'confirmed-open', valueChanged: true, targetResultIds: ['b-1'] });
|
||||||
|
// Both retained values travel as Work displays them, for the Finding Reader's side-by-side reading.
|
||||||
|
expect(byIdentity.MinimumPasswordLength).toMatchObject({
|
||||||
|
baselineValues: ['{"kind":"number","value":7}'], targetValues: ['{"kind":"number","value":10}'] });
|
||||||
|
expect(byIdentity.PasswordComplexity.targetValues).toEqual([]);
|
||||||
expect(byIdentity.LockoutBadCount).toMatchObject({ state: 'confirmed-open', valueChanged: false });
|
expect(byIdentity.LockoutBadCount).toMatchObject({ state: 'confirmed-open', valueChanged: false });
|
||||||
expect(byIdentity.NoLMHash).toMatchObject({ state: 'verified-remediated', reasons: ['same-setting-explicit-pass'] });
|
expect(byIdentity.NoLMHash).toMatchObject({ state: 'verified-remediated', reasons: ['same-setting-explicit-pass'] });
|
||||||
// A setting missing from the newer backup set is not proof of a removed configuration.
|
// A setting missing from the newer backup set is not proof of a removed configuration.
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb
|
||||||
await act(async () => trigger.click());
|
await act(async () => trigger.click());
|
||||||
const dialog = document.querySelector('[role="dialog"]')!;
|
const dialog = document.querySelector('[role="dialog"]')!;
|
||||||
const checks = [...dialog.querySelectorAll<HTMLInputElement>('input')];
|
const checks = [...dialog.querySelectorAll<HTMLInputElement>('input')];
|
||||||
expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true]);
|
expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true, true]);
|
||||||
expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false]);
|
expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false, false]);
|
||||||
await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); });
|
await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); checks[5].click(); });
|
||||||
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false });
|
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false, comparison: false });
|
||||||
checks[4].focus();
|
checks[5].focus();
|
||||||
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })));
|
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })));
|
||||||
expect(document.activeElement).toBe(dialog.querySelector('button'));
|
expect(document.activeElement).toBe(dialog.querySelector('button'));
|
||||||
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
|
await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })));
|
||||||
|
|
@ -43,22 +43,22 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb
|
||||||
|
|
||||||
it('persists independent choices, ignores the obsolete Context preference and cannot disable Core', () => {
|
it('persists independent choices, ignores the obsolete Context preference and cannot disable Core', () => {
|
||||||
localStorage.setItem('ASSESSOR.contextLayer', 'off');
|
localStorage.setItem('ASSESSOR.contextLayer', 'off');
|
||||||
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
|
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
|
||||||
setEngineEnabled('report', false); setEngineEnabled('core', false);
|
setEngineEnabled('report', false); setEngineEnabled('core', false);
|
||||||
resetEnginePreferencesForTests();
|
resetEnginePreferencesForTests();
|
||||||
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true });
|
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true, comparison: true });
|
||||||
localStorage.setItem(ENGINE_PREFERENCES_KEY, '{"core":false,"context":false}');
|
localStorage.setItem(ENGINE_PREFERENCES_KEY, '{"core":false,"context":false}');
|
||||||
resetEnginePreferencesForTests();
|
resetEnginePreferencesForTests();
|
||||||
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true });
|
expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true, comparison: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults safely when storage is malformed or blocked and still allows session toggles', () => {
|
it('defaults safely when storage is malformed or blocked and still allows session toggles', () => {
|
||||||
localStorage.setItem(ENGINE_PREFERENCES_KEY, 'bad json');
|
localStorage.setItem(ENGINE_PREFERENCES_KEY, 'bad json');
|
||||||
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
|
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
|
||||||
resetEnginePreferencesForTests();
|
resetEnginePreferencesForTests();
|
||||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); });
|
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); });
|
||||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
|
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
|
||||||
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true });
|
expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true });
|
||||||
expect(() => setEngineEnabled('context', false)).not.toThrow();
|
expect(() => setEngineEnabled('context', false)).not.toThrow();
|
||||||
expect(readEnginePreferences().context).toBe(false);
|
expect(readEnginePreferences().context).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ describe('DB Structure data flow', () => {
|
||||||
try {
|
try {
|
||||||
await act(async () => root.render(createElement(EngineDataFlow)));
|
await act(async () => root.render(createElement(EngineDataFlow)));
|
||||||
const engines = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node.is-engine')];
|
const engines = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node.is-engine')];
|
||||||
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance']);
|
expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance', 'comparison']);
|
||||||
const connectionCount: Record<string, number> = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4 };
|
const connectionCount: Record<string, number> = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4, comparison: 2 };
|
||||||
// Remove the initial Core selection; identity frames must remain independently.
|
// Remove the initial Core selection; identity frames must remain independently.
|
||||||
await act(async () => engines[0].click());
|
await act(async () => engines[0].click());
|
||||||
const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related');
|
const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related');
|
||||||
|
|
@ -60,8 +60,8 @@ describe('DB Structure data flow', () => {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
await act(async () => root.render(createElement(EngineDataFlow)));
|
await act(async () => root.render(createElement(EngineDataFlow)));
|
||||||
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(16);
|
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
|
||||||
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(17);
|
expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(19);
|
||||||
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(4);
|
expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(4);
|
||||||
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
|
expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3);
|
||||||
expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false);
|
expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false);
|
||||||
|
|
@ -98,12 +98,19 @@ describe('DB Structure data flow', () => {
|
||||||
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('assessor_remediation');
|
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('assessor_remediation');
|
||||||
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned');
|
expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned');
|
||||||
await act(async () => remediation.click());
|
await act(async () => remediation.click());
|
||||||
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(16);
|
// The Comparison Engine reads both timeslots through the port and reaches the UI only through extension points.
|
||||||
|
const comparison = [...container.querySelectorAll<HTMLButtonElement>('.db-engine-flow__node')]
|
||||||
|
.find(button => button.textContent?.startsWith('Comparison Engine'))!;
|
||||||
|
await act(async () => comparison.click());
|
||||||
|
expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([
|
||||||
|
'Core read-only port → Comparison Engine', 'Comparison Engine → Comparison in the UI']);
|
||||||
|
await act(async () => comparison.click());
|
||||||
|
expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18);
|
||||||
const dictionary = container.querySelector<HTMLDetailsElement>('.db-engine-flow__dictionary')!;
|
const dictionary = container.querySelector<HTMLDetailsElement>('.db-engine-flow__dictionary')!;
|
||||||
await act(async () => dictionary.querySelector('summary')!.click());
|
await act(async () => dictionary.querySelector('summary')!.click());
|
||||||
expect(dictionary.open).toBe(true);
|
expect(dictionary.open).toBe(true);
|
||||||
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (17)');
|
expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (19)');
|
||||||
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(17);
|
expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(19);
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -218,9 +225,9 @@ describe('DB Structure data flow', () => {
|
||||||
expect(container.querySelector('.db-flow__header p')).toBeNull();
|
expect(container.querySelector('.db-flow__header p')).toBeNull();
|
||||||
expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow');
|
expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow');
|
||||||
expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent))
|
expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent))
|
||||||
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine']);
|
.toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine']);
|
||||||
const relations = container.querySelector('table[aria-label="Directed engine relationships"]');
|
const relations = container.querySelector('table[aria-label="Directed engine relationships"]');
|
||||||
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(17);
|
expect(relations?.querySelectorAll('tbody tr')).toHaveLength(19);
|
||||||
expect(relations?.textContent).toContain('Core read-only port');
|
expect(relations?.textContent).toContain('Core read-only port');
|
||||||
expect(relations?.textContent).not.toContain('Planned');
|
expect(relations?.textContent).not.toContain('Planned');
|
||||||
expect(relations?.textContent).toContain('Versioned Context publication for the Management Report');
|
expect(relations?.textContent).toContain('Versioned Context publication for the Management Report');
|
||||||
|
|
@ -228,8 +235,8 @@ describe('DB Structure data flow', () => {
|
||||||
container.querySelector<HTMLButtonElement>('.db-structure-intro-actions button')!.click();
|
container.querySelector<HTMLButtonElement>('.db-structure-intro-actions button')!.click();
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
});
|
});
|
||||||
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(5);
|
expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(6);
|
||||||
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(17);
|
expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(19);
|
||||||
expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema');
|
expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema');
|
||||||
expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step');
|
expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step');
|
||||||
const chips = [...container.querySelectorAll<HTMLButtonElement>('.db-flow__table')];
|
const chips = [...container.querySelectorAll<HTMLButtonElement>('.db-flow__table')];
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ describe('local ASSESSOR HTTP server', () => {
|
||||||
|
|
||||||
const health = await fetch(`${baseUrl}/api/v1/health`);
|
const health = await fetch(`${baseUrl}/api/v1/health`);
|
||||||
expect(await health.json()).toEqual({
|
expect(await health.json()).toEqual({
|
||||||
status: 'ok', application: 'ASSESSOR', productVersion: '2.25.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
status: 'ok', application: 'ASSESSOR', productVersion: '2.26.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory'
|
||||||
});
|
});
|
||||||
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
expect(health.headers.get('access-control-allow-origin')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue