fix(client-info): keep repeat visits read-free after the Security tab rebase
The Overview (current source) and Security (no source filter) analyses of the same sheet overwrote each other's single cache slot, so every Client Info visit recomputed both. Keep up to three source-filter entries per sheet and cache the Security view and its sorted groups by identity. Align tests with Michael's base: DB Structure cards may use the shared --tile-radius token, and the Client Info action role covers the HTML Security Export button.
This commit is contained in:
parent
dbfabdda66
commit
01dabc9c3a
|
|
@ -1,5 +1,11 @@
|
||||||
# ASSESSOR – Umsetzungsplan
|
# ASSESSOR – Umsetzungsplan
|
||||||
|
|
||||||
|
## Rebase onto Michael 7471afc: Client Info repeat-visit cache and test alignment (2026-10-14, Phase 7 Presentation)
|
||||||
|
|
||||||
|
Current user consent: Presentation, Core Tests. After rebasing the branch onto Michael's `7471afc` (Security export, Work Packages tab), the Client Info repeat-visit check read 43 record fields instead of 0: the Overview (current source) and Security (no source filter) analyses of the same sheet overwrote each other's single cache slot, and the Security view, its GPO gap counts and its group sort were recomputed on every remount. The analysis cache now keeps up to three source-filter entries per sheet, and the Security view and its sorted groups are cached by their analysis/record identity; counts, verdicts and drilldowns are unchanged. Test alignment by user decision: DB Structure cards may use `border-radius: 0` or the shared `--tile-radius` token (the AGENTS tile-radius wording is unchanged), and the Client Info action test expects Management Report, Technical Report and HTML Security Export in the Engines-blue role. `ClientInfoTabs` keeps `lazy` (unmount while hidden) and adds `retain` for the Work Packages tab. No version change; verified with `npm run test:imports`, all 2561 vitest tests and both production builds. Open: `tests/design-language-check.mjs` rejects the inline bar widths in `src/features/comparison/ComparisonTab.tsx` from 2.27.0.
|
||||||
|
|
||||||
|
Change log 2026-10-14: rebased onto Michael 7471afc; restored zero-read Client Info repeat visits; aligned DB Structure radius and Client Info action tests.
|
||||||
|
|
||||||
## 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, 3a, 3b and the Security Domain deltas done (2026-10-13); step 4 (engine storage) 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, 3b and the Security Domain deltas done (2026-10-13); step 4 (engine storage) 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).
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ const flowStyle = (tone: string, strength?: number): CSSProperties => ({ '--doma
|
||||||
const shareStyle = (share: number): CSSProperties => ({ '--share': `${share}%` } as CSSProperties);
|
const shareStyle = (share: number): CSSProperties => ({ '--share': `${share}%` } as CSSProperties);
|
||||||
const overviewStyle = (groups: number): CSSProperties => ({ '--security-map-height': `${Math.max(1, groups) * 28 + 24}px` } as CSSProperties);
|
const overviewStyle = (groups: number): CSSProperties => ({ '--security-map-height': `${Math.max(1, groups) * 28 + 24}px` } as CSSProperties);
|
||||||
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
const severityOrder = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
|
||||||
|
/** Group record arrays come from the cached Security view, so their sorted order survives a Client Info remount. */
|
||||||
|
const sortedGroupRecords = new WeakMap<readonly FindingRecord[], FindingRecord[]>();
|
||||||
const severityOf = (record: FindingRecord) => normalizeSeverity(record.fields.find(field => /^(severity|criticality)$/i.test(field.label))?.value);
|
const severityOf = (record: FindingRecord) => normalizeSeverity(record.fields.find(field => /^(severity|criticality)$/i.test(field.label))?.value);
|
||||||
const label = (value: string) => value === 'unknown' ? 'Undetermined' : value.charAt(0).toUpperCase() + value.slice(1);
|
const label = (value: string) => value === 'unknown' ? 'Undetermined' : value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
const percent = (part: number, total: number) => !total ? '—' : part > 0 && part / total < .005 ? '<1%' : `${Math.round(100 * part / total)}%`;
|
const percent = (part: number, total: number) => !total ? '—' : part > 0 && part / total < .005 ? '<1%' : `${Math.round(100 * part / total)}%`;
|
||||||
|
|
@ -177,7 +179,13 @@ function FindingGroup({ group, navigation, open, onToggle, ...props }: { group:
|
||||||
toggle.current?.focus({ preventScroll: true });
|
toggle.current?.focus({ preventScroll: true });
|
||||||
section.current?.scrollIntoView?.({ block: 'start', behavior: 'instant' });
|
section.current?.scrollIntoView?.({ block: 'start', behavior: 'instant' });
|
||||||
}, [navigation, group.id]);
|
}, [navigation, group.id]);
|
||||||
const records = useMemo(() => [...group.records].sort((left, right) => severityOrder.indexOf(severityOf(left)) - severityOrder.indexOf(severityOf(right)) || left.title.localeCompare(right.title, 'en') || left.id.localeCompare(right.id)), [group.records]);
|
const records = useMemo(() => {
|
||||||
|
const cached = sortedGroupRecords.get(group.records);
|
||||||
|
if (cached) return cached;
|
||||||
|
const sorted = [...group.records].sort((left, right) => severityOrder.indexOf(severityOf(left)) - severityOrder.indexOf(severityOf(right)) || left.title.localeCompare(right.title, 'en') || left.id.localeCompare(right.id));
|
||||||
|
sortedGroupRecords.set(group.records, sorted);
|
||||||
|
return sorted;
|
||||||
|
}, [group.records]);
|
||||||
const share = props.total ? 100 * records.length / props.total : 0;
|
const share = props.total ? 100 * records.length / props.total : 0;
|
||||||
const heading = <>
|
const heading = <>
|
||||||
<TriangleIcon direction={open ? 'down' : 'right'}/><span className="client-security-category"><i className="client-security-marker" data-severity={records.length ? group.severity : 'unknown'}/><span>{group.number && <small>{group.number} · </small>}{group.label}</span></span>
|
<TriangleIcon direction={open ? 'down' : 'right'}/><span className="client-security-category"><i className="client-security-marker" data-severity={records.length ? group.severity : 'unknown'}/><span>{group.number && <small>{group.number} · </small>}{group.label}</span></span>
|
||||||
|
|
|
||||||
|
|
@ -1043,12 +1043,23 @@ type DashboardAnalysis = ReturnType<typeof dashboardAnalysis>;
|
||||||
* Zurueckkehren neu aufgebaut, sodass dieselbe Rechnung sonst bei jedem Besuch
|
* Zurueckkehren neu aufgebaut, sodass dieselbe Rechnung sonst bei jedem Besuch
|
||||||
* erneut liefe.
|
* erneut liefe.
|
||||||
*/
|
*/
|
||||||
const analysisCache = new WeakMap<object, {
|
const analysisCache = new WeakMap<object, Array<{
|
||||||
dataset: ImportedDataset;
|
dataset: ImportedDataset;
|
||||||
profile: ImportProfile;
|
profile: ImportProfile;
|
||||||
reviews: Record<string, Review>;
|
reviews: Record<string, Review>;
|
||||||
sourceFilter?: DynamicFacetFilter;
|
sourceFilter?: DynamicFacetFilter;
|
||||||
value: DashboardAnalysis;
|
value: DashboardAnalysis;
|
||||||
|
}>>();
|
||||||
|
/** Overview (current source) and Security (no source filter) read the same sheet; keep both plus one recent source. */
|
||||||
|
const ANALYSIS_CACHE_ENTRIES_PER_SHEET = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Security view is derived from the cached Security analysis and survives a Client Info remount
|
||||||
|
* like the analysis itself; any other profile, tool, context state or report switch recomputes it.
|
||||||
|
*/
|
||||||
|
const securityViewCache = new WeakMap<DashboardAnalysis, {
|
||||||
|
profile: ImportProfile; toolId?: string; context: unknown; report: boolean;
|
||||||
|
value: { groups: SecurityFindingGroup[]; domains: Array<{ id: string; label: string; count: number }>; total: number; gpoGaps: ReturnType<typeof dashboardGpoGapCounts> };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1067,13 +1078,19 @@ function useDashboardAnalysis(
|
||||||
sourceFilter?: DynamicFacetFilter
|
sourceFilter?: DynamicFacetFilter
|
||||||
) {
|
) {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const cached = activeSheet && dataset ? analysisCache.get(activeSheet) : undefined;
|
const entries = activeSheet && dataset ? analysisCache.get(activeSheet) ?? [] : [];
|
||||||
if (cached && cached.dataset === dataset && cached.profile === activeProfile && cached.reviews === reviews && cached.sourceFilter === sourceFilter) {
|
const cached = entries.find(entry => entry.dataset === dataset && entry.profile === activeProfile && entry.reviews === reviews && entry.sourceFilter === sourceFilter);
|
||||||
|
if (cached) {
|
||||||
|
if (entries[0] !== cached) analysisCache.set(activeSheet!, [cached, ...entries.filter(entry => entry !== cached)]);
|
||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
const facets = activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : [];
|
const facets = activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : [];
|
||||||
const value = dashboardAnalysis(activeSheet, activeProfile, reviews, facets, sourceFilter, dataset?.securityScoring);
|
const value = dashboardAnalysis(activeSheet, activeProfile, reviews, facets, sourceFilter, dataset?.securityScoring);
|
||||||
if (activeSheet && dataset) analysisCache.set(activeSheet, { dataset, profile: activeProfile, reviews, sourceFilter, value });
|
if (activeSheet && dataset) {
|
||||||
|
// Entries for another dataset, profile or review state can never match again.
|
||||||
|
const current = entries.filter(entry => entry.dataset === dataset && entry.profile === activeProfile && entry.reviews === reviews);
|
||||||
|
analysisCache.set(activeSheet, [{ dataset, profile: activeProfile, reviews, sourceFilter, value }, ...current].slice(0, ANALYSIS_CACHE_ENTRIES_PER_SHEET));
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}, [activeProfile, activeSheet, dataset, reviews, sourceFilter]);
|
}, [activeProfile, activeSheet, dataset, reviews, sourceFilter]);
|
||||||
}
|
}
|
||||||
|
|
@ -1182,7 +1199,11 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const securityAnalysis = useDashboardAnalysis(securitySheet, dataset, securityProfile, reviews);
|
const securityAnalysis = useDashboardAnalysis(securitySheet, dataset, securityProfile, reviews);
|
||||||
const engines = useEnginePreferences();
|
const engines = useEnginePreferences();
|
||||||
const impactContext = useAssessmentContext(Boolean(dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
|
const impactContext = useAssessmentContext(Boolean(dataset), app.targetProjectId, app.activeAssessmentIds, app.activeScanRunIds, dataset);
|
||||||
|
const securityContextKey: unknown = impactContext.status === 'ready' ? impactContext.context : impactContext.status;
|
||||||
const securityView = useMemo(() => {
|
const securityView = useMemo(() => {
|
||||||
|
const cached = securityViewCache.get(securityAnalysis);
|
||||||
|
if (cached && cached.profile === securityProfile && cached.toolId === securitySheet?.toolId
|
||||||
|
&& cached.context === securityContextKey && cached.report === engines.report) return cached.value;
|
||||||
const records = securityAnalysis.records.filter(record => dashboardResultState(record, securityProfile) === 'deviation');
|
const records = securityAnalysis.records.filter(record => dashboardResultState(record, securityProfile) === 'deviation');
|
||||||
const buckets = dashboardDomainAssignment(records, securitySheet?.toolId);
|
const buckets = dashboardDomainAssignment(records, securitySheet?.toolId);
|
||||||
const domainCounts = securityDomainCatalog.map(domain => ({ id: domain.id, label: domain.label, count: buckets.get(domain.id)?.length ?? 0 }));
|
const domainCounts = securityDomainCatalog.map(domain => ({ id: domain.id, label: domain.label, count: buckets.get(domain.id)?.length ?? 0 }));
|
||||||
|
|
@ -1203,8 +1224,11 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
if (remaining.length) groups.push({ id: 'unassigned', label: impactContext.status === 'ready' ? 'Unassigned findings' : 'All findings', severity: 'unknown',
|
if (remaining.length) groups.push({ id: 'unassigned', label: impactContext.status === 'ready' ? 'Unassigned findings' : 'All findings', severity: 'unknown',
|
||||||
situation: impactContext.status === 'ready' ? 'These findings have no matching published category assignment in the current scope.' : undefined,
|
situation: impactContext.status === 'ready' ? 'These findings have no matching published category assignment in the current scope.' : undefined,
|
||||||
records: remaining, domains: countsFor(remaining) });
|
records: remaining, domains: countsFor(remaining) });
|
||||||
return { groups, domains: domainCounts, total: records.length };
|
const value = { groups, domains: domainCounts, total: records.length,
|
||||||
}, [securityAnalysis, securityProfile, securitySheet?.toolId, impactContext.status === 'ready' ? impactContext.context : impactContext.status, engines.report]);
|
gpoGaps: dashboardGpoGapCounts(securityAnalysis.records, securityProfile, securitySheet?.toolId ?? dataset?.profileId) };
|
||||||
|
securityViewCache.set(securityAnalysis, { profile: securityProfile, toolId: securitySheet?.toolId, context: securityContextKey, report: engines.report, value });
|
||||||
|
return value;
|
||||||
|
}, [securityAnalysis, securityProfile, securitySheet?.toolId, securityContextKey, engines.report, dataset?.profileId]);
|
||||||
const impactDistribution = useMemo(() => {
|
const impactDistribution = useMemo(() => {
|
||||||
if (!engines.report || impactContext.status !== 'ready') return undefined;
|
if (!engines.report || impactContext.status !== 'ready') return undefined;
|
||||||
const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation');
|
const keys = managementWorkspaceDeviationKeys(analysis.records, record => dashboardResultState(record, activeProfile) === 'deviation');
|
||||||
|
|
@ -1439,7 +1463,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
['Base severity Critical categories', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length && group.severity === 'critical').length : undefined],
|
['Base severity Critical categories', engines.context && impactContext.status === 'ready' ? securityView.groups.filter(group => group.records.length && group.severity === 'critical').length : undefined],
|
||||||
['Accepted risks (valid)', riskRegisterEntries(dataset, reviews, undefined, app.scopedRiskAcceptances).filter(entry => entry.state === 'Accepted').length]
|
['Accepted risks (valid)', riskRegisterEntries(dataset, reviews, undefined, app.scopedRiskAcceptances).filter(entry => entry.state === 'Accepted').length]
|
||||||
].map(([label, value]) => <tr key={label}><th scope="row">{label}</th><td>{typeof value === 'number' ? value.toLocaleString('en-US') : value ?? '—'}</td></tr>)}</tbody></table>
|
].map(([label, value]) => <tr key={label}><th scope="row">{label}</th><td>{typeof value === 'number' ? value.toLocaleString('en-US') : value ?? '—'}</td></tr>)}</tbody></table>
|
||||||
<AssessmentFactsExplanation counts={securityResult} gpo={dashboardGpoGapCounts(securityAnalysis.records, securityProfile, securitySheet?.toolId ?? dataset.profileId)}/>
|
<AssessmentFactsExplanation counts={securityResult} gpo={securityView.gpoGaps}/>
|
||||||
</EnvironmentResultsButton>
|
</EnvironmentResultsButton>
|
||||||
</div>}
|
</div>}
|
||||||
overview={<section className="client-security-hardfacts" aria-label="Security overview and environment facts">
|
overview={<section className="client-security-hardfacts" aria-label="Security overview and environment facts">
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,10 @@ it.each(['dark', 'light'])('removes only the Client Information button and keeps
|
||||||
expect(host.querySelector('.client-overview-header > .client-report-actions')).toBeNull();
|
expect(host.querySelector('.client-overview-header > .client-report-actions')).toBeNull();
|
||||||
expect(host.querySelector('[aria-label="Open Client Information results"]')).toBeNull();
|
expect(host.querySelector('[aria-label="Open Client Information results"]')).toBeNull();
|
||||||
expect(actions.every(button => button.classList.contains('client-information-action'))).toBe(true);
|
expect(actions.every(button => button.classList.contains('client-information-action'))).toBe(true);
|
||||||
expect(host.querySelectorAll('.client-information-action')).toHaveLength(2);
|
// Both reports plus the Security tab's HTML Security Export share the Engines-blue action role.
|
||||||
|
expect([...host.querySelectorAll('.client-information-action')].map(button => button.textContent))
|
||||||
|
.toEqual(['Management Report', 'Technical Report', 'HTML Security Export']);
|
||||||
|
actions.push(host.querySelector<HTMLButtonElement>('.client-security-panel .client-information-action')!);
|
||||||
await act(async () => setEngineEnabled('report', false));
|
await act(async () => setEngineEnabled('report', false));
|
||||||
expect(actions.every(button => button.disabled && button.classList.contains('client-information-action'))).toBe(true);
|
expect(actions.every(button => button.disabled && button.classList.contains('client-information-action'))).toBe(true);
|
||||||
const css = readFileSync('src/styles/components.css', 'utf8');
|
const css = readFileSync('src/styles/components.css', 'utf8');
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ describe('DB Structure information design', () => {
|
||||||
it('uses the shared flat segment and tile grammar without legacy card decoration', () => {
|
it('uses the shared flat segment and tile grammar without legacy card decoration', () => {
|
||||||
expect(styles).toMatch(/\.db-structure-summary\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*background:\s*var\(--segment-surface\);[^}]*box-shadow:\s*none;/s);
|
expect(styles).toMatch(/\.db-structure-summary\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*background:\s*var\(--segment-surface\);[^}]*box-shadow:\s*none;/s);
|
||||||
expect(styles).toMatch(/\.db-structure-layer\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*border:\s*0;[^}]*background:\s*var\(--segment-surface\);[^}]*box-shadow:\s*none;/s);
|
expect(styles).toMatch(/\.db-structure-layer\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*border:\s*0;[^}]*background:\s*var\(--segment-surface\);[^}]*box-shadow:\s*none;/s);
|
||||||
expect(styles).toMatch(/\.db-structure-card\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--tile-border\);[^}]*border-radius:\s*0;[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
// Square tiles or the shared tile radius token; never a local radius value.
|
||||||
|
expect(styles).toMatch(/\.db-structure-card\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--tile-border\);[^}]*border-radius:\s*(?:0|var\(--tile-radius\));[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
||||||
expect(styles).toMatch(/\.db-structure-card-header:hover\s*\{[^}]*background:\s*var\(--tile-surface-hover\);/s);
|
expect(styles).toMatch(/\.db-structure-card-header:hover\s*\{[^}]*background:\s*var\(--tile-surface-hover\);/s);
|
||||||
expect(styles).toMatch(/\.db-structure-card-copy\s*>\s*strong\s*\{[^}]*font-size:\s*var\(--type-h4-size\);[^}]*font-weight:\s*var\(--fw-semibold\);/s);
|
expect(styles).toMatch(/\.db-structure-card-copy\s*>\s*strong\s*\{[^}]*font-size:\s*var\(--type-h4-size\);[^}]*font-weight:\s*var\(--fw-semibold\);/s);
|
||||||
expect(styles).not.toContain('.db-structure-columns code');
|
expect(styles).not.toContain('.db-structure-columns code');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue