UI Anpassung- PK + PC HTML Import implementiert
Deploy / Test, build & deploy (push) Successful in 2m3s
Details
Deploy / Test, build & deploy (push) Successful in 2m3s
Details
This commit is contained in:
parent
7a96ed3ae1
commit
a5331de771
13
AGENTS.md
13
AGENTS.md
|
|
@ -22,9 +22,9 @@
|
||||||
|
|
||||||
- Use the locally bundled Inter variable font as the single application typeface. Do not introduce view-specific display, condensed, serif, or monospace families; hierarchy comes from size and weight.
|
- Use the locally bundled Inter variable font as the single application typeface. Do not introduce view-specific display, condensed, serif, or monospace families; hierarchy comes from size and weight.
|
||||||
- Use only the visual weights Regular 400, Semibold 600, and Bold 700. Bold is reserved for branding and exceptional emphasis.
|
- Use only the visual weights Regular 400, Semibold 600, and Bold 700. Bold is reserved for branding and exceptional emphasis.
|
||||||
- Use the semantic typography roles: H1 56px/600, H2 32px/600, H3 18px/600, H4 16px/600, body and table content 16px/400, labels 14px/600, metadata and table headers 13px, and buttons 14px/600.
|
- Use the semantic typography roles: H1 56px/600, H2 32px/600, H3 18px/600, detail titles 20px/600, H4 16px/600, continuous prose 16px/400, structured table/Grid/Raw Data content 14px/400, labels and table headers 12px/600, metadata 12px, badges 11px, and buttons/controls 13px/600.
|
||||||
- Preserve normal title case for H1-H4, field labels, and buttons. Uppercase is limited to compact table headers, badges/categories, and the ASSESSOR wordmark.
|
- Preserve normal title case for H1-H4, field labels, and buttons. Uppercase is limited to compact table headers, badges/categories, and the ASSESSOR wordmark.
|
||||||
- Render continuous prose and other content-bearing text at no less than `16px`, including finding content, report text, Grid cell content, and Raw Data.
|
- Render continuous prose such as finding and report text at no less than `16px`. Structured data in tables, Grid, and Raw Data uses the dedicated compact 14px role.
|
||||||
- Compact interface text such as button captions, field labels, metadata, badges, and comparable control annotations may use a smaller type size.
|
- Compact interface text such as button captions, field labels, metadata, badges, and comparable control annotations may use a smaller type size.
|
||||||
- When adding or changing styles, preserve this distinction instead of reducing content text to a compact UI-label token.
|
- When adding or changing styles, preserve this distinction instead of reducing content text to a compact UI-label token.
|
||||||
|
|
||||||
|
|
@ -37,12 +37,13 @@
|
||||||
|
|
||||||
## UI segments and tiles
|
## UI segments and tiles
|
||||||
|
|
||||||
- Treat segments and tiles as a binding two-level visual grammar independent of their content. A segment is the rounded grouped background; a tile is a rounded, individually bordered element inside it.
|
- Treat segments and tiles as a binding two-level visual grammar independent of their content. A segment is a transparent grouped layout; a tile is a flat, square-cornered surface inside it.
|
||||||
- Use only the shared `--segment-*` and `--tile-*` tokens for segment surfaces, borders, radii, insets, gaps, tile surfaces, shadows, widths, hover states, and selected contours. Do not create view-local substitute geometry.
|
- Use only the shared `--segment-*` and `--tile-*` tokens for segment surfaces, borders, radii, insets, gaps, tile surfaces, shadows, widths, hover states, and selected contours. Do not create view-local substitute geometry.
|
||||||
- Segments use the light neutral group surface, a fine border, `--segment-radius`, one uniform inset, and one uniform gap. Segments remain flat and do not receive a floating shadow.
|
- Segments are visually transparent and have no visible border or shadow. They retain `--segment-radius`, one uniform inset, and one uniform gap as shared layout geometry.
|
||||||
- Tiles use the common white or theme-equivalent surface, fine gray border, `--tile-radius`, and the single subtle `--tile-card-shadow`. Hover may change border and surface but must not lift or translate the tile.
|
- Tiles use the common white or theme-equivalent surface without a visible normal-state border, radius, or shadow. Hover changes the surface only and must not add a decorative contour, lift, or translation.
|
||||||
- Status color belongs only in a small accent line, marker, data visualization, or selected contour. Never fill the complete tile with a status color.
|
- Status color belongs only in a small accent line, marker, data visualization, or selected contour. Never fill the complete tile with a status color.
|
||||||
- Selected tiles use the shared selected border/outline. Action tiles and add tiles use the same geometry as content tiles.
|
- Keyboard focus and selected tiles use the shared functional focus or selected outline. Action tiles and add tiles use the same flat geometry as content tiles.
|
||||||
|
- The persistent marking and selection color is always the shared yellow `--marking-color` in both themes. Purple must not be used for selected contours or markers. Blue remains reserved for keyboard focus, while green, red, orange, and severity colors remain domain status signals rather than selection colors.
|
||||||
- Keep unused segment space intentionally empty: lay tiles out from the start at their shared compact or content width and never stretch a partial final row merely to fill the container.
|
- Keep unused segment space intentionally empty: lay tiles out from the start at their shared compact or content width and never stretch a partial final row merely to fill the container.
|
||||||
- Tile content must never cross the tile boundary or cover a neighboring tile. Nothing may be clipped, clamped, truncated, or replaced by an ellipsis. Text and actions reflow inside the tile; media scales within the available inner area. If content does not fit, enlarge or reflow the tile.
|
- Tile content must never cross the tile boundary or cover a neighboring tile. Nothing may be clipped, clamped, truncated, or replaced by an ellipsis. Text and actions reflow inside the tile; media scales within the available inner area. If content does not fit, enlarge or reflow the tile.
|
||||||
- Avoid line breaks in compact tile labels where practical. If wrapping is necessary, wrap only at word boundaries: never split ordinary words or numeric values across lines. Use a wider or responsive tile whenever a complete word or value cannot fit.
|
- Avoid line breaks in compact tile labels where practical. If wrapping is necessary, wrap only at word boundaries: never split ordinary words or numeric values across lines. Use a wider or responsive tile whenever a complete word or value cannot fit.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Status: lebendes Projektdokument
|
Status: lebendes Projektdokument
|
||||||
Erstellt: 2026-07-17
|
Erstellt: 2026-07-17
|
||||||
Zuletzt aktualisiert: 2026-08-04
|
Zuletzt aktualisiert: 2026-08-25
|
||||||
Aktuelle Entwicklungsstufe: lokale React-/Tauri-Anwendung
|
Aktuelle Entwicklungsstufe: lokale React-/Tauri-Anwendung
|
||||||
Zielzustand: interne Mehrbenutzer-Webanwendung mit Backend und PostgreSQL
|
Zielzustand: interne Mehrbenutzer-Webanwendung mit Backend und PostgreSQL
|
||||||
|
|
||||||
|
|
@ -1349,9 +1349,16 @@ Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04
|
||||||
|
|
||||||
| Datum | Entscheidung | Begründung |
|
| Datum | Entscheidung | Begründung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-08-25 | Die appweite persistente Markierungs- und Auswahlfarbe ist auf ausdrueckliche Nutzerfestlegung immer Gelb und wird in beiden Themes ueber `--marking-color` gefuehrt. Kachelauswahl, Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl verwenden diesen Token. Blau bleibt Tastaturfokus; Gruen, Rot, Orange und Severitytoene bleiben fachliche Statussignale. | Ein einzelner semantischer Markierungston verhindert violette oder skinabhaengig wechselnde Auswahlkonturen wie im Datahub-Screenshot und bewahrt gleichzeitig die Bedeutung bestehender Statusfarben. |
|
||||||
|
| 2026-08-25 | Die unmittelbar zuvor fuer Client Info festgelegte flache SOC-Kachel ist auf ausdrueckliche Nutzerfestlegung der neue appweite Kachelstandard: keine sichtbare Normalzustandskontur, kein Radius und kein Schatten; Hover veraendert nur die Flaeche. Fokus, Auswahl und kleine Statusakzente bleiben funktionale Ausnahmen. Die gemeinsamen `--tile-*`-Tokens und Projektregeln tragen diesen Vertrag. | Ein zentraler Standard verhindert, dass neue oder bestehende Ansichten erneut weisse Konturen, Rundungen oder schwebende Schatten einfuehren. Flaechenkontrast und gemeinsamer Abstand erzeugen die gewuenschte ruhige, dichte SOC-Geometrie. |
|
||||||
|
| 2026-08-25 | Das quadratische Client-Info-Raster uebernimmt die flache Kartenoberflaeche der Nutzerreferenz: regulaere Kennzahl- und Inhaltskacheln haben keine Aussenkontur, keinen Schatten, den zentralen Nullradius und keine dekorative Trennlinie unter dem Kartentitel. Farbige Fokus- und Auswahlkonturen bleiben als funktionale Zustaende erhalten. | In der Referenz werden Karten allein durch Flaechenkontrast und dunkle Zwischenraeume getrennt. Die bisherige helle Kontur, Rundung und Schattenkante erzeugten eine abweichende, unruhige zweite Geometrieebene. |
|
||||||
|
| 2026-08-25 | Client Info verwendet nach ausdruecklicher Nutzerkorrektur ausschliesslich quadratische Kacheln in einem gemeinsamen dichten Raster: Kennzahlen belegen 1 x 1 Rastereinheiten, Inhaltskarten 2 x 2 Einheiten. Die frueher getrennten, ueber die Gesamtbreite gestreckten Kennzahl- und Inhaltsraster entfallen. | Zwei quadratische Groessen halten kurze Kennzahlen kompakt und geben inhaltsreichen Visualisierungen ausreichend Raum. Ein gemeinsames `dense`-Raster nutzt freie Zellen neben kleinen Kacheln und vermeidet breite Karten mit ungenutzter Innenflaeche. |
|
||||||
|
| 2026-08-25 | Die Nutzerkorrektur ersetzt den sichtbaren Segmentcontainer aus der Kachelgrammatik vom 2026-08-24: Segmente sind anwendungsweit transparente Layoutgruppen ohne sichtbare Flaeche, Kontur oder Schatten. Gemeinsamer Radius, Inset und Gap bleiben als Geometrievertrag erhalten; Kacheln behalten ihre eigene Flaeche, Kontur, Auswahlmarkierung und den gemeinsamen dezenten Schatten. | Der zusaetzliche Gruppenunterbau konkurriert visuell mit den einzeln abgegrenzten Kacheln und erzeugt grosse leere Farbflächen bei unvollstaendigen Reihen. Transparente Segmente bewahren Gruppierung und Layoutstabilitaet, ohne eine zweite sichtbare Kartenebene einzufuehren. |
|
||||||
|
| 2026-08-25 | Source Extractors duerfen versionierte `supportingTables` liefern: quellengetreue Tabellen, die Findings belegen, aber selbst keine Finding-Quelle sind. Die Importengine projiziert sie generisch als DatasetSheets; werkzeugspezifische Darstellung bleibt ausserhalb von React. PurpleKnight-HTML 1.1.0 verwendet diesen Vertrag fuer seine eingebetteten SI-Ergebnistabellen. | PurpleKnight-Excel speichert Ergebnisdetails als eigene Workbook-Sheets, waehrend der HTML-Bericht dieselben Tabellen als strukturierte Zeilen im inerten Reportmodell traegt. Ein gemeinsamer Supporting-Table-Vertrag erhaelt Tabellen, Provenienz und Result-Verknuepfung formatuebergreifend, ohne Parsinglogik in UI oder kanonische Persistenz zu verschieben. |
|
||||||
|
| 2026-08-25 | Die Nutzerkorrektur ersetzt die flaeckende 16-px-Inhaltsuntergrenze des Typografievertrags vom 2026-08-24 durch eine hybride Dichteskala: H1 bis H4, 20-px-Detailtitel und kontinuierlicher Lesetext mit 16 px bleiben erhalten; strukturierte Tabellen-, Grid- und Raw-Data-Werte verwenden 14 px, Controls 13 px, Labels, Metadaten und Tabellenkoepfe 12 px sowie Badges 11 px. Inter und die semantischen Rollen bleiben verbindlich. | Lesetext benoetigt die groessere Stufe, waehrend dichte Tabellen und Bedienoberflaechen durch die pauschale Anhebung zu wenig Information zeigten. Eine eigene Tabellenkoerperrolle verhindert, dass strukturierte Daten und Prosa erneut ueber denselben Token gekoppelt werden. |
|
||||||
| 2026-08-24 | Die PostgreSQL-Zieladresse der Serverlaufzeit wird bei Einzelvariablen-Konfiguration aus `ASSESSOR_POSTGRES_HOST` gelesen; ohne Wert bleibt `127.0.0.1` der lokale Standard. Eine explizite `ASSESSOR_DATABASE_URL` behaelt Vorrang. | Container teilen keinen Loopback-Namensraum. Der Compose-Dienstname `postgres` muss deshalb bis in die Backend-Verbindungszeichenfolge gelangen, ohne den bestehenden lokalen Entwicklungsbetrieb zu brechen. |
|
| 2026-08-24 | Die PostgreSQL-Zieladresse der Serverlaufzeit wird bei Einzelvariablen-Konfiguration aus `ASSESSOR_POSTGRES_HOST` gelesen; ohne Wert bleibt `127.0.0.1` der lokale Standard. Eine explizite `ASSESSOR_DATABASE_URL` behaelt Vorrang. | Container teilen keinen Loopback-Namensraum. Der Compose-Dienstname `postgres` muss deshalb bis in die Backend-Verbindungszeichenfolge gelangen, ohne den bestehenden lokalen Entwicklungsbetrieb zu brechen. |
|
||||||
| 2026-08-24 | Segmente und Kacheln folgen anwendungsweit einer gemeinsamen visuellen Grammatik: flacher abgerundeter Segmentcontainer mit einheitlichem Inset und Gap; darin gleichartige abgerundete Kacheln mit feiner Kontur, dezentem gemeinsamem Schatten und fester Inhaltsbreite. Statusfarbe bleibt Akzent oder Auswahlkontur. Unvollstaendige Zeilen werden nicht gestreckt. Jede Kachel begrenzt ihren Inhalt; Titel, Werte, Labels, Zusatztexte, Medien und Aktionen duerfen weder abgeschnitten, geklemmt, ellipsiert oder verborgen werden noch die Kachelgrenze ueberschreiten oder Nachbarkacheln ueberdecken. Notwendige Umbrueche erfolgen ausschliesslich an Wortgrenzen, Zahlen werden nicht getrennt. | Ein gemeinsamer Vertrag verhindert springende Geometrie und ansichtsspezifische Sonderkarten. Die harte Inhaltsgrenze schuetzt Nachbarkacheln, waehrend vollstaendige Inhalte die fachliche Lesbarkeit erhalten. Wenn Inhalt nicht vollstaendig passt, wird er intern neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv neu angeordnet. |
|
| 2026-08-24 | Segmente und Kacheln folgen anwendungsweit einer gemeinsamen visuellen Grammatik: flacher abgerundeter Segmentcontainer mit einheitlichem Inset und Gap; darin gleichartige abgerundete Kacheln mit feiner Kontur, dezentem gemeinsamem Schatten und fester Inhaltsbreite. Statusfarbe bleibt Akzent oder Auswahlkontur. Unvollstaendige Zeilen werden nicht gestreckt. Jede Kachel begrenzt ihren Inhalt; Titel, Werte, Labels, Zusatztexte, Medien und Aktionen duerfen weder abgeschnitten, geklemmt, ellipsiert oder verborgen werden noch die Kachelgrenze ueberschreiten oder Nachbarkacheln ueberdecken. Notwendige Umbrueche erfolgen ausschliesslich an Wortgrenzen, Zahlen werden nicht getrennt. | Ein gemeinsamer Vertrag verhindert springende Geometrie und ansichtsspezifische Sonderkarten. Die harte Inhaltsgrenze schuetzt Nachbarkacheln, waehrend vollstaendige Inhalte die fachliche Lesbarkeit erhalten. Wenn Inhalt nicht vollstaendig passt, wird er intern neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv neu angeordnet. |
|
||||||
| 2026-08-24 | Die neueste Nutzerkorrektur ersetzt fuer Finding-Titel die unmittelbar folgende fruehere 18-px-Reader-Entscheidung: Trefferlistentitel bleiben auf der 16-px-Inhaltsstufe, werden aber mit Medium 500 statt Semibold gesetzt; der Titel des geoeffneten Findings verwendet eine eigene 20-px-Detailtitelrolle mit Semibold. Die Finding-Abschnittsueberschrift bleibt 18 px, regulaere Matrixkacheln bleiben 16 px. | Die Trefferliste soll ruhig und scanbar bleiben, waehrend das geoeffnete Finding als primaerer Detailkontext klar hervortreten muss. Die getrennten semantischen Rollen loesen den Kontrast ohne die 16-px-Untergrenze fuer inhaltstragenden Text zu unterschreiten. |
|
| 2026-08-24 | Die neueste Nutzerkorrektur, am 2026-08-25 weiter verdichtet, ersetzt fuer Finding-Titel die unmittelbar folgende fruehere 18-px-Reader-Entscheidung: Trefferlistentitel bleiben auf der 16-px-Inhaltsstufe und verwenden Regular 400 statt der frueheren staerkeren Gewichte; der Titel des geoeffneten Findings verwendet eine eigene 20-px-Detailtitelrolle mit Semibold. Die Finding-Abschnittsueberschrift bleibt 18 px, regulaere Matrixkacheln bleiben 16 px. | Die Trefferliste soll ruhig und scanbar bleiben, waehrend das geoeffnete Finding als primaerer Detailkontext klar hervortreten muss. Die getrennten semantischen Rollen und kompaktere Listengeometrie loesen den Kontrast ohne die 16-px-Untergrenze fuer inhaltstragenden Text zu unterschreiten. |
|
||||||
| 2026-08-24 | Finding-Titel verwenden innerhalb von Work eine kompakte semantische Hierarchie: Reader-Titel und Finding-Abschnitt nutzen H3 mit 18 px; Titel in Trefferlisten und regulaeren Matrixkacheln nutzen H4 mit 16 px. Die globale H1-/H2-Hierarchie und die eigenstaendige Pathfinder-Micro-Card bleiben unveraendert. | Lange Scannerbezeichnungen benoetigen in Listen und Kacheln sonst zu viele Zeilen und verdraengen Status sowie Metadaten. Die vorhandenen Typografietokens erhalten die Lesbarkeitsuntergrenze von 16 px fuer inhaltstragenden Text und vermeiden lokale Sondergroessen. |
|
| 2026-08-24 | Finding-Titel verwenden innerhalb von Work eine kompakte semantische Hierarchie: Reader-Titel und Finding-Abschnitt nutzen H3 mit 18 px; Titel in Trefferlisten und regulaeren Matrixkacheln nutzen H4 mit 16 px. Die globale H1-/H2-Hierarchie und die eigenstaendige Pathfinder-Micro-Card bleiben unveraendert. | Lange Scannerbezeichnungen benoetigen in Listen und Kacheln sonst zu viele Zeilen und verdraengen Status sowie Metadaten. Die vorhandenen Typografietokens erhalten die Lesbarkeitsuntergrenze von 16 px fuer inhaltstragenden Text und vermeiden lokale Sondergroessen. |
|
||||||
| 2026-08-24 | Alle Donutdiagramme verwenden einen gemeinsamen visuellen Vertrag: vollstaendige neutrale 360-Grad-Ringspur, farbiger Wertbogen im Uhrzeigersinn ab 12 Uhr, flache Bogenenden, gemeinsame Strichstaerke und eine mittige Primaerkennzahl mit kleiner Einheit auf derselben Grundlinie. Mehrteilige Verteilungen behalten ihre fachlichen Farbsegmente innerhalb derselben Geometrie. | Eine gemeinsame Form verhindert wechselnde Gauge-, Ring- und Prozentdarstellungen zwischen Datahub, Dashboard und Sidebar. Die neutrale Restspur macht den verbleibenden Anteil sichtbar; die zentrale Kennzahl bleibt unabhaengig von Kartenbreite und Legende unmittelbar lesbar. |
|
| 2026-08-24 | Alle Donutdiagramme verwenden einen gemeinsamen visuellen Vertrag: vollstaendige neutrale 360-Grad-Ringspur, farbiger Wertbogen im Uhrzeigersinn ab 12 Uhr, flache Bogenenden, gemeinsame Strichstaerke und eine mittige Primaerkennzahl mit kleiner Einheit auf derselben Grundlinie. Mehrteilige Verteilungen behalten ihre fachlichen Farbsegmente innerhalb derselben Geometrie. | Eine gemeinsame Form verhindert wechselnde Gauge-, Ring- und Prozentdarstellungen zwischen Datahub, Dashboard und Sidebar. Die neutrale Restspur macht den verbleibenden Anteil sichtbar; die zentrale Kennzahl bleibt unabhaengig von Kartenbreite und Legende unmittelbar lesbar. |
|
||||||
| 2026-08-24 | ASSESSOR verwendet anwendungsweit die lokal gebuendelte variable Schriftfamilie Inter. Die verbindliche Hierarchie ist H1 56 px/600, H2 32 px/600, H3 18 px/600, H4 und Fliesstext 16 px mit 600 beziehungsweise 400, UI-Labels und Buttons 14 px, Metadaten und Tabellenkoepfe 13 px sowie kompakte Finding-Badges 12 px. Uppercase bleibt Tabellenkoepfen und kurzen technischen Kennzeichnungen vorbehalten; regulaere Ueberschriften und Feldbezeichnungen verwenden Title Case. | Eine lokal gebuendelte Einzelschrift verhindert plattformabhaengige Fallbacks und visuelles Springen. Semantische Rollen statt lokaler Einzelwerte schaffen nachvollziehbare Abstufungen, waehrend 16 px fuer inhaltstragenden Text die bestehende Lesbarkeitsgrenze bewahrt und dichte Finding-Zustandszeilen visuell hinter den Titel treten. |
|
| 2026-08-24 | ASSESSOR verwendet anwendungsweit die lokal gebuendelte variable Schriftfamilie Inter. Die verbindliche Hierarchie ist H1 56 px/600, H2 32 px/600, H3 18 px/600, H4 und Fliesstext 16 px mit 600 beziehungsweise 400, UI-Labels und Buttons 14 px, Metadaten und Tabellenkoepfe 13 px sowie kompakte Finding-Badges 12 px. Uppercase bleibt Tabellenkoepfen und kurzen technischen Kennzeichnungen vorbehalten; regulaere Ueberschriften und Feldbezeichnungen verwenden Title Case. | Eine lokal gebuendelte Einzelschrift verhindert plattformabhaengige Fallbacks und visuelles Springen. Semantische Rollen statt lokaler Einzelwerte schaffen nachvollziehbare Abstufungen, waehrend 16 px fuer inhaltstragenden Text die bestehende Lesbarkeitsgrenze bewahrt und dichte Finding-Zustandszeilen visuell hinter den Titel treten. |
|
||||||
|
|
@ -1610,9 +1617,14 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
|
|
||||||
| Risiko | Gegenmaßnahme |
|
| Risiko | Gegenmaßnahme |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| Auswahl, Fokus und fachlicher Status koennen erneut farblich vermischt werden, wenn Komponenten direkt Akzent-, Fokus- oder Severitytokens fuer eine persistente Markierung verwenden. | Persistente Auswahl ausschliesslich an `--marking-color` binden und in beiden Themes auf denselben Gelbwert regressionspruefen. `--focus-ring` bleibt nur fuer Tastaturfokus; Online-, Fehler-, Workflow- und Severityfarben bleiben fachlich getrennt. |
|
||||||
|
| Einzelansichten koennten trotz flacher gemeinsamer Tokens im Hover erneut dekorative Konturen setzen oder den Standard mit lokalen Radien und Schatten umgehen. | Appweite Projektregel und Regression an die flachen `--tile-*`-Tokens binden; normale und Hover-Zustaende getrennt von Fokus, Auswahl und Statusakzenten pruefen. Lokale Kachelgeometrie bleibt unzulaessig. |
|
||||||
|
| Kontur- und schattenlose Client-Info-Kacheln koennen bei spaeteren Themeaenderungen ihren Flaechenkontrast zum Dashboardgrund verlieren; interaktive Kacheln koennten ohne eigene Zustandsmarkierung schwer erkennbar werden. | Karten- und Grundflaeche ueber die bestehenden Theme-Tokens getrennt halten, Hover weiterhin ueber die Hoverflaeche zeigen und Fokus sowie Auswahl mit den vorhandenen farbigen Konturen sichern. Die Dashboardregression prueft den flachen Normalzustand getrennt von diesen Funktionszustaenden. |
|
||||||
|
| Quadratische Dashboardkarten koennen bei schmalen Breakpoints zu klein fuer vollstaendige Fakten-, Legenden- oder Balkeninhalte werden oder durch getrennte Raster erneut ungenutzte Flaechen erzeugen. | Kennzahlen und Inhaltskarten in demselben dichten Raster halten, Inhaltskarten ueber zwei Spalten und Zeilen skalieren, responsive gerade Spaltenzahlen verwenden und Quadratverhaeltnis, Rasterspans sowie das Fehlen fester Mindesthoehen regressionspruefen. Inhalte duerfen weiterhin weder geklemmt noch abgeschnitten werden. |
|
||||||
|
| Eine kompakte UI-Skala koennte versehentlich auch kontinuierlichen Finding- oder Reporttext verkleinern; umgekehrt koennte eine spaetere Body-Token-Aenderung Tabellen erneut auf Prosegroesse anheben. | Kontinuierlichen Lesetext fest an `--type-body-size` mit 16 px und strukturierte Daten an den getrennten 14-px-Token `--type-table-body-size` binden. Grid, Raw Data, allgemeine Tabellenzellen und die semantische Rollenskala gemeinsam regressionspruefen. |
|
||||||
| Die Compose-Konfiguration benennt den PostgreSQL-Dienst korrekt, waehrend das Backend den Host ignoriert und innerhalb des App-Containers gegen `127.0.0.1` verbindet; Build und Datenbank-Health bleiben dabei gruen, aber die App wird unhealthy. | `ASSESSOR_POSTGRES_HOST` in den Repository-Umgebungsvertrag und die Verbindungszeichenfolge aufnehmen, Loopback nur als lokalen Fallback verwenden und Docker-Hostname sowie lokalen Standard durch einen reinen Unit-Test absichern. |
|
| Die Compose-Konfiguration benennt den PostgreSQL-Dienst korrekt, waehrend das Backend den Host ignoriert und innerhalb des App-Containers gegen `127.0.0.1` verbindet; Build und Datenbank-Health bleiben dabei gruen, aber die App wird unhealthy. | `ASSESSOR_POSTGRES_HOST` in den Repository-Umgebungsvertrag und die Verbindungszeichenfolge aufnehmen, Loopback nur als lokalen Fallback verwenden und Docker-Hostname sowie lokalen Standard durch einen reinen Unit-Test absichern. |
|
||||||
| Lokale Kartenraster koennen Kacheln wieder auf Restbreite strecken; lange Titel, Werte, Labels, Zusatztexte, Medien oder Aktionen koennen abgeschnitten, ellipsiert oder verborgen werden, aus einer Kachel herausragen, Nachbarkacheln ueberdecken oder durch aggressive Umbruchregeln mitten im Wort getrennt werden. | Gemeinsame Segment-/Kacheltokens, feste Startausrichtung und eine zuletzt geladene Kachel-Vertragsschicht verwenden. Woerter nur an Wortgrenzen umbrechen und Zahlen zusammenhalten. Bei Platzmangel Inhalt intern neu anordnen oder die Kachel verbreitern beziehungsweise responsiv neu anordnen; Clipping, Clamp, Ellipse und Truncation sind keine zulaessigen Rueckfaelle. Statische Regressionen sichern Vertrag, relevante Ansichten und Kaskadenreihenfolge. |
|
| Lokale Kartenraster koennen Kacheln wieder auf Restbreite strecken; lange Titel, Werte, Labels, Zusatztexte, Medien oder Aktionen koennen abgeschnitten, ellipsiert oder verborgen werden, aus einer Kachel herausragen, Nachbarkacheln ueberdecken oder durch aggressive Umbruchregeln mitten im Wort getrennt werden. | Gemeinsame Segment-/Kacheltokens, feste Startausrichtung und eine zuletzt geladene Kachel-Vertragsschicht verwenden. Woerter nur an Wortgrenzen umbrechen und Zahlen zusammenhalten. Bei Platzmangel Inhalt intern neu anordnen oder die Kachel verbreitern beziehungsweise responsiv neu anordnen; Clipping, Clamp, Ellipse und Truncation sind keine zulaessigen Rueckfaelle. Statische Regressionen sichern Vertrag, relevante Ansichten und Kaskadenreihenfolge. |
|
||||||
| Trefferlistentitel und geoeffneter Finding-Titel koennen trotz unterschiedlicher Informationshierarchie optisch gleich laut oder in der falschen Reihenfolge erscheinen. | Trefferliste auf 16 px/500 und geoeffneten Detailtitel auf 20 px/600 ueber eigene zentrale Tokens binden; Finding-Abschnitt und Matrixkacheln getrennt regressionspruefen. |
|
| Trefferlistentitel und geoeffneter Finding-Titel koennen trotz unterschiedlicher Informationshierarchie optisch gleich laut oder in der falschen Reihenfolge erscheinen. | Trefferliste auf 16 px/400 und geoeffneten Detailtitel auf 20 px/600 ueber eigene zentrale Tokens binden; Finding-Abschnitt und Matrixkacheln getrennt regressionspruefen. |
|
||||||
| Finding-Badges und Outcome-Texte koennen bei derselben Groesse wie regulaere Labels eine lange Titelzeile optisch ueberladen und MITRE-Zuordnungen unnoetig umbrechen. | Fuer Finding-Zustandszeilen ausschliesslich den zentralen 12-px-Badgetoken verwenden; Farbe, Glyph, Rahmen und feste Dimensionsreihenfolge bleiben als zusaetzliche Bedeutungstraeger erhalten. |
|
| Finding-Badges und Outcome-Texte koennen bei derselben Groesse wie regulaere Labels eine lange Titelzeile optisch ueberladen und MITRE-Zuordnungen unnoetig umbrechen. | Fuer Finding-Zustandszeilen ausschliesslich den zentralen 12-px-Badgetoken verwenden; Farbe, Glyph, Rahmen und feste Dimensionsreihenfolge bleiben als zusaetzliche Bedeutungstraeger erhalten. |
|
||||||
| Lange Finding-Titel koennen bei Verwendung der globalen H2- oder allgemeinen Heading-Rolle Reader, Trefferlisten und Matrixkacheln visuell dominieren und Statusinformationen aus dem sichtbaren Bereich druecken. | Finding-spezifische Titelrollen regressionspruefen: Reader 18 px, Listen- und Matrixkacheln 16 px; Fliesstext bleibt mindestens 16 px. Globale Seitentitel und Pathfinder-Micro-Cards nicht ueber lokale Work-Regeln mitveraendern. |
|
| Lange Finding-Titel koennen bei Verwendung der globalen H2- oder allgemeinen Heading-Rolle Reader, Trefferlisten und Matrixkacheln visuell dominieren und Statusinformationen aus dem sichtbaren Bereich druecken. | Finding-spezifische Titelrollen regressionspruefen: Reader 18 px, Listen- und Matrixkacheln 16 px; Fliesstext bleibt mindestens 16 px. Globale Seitentitel und Pathfinder-Micro-Cards nicht ueber lokale Work-Regeln mitveraendern. |
|
||||||
| Unabhaengige Donutimplementierungen koennen erneut in offene 270-Grad-Gauges, abweichende Ringspuren, runde Bogenenden oder unterschiedlich platzierte Prozentwerte auseinanderlaufen; mehrteilige Verteilungen koennten den gemeinsamen Standard umgehen. | Vollkreis, Startposition, flache Enden, neutrale Spur, Strichstaerke und zentrale Wertebehandlung ueber gemeinsame Donuttokens und Regressionen fuer Datahub-Karten, Sidebar, Dashboardstatus und mehrteilige Risiko-Verteilung absichern. Neue Donuts muessen denselben Vertrag verwenden. |
|
| Unabhaengige Donutimplementierungen koennen erneut in offene 270-Grad-Gauges, abweichende Ringspuren, runde Bogenenden oder unterschiedlich platzierte Prozentwerte auseinanderlaufen; mehrteilige Verteilungen koennten den gemeinsamen Standard umgehen. | Vollkreis, Startposition, flache Enden, neutrale Spur, Strichstaerke und zentrale Wertebehandlung ueber gemeinsame Donuttokens und Regressionen fuer Datahub-Karten, Sidebar, Dashboardstatus und mehrteilige Risiko-Verteilung absichern. Neue Donuts muessen denselben Vertrag verwenden. |
|
||||||
|
|
@ -1795,6 +1807,24 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
## 12. Änderungsprotokoll
|
## 12. Änderungsprotokoll
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|
| 2026-08-25 | Phase-7-Markierungsfarbe appweit auf Gelb vereinheitlicht: Der neue Theme-Token `--marking-color` traegt in Dark und Light denselben Wert und steuert Kachelauswahl, allgemeine Auswahlrahmen/-marker, gemerkte Eintraege und Graphauswahl. Die violette Datahub-Auswahlkontur entfaellt. Tastaturfokus bleibt blau, Scanner-/Workflow-/Severitystatus behalten ihre fachlichen Farben. Projektregel und Kachelregression wurden aktualisiert. 24 fokussierte Auswahl-/Dashboard-/Graphtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Kachelstandard appweit auf die freigegebene flache Client-Info-Geometrie umgestellt: `--tile-border` ist in beiden Themes transparent, `--tile-radius` verwendet den zentralen Nullradius und `--tile-card-shadow` ist deaktiviert. Datahub-, Dashboard-, Sidebar-, Work- und Pathfinder-Hover veraendern die Flaeche ohne dekorative Kontur; Fokus, Auswahl und Statusmarkierungen bleiben sichtbar. `AGENTS.md` und die gemeinsamen Kachelregressionen wurden auf den neuen verbindlichen Standard aktualisiert. 24 fokussierte Kachel-/Dashboardtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Client-Info-Kacheldesign nach erneutem Referenzabgleich korrigiert: Die quadratischen Kennzahl- und Inhaltskacheln verwenden im Normalzustand keine helle Aussenkontur, keinen Kartenschatten und keine Rundung; auch die dekorative Linie unter Inhaltskartentiteln entfaellt. Flaechenkontrast und gemeinsamer Rasterabstand trennen die Kacheln wie im Sollbild, waehrend Hover, Tastaturfokus und aktive Filter funktional sichtbar bleiben. Neun fokussierte Dashboardtests, Designcheck und Client-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Client-Info nach Nutzerreferenz auf ein quadratisches SOC-Raster verdichtet: Alle sechs Kennzahlen sind kompakte 1-x-1-Kacheln, alle neun Inhaltskarten einheitliche 2-x-2-Kacheln im selben responsiven `dense`-Raster. Die bisherigen separaten Sechser- und Dreispaltenraster, gestreckten Kennzahlen sowie 384-px-Mindesthoehen entfallen; Faktenzeilen wurden innerhalb der quadratischen Karten kompakter angeordnet. Neun Dashboard-Regressionen, Gesamtlauf mit 755 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Eine Live-Browser-Geometriepruefung war in der Sitzung mangels Browser-Verbindung nicht verfuegbar. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-PurpleKnight-Altlabel korrigiert: Der FindingReader behandelt nun `Short Name` mit Leerzeichen ebenso wie `ShortName` als technische Kennung und zeigt beide ausschliesslich unter Additional Information. Eine synthetische Regression prueft Summary-Ausschluss und Additional-Zuordnung; alle 15 FindingReader-Tests sowie Client-/Server-Produktionsbuild erfolgreich. Keine Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Ergebnis- und Timeslot-Dichte nach Nutzerkorrektur nachgeschaerft: Im Resultfenster verwenden ausschliesslich Fenstertitel und wiederholte Inhaltsueberschrift H4 mit 16 px statt H3 mit 18 px. Die Finding-Trefferliste behaelt die verbindliche 16-px-Inhaltsuntergrenze, verwendet fuer Titel aber Regular 400, eine kompaktere Zeilenhoehe sowie schmalere Iconspalte, Abstaende und Innenraender; dadurch stehen mehr Zeichen pro Zeile ohne schwer wirkende Fettschrift zur Verfuegung. Timeslots besitzen in jedem Zustand eine 1-px-Kontur, 12 px Abstand und getrennte Interaktionszonen: links Checkbox plus Datum ausschliesslich fuer die Auswahl, rechts Zaehler plus Dreieck ausschliesslich zum Auf- und Zuklappen; Auswahl und Expansion sind nicht mehr gekoppelt. Am freigegebenen Dummy-Client wurden 16 px/400, 1-px-Kontur, 12-px-Abstand und die unabhaengigen Zustandswechsel geometrie- und zustandsbasiert verifiziert. Acht fokussierte Regressionen sowie der Gesamtlauf mit 753 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Keine Import-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7/13A-Rueckwaertskompatibilitaet fuer bereits persistierte PurpleKnight-HTML-Altimporte ergaenzt: Das fruehere `ResultDetail`-JSON wird read-only und begrenzt als `Column: value`-Tabelle projiziert, aktiviert damit das vorhandene Results-Fenster und wird bei vorhandener strukturierter Tabelle nicht mehr zusaetzlich im Finding oder unter Additional Information angezeigt. Das alte `Framework`-JSON wird ohne Ausfuehrung in lesbare MITRE- und ANSSI-Felder aufgeloest; die nur im alten HTML-Schema zusaetzlich sichtbaren Rohfelder `Grade` und `Exposure Type` werden in diesem Kompatibilitaetsfall ausgeblendet. Originalwerte und Persistenz bleiben unveraendert, ein Reimport ist fuer die Darstellungskorrektur nicht erforderlich. Zwei synthetische Regressionen pruefen JSON-Tabellenprojektion, Framework-Aufloesung und die Unterdrueckung redundanter Rohfelder. Gesamtlauf 753 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL-, Profil- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Kachelunterbau auf ausdruecklichen Nutzerwunsch anwendungsweit entfernt: Die gemeinsamen Segmenttokens fuer Flaeche und Kontur sind in dunklem und hellem Skin transparent. Donut-Racks, Mandantenkachelgruppen, Dashboardgruppen, Work-Matrix, Policy-, Pathfinder- und weitere tokengebundene Kachelgruppen behalten Radius, Inset und den 12-px-Gap, zeigen aber keine zweite sichtbare Gruppenflaeche oder Aussenkontur mehr. Einzelkacheln behalten unveraendert Flaeche, graue Kontur, Auswahlzustand und Schatten. Die verbindliche Projektregel wurde auf transparente Layoutsegmente aktualisiert. Im Browser wurden Data-Hub-Donutgruppe, Mandantenkachelgruppe und Work-Matrix in beiden Skins mit `rgba(0, 0, 0, 0)` fuer Hintergrund und Kontur verifiziert; die Work-Kachelkontur blieb sichtbar. Dreizehn fokussierte Segment-/Dashboardtests sowie der Gesamtlauf mit 751 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Client-Info-Kopf um den aktiven Clientkontext ergaenzt: Direkt hinter der H1 `Client Info` stehen jetzt das vorhandene rechtsgerichtete Dreieck im gelben Aktivton und der Name des aktiven Clients auf der H3-Typografiestufe. Der bisher rechtsbuendig gerenderte Clientname wird im Dashboard unterdrueckt, damit er nicht doppelt erscheint; Verbindungsstatus und die Kopfgeometrie der uebrigen Ansichten bleiben unveraendert. Am freigegebenen Dummy-Client wurden Reihenfolge `Client Info` → gelbes 24-px-Dreieck → `Dummy`, Aktivfarbe `rgb(217, 150, 42)` und die Abwesenheit einer zweiten rechten Namensinstanz verifiziert. Sechs fokussierte Header-/Icontests sowie der Gesamtlauf mit 751 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Client-Info um eine Timeslotauswahl in der oberen Kontextzeile ergaenzt: Das Dashboard verwendet die bereits bestehende zentrale WorkTimeslotControl und damit dieselbe servergestuetzte Timeslotliste, aktive ScanRun-Auswahl und `openProject`-Grenze wie Work; keine zweite Auswahl- oder Ladefachlogik wurde eingefuehrt. Der Selektor steht rechtsbuendig neben Client-, Bericht- und Datensatzkontext. Am freigegebenen Dummy-Client wurde der Wechsel von 18.08.2026 mit 318 Datensaetzen auf 22.07.2026 mit vier Scanberichten und 885 Datensaetzen in derselben Client-Info-Ansicht verifiziert. Elf fokussierte Dashboard-/Timeslottests sowie der Gesamtlauf mit 750 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Keine API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Donut-Units auf Nutzerwunsch vereinfacht: Die farbigen horizontalen Akzentstriche am oberen Rand der gemeinsamen DonutMetricCard wurden zentral samt ungenutzter Aufbauanimation entfernt. Ringtrack, Wertbogen, Kartenkontur, Titel, Werte, Prozentunit und alle Tonfarben bleiben unveraendert; die Aenderung gilt damit konsistent fuer Datahub und kompakte Sidebar-Donuts. In der laufenden Data-Hub-Ansicht wurden alle sechs Statusunits geprueft, ihr ehemaliges Pseudoelement liefert jeweils `content: none`. Sechs fokussierte Donut-/Datahubtests sowie der Gesamtlauf mit 750 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der zuvor dokumentierte PurpleKnight-Extractorfehler wurde unabhaengig davon im unmittelbar vorhergehenden Phase-13A-Schritt behoben; keine Fachlogik-, API-, Persistenz- oder Schemaaenderung, Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-13A-PurpleKnight-HTML-Paritaet zur Excel-Anzeige nachgehaertet: Extractor 1.1.0 projiziert die 26 Spalten von `Indicators results` in identischer Reihenfolge und mit Excel-kompatiblen Status-, Hybrid-, Laufzeit-, Framework-, Result- und Zeitwerten. Eingebettete `IndicatorReportObjects` werden ueber den neuen generischen `supportingTables`-Vertrag als eigenstaendige SI-Ergebnisblaetter erhalten und durch das vorhandene Result-Feld verlinkt; React enthaelt weiterhin keine Quellformatlogik. Die doppelte Source-Table-ID bei HTML mit bereits dekodierten Tabellen wurde durch explizit kollisionsfreie Tabellenindizes behoben. Synthetische Regressionen pruefen 26 Hauptspalten, Result-Verweis, Detailkopf/-zeile, Excel-Zeitformat und kanonische Result-Verknuepfung. Die beiden ausdruecklich freigegebenen Dummyberichte wurden read-only gegengeprueft: 119/119 Hauptzeilen, alle nicht-prosaischen Hauptfelder sowie alle 20 Detailtabellen einschliesslich jeder Kopfzeile und Zelle stimmen ueberein; Inhalte wurden weder als Fixture uebernommen noch dauerhaft protokolliert, temporaere Pruefartefakte wurden entfernt. Gesamtlauf 750 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL- oder Persistenzmigration; Phase 13A bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Severity-Verteilung um den unbezeichneten CIS-CAT-Anteil ergaenzt: Der bisher nur als dunkle Trackflaeche sichtbare Rest wurde am freigegebenen Dummy-Timeslot fachlich geprueft. Von 413 Datensaetzen ohne Severity stammen exakt 411 aus CIS-CAT und 2 aus Maester. Nur die 411 CIS-CAT-Datensaetze erscheinen deshalb als eigener neutralgrauer Abschnitt `CIS-CAT` mit 46 Prozent; die zwei Maester-Datensaetze bleiben ein ehrlicher, winziger unklassifizierter Rest. Die CIS-CAT-Legende fuehrt ueber die vorhandene Scanquellenfacette nach Work, statt einen nicht vorhandenen Severity-Wert vorzutaeuschen. Im Browser wurden Beschriftung `CIS-CAT`, Anzahl 411, Anteil 46 Prozent und die graue Segmentfarbe `rgb(138, 142, 149)` verifiziert. Acht Dashboardtests, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bereits dokumentierte fachfremde PurpleKnight-Extractorfehler im Gesamtlauf bleibt unveraendert; keine Import-, API-, Persistenz- oder Schemaaenderung, Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Dashboard nach Nutzerkorrektur bereinigt: Die Directory-overview-Kachel verwendet keine violett-blaue Akzentkante mehr, sondern rundum die normale gemeinsame 1-px-Kachelkontur. In den Statuskacheln stehen Prozentwerte nur noch zentriert im Donut; ausserhalb bleiben die absoluten Werte fuer Deviation, Passed und Not Run sowie die fachliche Erlaeuterung des Compliance Score. Am freigegebenen Client Dummy wurden ausschliesslich Geometrie- und Elementzustaende verifiziert: Directory overview rundum `rgb(85, 91, 101)` bei 1 px, keine aeusseren Prozentwerte, Donutwerte weiterhin vorhanden. Sieben Dashboard-Regressionspruefungen, Local-only-Sicherheitscheck, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der Gesamtlauf erreichte 748 bestandene und 8 uebersprungene Tests; ein fachfremder PurpleKnight-HTML-Extractor-Test bleibt wegen einer doppelten synthetischen Source-Table-ID rot und wird durch diese reine Dashboardaenderung nicht beruehrt. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Segmentabstand auf Nutzerwunsch vergroessert: Das gemeinsame `--segment-gap` verwendet jetzt 12 px statt 8 px und gilt damit konsistent fuer alle Kachelsegmente. Die Work-Matrix fuehrt denselben Wert in ihrer Spaltenberechnung und eine entsprechend angepasste virtuelle Zeilenhoehe, damit CSS-Layout und Scrollmathematik uebereinstimmen. Im laufenden, ausdruecklich freigegebenen Client Dummy wurden ausschliesslich Geometriesignale geprueft: horizontaler und vertikaler Abstand jeweils exakt 12 px, Kachelbreite weiterhin 320 px. Drei fokussierte Vertragsregressionen sowie der Gesamtlauf mit 748 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitspruefung, Designcheck und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7/12/13A-Reimportzielbindung fuer zusammengefuehrte historische Timeslots korrigiert: Der werkzeugspezifische Reimport verwendet jetzt die Assessment-ID des konkret zu ersetzenden ScanRuns statt pauschal der ersten Assessment-ID des kalendarisch gruppierten Timeslots. Dadurch bleibt die serverseitige fail-closed-Pruefung unveraendert wirksam, ohne gueltige Altimporte abzulehnen, deren vier Scannerlaeufe historisch in getrennten Assessments gespeichert und im Datahub zu einem Tag zusammengefuehrt werden. Zwei UI-Vertragsregressionen und neun HTTP-Reimporttests sind gruen. Am ausdruecklich freigegebenen Client Dummy wurde der Fehler mit dem unveraenderten PingCastle-Original reproduziert und nach der Korrektur bis zur sicheren, vorausgewaehlten Vorschau `Expected replacement source` verifiziert; die Vorschau wurde abgebrochen, kein Reimport abgeschlossen und keine Daten veraendert. Gesamtlauf 748 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-, Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL-, Schema- oder Persistenzaenderung; Phasen 7, 12 und 13A bleiben abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-13A-Scanner-HTML-Erweiterung umgesetzt: Neue versionierte Source Extractors lesen PurpleKnight-HTML aus `reportJSON` plus Kategorie-Indikatorzuweisungen und erzeugte Maester-Einzel- beziehungsweise Multi-Tenant-HTML-Berichte aus ihrem eingebetteten Ergebnis-JSON, ohne JavaScript auszufuehren. Originalwerte und Objektpfade bleiben erhalten; HTML-Inhalt wird nur fuer sichere Anzeigewerte in Klartext dekodiert. Script-only-Berichte gelten nach der Formatdekodierung als auswertbar, sobald ein registrierter Extractor FindingCandidates liefert. Synthetische Fixtures und vier End-to-End-Regressionen pruefen Profilwahl, Einzel-/Multi-Tenant-Extraktion, aktive Script-Inhalte, Feldabbildung und kanonische Persistenzprojektion. Der ausdruecklich bereitgestellte PurpleKnight-Dummy wurde nur ueber inhaltsfreie technische Signale erfolgreich gegen Profil und Extractor verifiziert; die bereitgestellte Maester-Datei ist ein Report-Template ohne `Tests` und wird folgerichtig nicht als Ergebnisbericht extrahiert. Gesamtlauf 748 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-Sicherheitspruefung, Designcheck und Client-/Server-Produktionsbuild erfolgreich. Keine API-, SQL-, Persistenz- oder Profilversionsaenderung; Phase 13A bleibt abgeschlossen. |
|
||||||
|
| 2026-08-25 | Phase-7-Typografiedichte auf Nutzerentscheidung gezielt teilzurueckgenommen: Inter, H1 56 px, H2 32 px, H3 18 px, H4 16 px, 20-px-Detailtitel und kontinuierlicher Lesetext mit 16 px bleiben unveraendert. Neu getrennte strukturierte Datenrollen setzen Tabellen-, Grid- und Raw-Data-Inhalte auf 14 px; Controls auf 13 px; Labels, Metadaten und Tabellenkoepfe auf 12 px; Badges auf 11 px. Allgemeine Tabellenzellen, Work Grid, Raw Data und Kompatibilitaetstokens verwenden die neuen Rollen, ohne Dashboard-, Importer-, API-, Persistenz- oder Schemaaenderung. Vier fokussierte Testdateien mit 10 Tests sowie der Gesamtlauf mit 744 bestandenen und 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only-Sicherheitspruefung, Designcheck mit 0 Rohfarben und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Phase 7 bleibt abgeschlossen. |
|
||||||
| 2026-08-24 | Phase-14-Containerverbindung korrigiert: Die Repository-Laufzeit uebernimmt `ASSESSOR_POSTGRES_HOST` in die PostgreSQL-Verbindungszeichenfolge, sodass der App-Container den Compose-Dienst `postgres` statt seines eigenen Loopbacks anspricht. `ASSESSOR_DATABASE_URL` behaelt Vorrang und `127.0.0.1` bleibt der lokale Standard. Docker-Hostname und lokaler Fallback sind synthetisch regressionsgeprueft; Gesamtlauf 735 bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Schema- oder Datenmigration; Phase 14 bleibt offen. |
|
| 2026-08-24 | Phase-14-Containerverbindung korrigiert: Die Repository-Laufzeit uebernimmt `ASSESSOR_POSTGRES_HOST` in die PostgreSQL-Verbindungszeichenfolge, sodass der App-Container den Compose-Dienst `postgres` statt seines eigenen Loopbacks anspricht. `ASSESSOR_DATABASE_URL` behaelt Vorrang und `127.0.0.1` bleibt der lokale Standard. Docker-Hostname und lokaler Fallback sind synthetisch regressionsgeprueft; Gesamtlauf 735 bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Schema- oder Datenmigration; Phase 14 bleibt offen. |
|
||||||
| 2026-08-24 | Phase-7-Segment-/Kachelgrammatik nach Nutzerreferenz anwendungsweit umgesetzt und als Projektvorgabe verankert: gemeinsame Segment- und Kacheltokens fuer Flaeche, Kontur, Radius, Inset, Gap, Schatten, Auswahl und Breiten; flache Gruppencontainer; gleichartige Inhalts-, Aktions- und Hinzufuegekacheln; Statusfarben nur als Akzent; unvollstaendige Zeilen bleiben unstretched. Datahub, Dashboard, Sidebar, Work-TileGrid, DB Structure, Policy Analysis und statische Pathfinder-Karten verwenden den Vertrag. Die verbindliche Nutzerregel verbietet abgeschnittene, geklemmte, ellipsierte oder verborgene Titel, Werte, Labels, Zusatztexte und Aktionen. Dashboardstatuskarten wurden auf 290 beziehungsweise 210 px abgestimmt; Anzahl und Prozentwert stehen getrennt, sodass `Compliance` und Werte wie `453` vollstaendig bleiben. Das Donut-Raster nutzt auf Desktop sechs Spalten und die Matrix verteilt ihre feste Spaltenzahl ueber die volle Segmentbreite. Bei Platzmangel wird Inhalt neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv umgebrochen. Sechzehn fokussierte Layout-/Donuttests und der Gesamtlauf mit 736 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung mit 0 Rohfarben sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-24 | Phase-7-Segment-/Kachelgrammatik nach Nutzerreferenz anwendungsweit umgesetzt und als Projektvorgabe verankert: gemeinsame Segment- und Kacheltokens fuer Flaeche, Kontur, Radius, Inset, Gap, Schatten, Auswahl und Breiten; flache Gruppencontainer; gleichartige Inhalts-, Aktions- und Hinzufuegekacheln; Statusfarben nur als Akzent; unvollstaendige Zeilen bleiben unstretched. Datahub, Dashboard, Sidebar, Work-TileGrid, DB Structure, Policy Analysis und statische Pathfinder-Karten verwenden den Vertrag. Die verbindliche Nutzerregel verbietet abgeschnittene, geklemmte, ellipsierte oder verborgene Titel, Werte, Labels, Zusatztexte und Aktionen. Dashboardstatuskarten wurden auf 290 beziehungsweise 210 px abgestimmt; Anzahl und Prozentwert stehen getrennt, sodass `Compliance` und Werte wie `453` vollstaendig bleiben. Das Donut-Raster nutzt auf Desktop sechs Spalten und die Matrix verteilt ihre feste Spaltenzahl ueber die volle Segmentbreite. Bei Platzmangel wird Inhalt neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv umgebrochen. Sechzehn fokussierte Layout-/Donuttests und der Gesamtlauf mit 736 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung mit 0 Rohfarben sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
| 2026-08-24 | Phase-7-Finding-Titelhierarchie nach visueller Nutzerkorrektur nachgeschaerft: Trefferlistentitel bleiben 16 px, verwenden aber Medium 500 statt Semibold; der geoeffnete Finding-Titel verwendet eine neue zentrale 20-px-Detailtitelrolle mit Semibold. Finding-Abschnitt 18 px, Matrixkacheln 16 px und die 16-px-Untergrenze fuer Inhalt bleiben unveraendert. Titel brechen nur an Wortgrenzen. Drei fokussierte Typografieregressionen sowie der Gesamtlauf mit 736 bestandenen und 8 uebersprungenen Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-24 | Phase-7-Finding-Titelhierarchie nach visueller Nutzerkorrektur nachgeschaerft: Trefferlistentitel bleiben 16 px, verwenden aber Medium 500 statt Semibold; der geoeffnete Finding-Titel verwendet eine neue zentrale 20-px-Detailtitelrolle mit Semibold. Finding-Abschnitt 18 px, Matrixkacheln 16 px und die 16-px-Untergrenze fuer Inhalt bleiben unveraendert. Titel brechen nur an Wortgrenzen. Drei fokussierte Typografieregressionen sowie der Gesamtlauf mit 736 bestandenen und 8 uebersprungenen Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
|
@ -2078,4 +2108,3 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
| 2026-07-17 | Phase 1 abgeschlossen: synthetische, quellengestützte Fixtures für PingCastle, CIS-CAT und Maester ergänzt und über den generischen Importweg automatisiert geprüft; 21 Testdateien mit 51 Tests, Local-only-Prüfung und Produktionsbuild erfolgreich. |
|
| 2026-07-17 | Phase 1 abgeschlossen: synthetische, quellengestützte Fixtures für PingCastle, CIS-CAT und Maester ergänzt und über den generischen Importweg automatisiert geprüft; 21 Testdateien mit 51 Tests, Local-only-Prüfung und Produktionsbuild erfolgreich. |
|
||||||
| 2026-07-17 | Phase 1 begonnen: Format- und Fehlerfalltests erweitert, generische Fixture und Testbaseline ergänzt, Gesamtworkflow und Belastungsfälle automatisiert, leere Importe abgewiesen und englische Titelzuordnung korrigiert; Phase bleibt bis zum vollständigen Quellenkorpus in Arbeit. |
|
| 2026-07-17 | Phase 1 begonnen: Format- und Fehlerfalltests erweitert, generische Fixture und Testbaseline ergänzt, Gesamtworkflow und Belastungsfälle automatisiert, leere Importe abgewiesen und englische Titelzuordnung korrigiert; Phase bleibt bis zum vollständigen Quellenkorpus in Arbeit. |
|
||||||
| 2026-07-17 | Ersten versionierten Umsetzungsplan mit Zielarchitektur, Phasen, Abnahmekriterien, Entscheidungen und Risiken angelegt. |
|
| 2026-07-17 | Ersten versionierten Umsetzungsplan mit Zielarchitektur, Phasen, Abnahmekriterien, Entscheidungen und Risiken angelegt. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,4 +30,14 @@ All files below are deliberately small, synthetic, and free of customer or compa
|
||||||
- https://github.com/maester365/maester/blob/main/powershell/public/core/Convert-MtResultsToFlatObject.ps1
|
- https://github.com/maester365/maester/blob/main/powershell/public/core/Convert-MtResultsToFlatObject.ps1
|
||||||
- https://maester.dev/docs/commands/Invoke-Maester/
|
- https://maester.dev/docs/commands/Invoke-Maester/
|
||||||
|
|
||||||
|
- File: `maester/test-results.synthetic.html`
|
||||||
|
- Models the inert JSON object embedded by the official single-file Maester HTML report generator; it contains synthetic test values only.
|
||||||
|
- Sources:
|
||||||
|
- https://github.com/maester365/maester/blob/main/powershell/public/Get-MtHtmlReport.ps1
|
||||||
|
- https://github.com/maester365/maester/blob/main/powershell/public/core/ConvertTo-MtMaesterResult.ps1
|
||||||
|
- https://maester.dev/docs/commands/Get-MtHtmlReport/
|
||||||
|
|
||||||
|
- File: `purple-knight/security-assessment.synthetic.html`
|
||||||
|
- Models the script-embedded `window.reportJSON` and category indicator assignments observed in a locally supplied PurpleKnight dummy report. No source values were copied into this fixture.
|
||||||
|
|
||||||
When an anonymized real export can legally be added, it should supplement these structural fixtures and must be reviewed for tenant names, domains, accounts, identifiers, evidence, comments, and embedded metadata first.
|
When an anonymized real export can legally be added, it should supplement these structural fixtures and must be reviewed for tenant names, domains, accounts, identifiers, evidence, comments, and embedded metadata first.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>Maester synthetic report</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
var syntheticMaesterResults = {
|
||||||
|
"Result": "Failed",
|
||||||
|
"FailedCount": 1,
|
||||||
|
"PassedCount": 1,
|
||||||
|
"TotalCount": 2,
|
||||||
|
"Tests": [
|
||||||
|
{
|
||||||
|
"Index": 1,
|
||||||
|
"Id": "MT.9001",
|
||||||
|
"Title": "Ensure the synthetic policy is configured",
|
||||||
|
"Name": "MT.9001: Ensure the synthetic policy is configured",
|
||||||
|
"HelpUrl": "https://maester.dev/docs/tests/MT.9001",
|
||||||
|
"Severity": "High",
|
||||||
|
"Tag": ["Synthetic", "Entra"],
|
||||||
|
"Result": "Failed",
|
||||||
|
"ScriptBlock": "Describe 'synthetic test' { It 'is deterministic' { $true | Should -BeTrue } }",
|
||||||
|
"ScriptBlockFile": "MT.9001.Tests.ps1",
|
||||||
|
"ErrorRecord": null,
|
||||||
|
"Block": "Maester Security Tests",
|
||||||
|
"Duration": "00:00:01",
|
||||||
|
"ResultDetail": {
|
||||||
|
"TestTitle": "Synthetic policy",
|
||||||
|
"TestDescription": "Checks a synthetic policy without tenant data.",
|
||||||
|
"TestResult": "The synthetic policy needs review.",
|
||||||
|
"TestSkipped": false,
|
||||||
|
"SkippedReason": "",
|
||||||
|
"TestInvestigate": false,
|
||||||
|
"Severity": "High",
|
||||||
|
"Service": "Entra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Index": 2,
|
||||||
|
"Id": "MT.9002",
|
||||||
|
"Title": "Ensure the synthetic control is enabled",
|
||||||
|
"Name": "MT.9002: Ensure the synthetic control is enabled",
|
||||||
|
"HelpUrl": "https://maester.dev/docs/tests/MT.9002",
|
||||||
|
"Severity": "Medium",
|
||||||
|
"Tag": ["Synthetic"],
|
||||||
|
"Result": "Passed",
|
||||||
|
"ScriptBlockFile": "MT.9002.Tests.ps1",
|
||||||
|
"Block": "Maester Security Tests",
|
||||||
|
"Duration": "00:00:01",
|
||||||
|
"ResultDetail": {
|
||||||
|
"TestDescription": "Checks a second synthetic control.",
|
||||||
|
"TestResult": "The synthetic control is enabled.",
|
||||||
|
"TestSkipped": false,
|
||||||
|
"Severity": "Medium",
|
||||||
|
"Service": "Entra"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Blocks": [],
|
||||||
|
"EndOfJson": true
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script>window.__syntheticActiveContentExecuted = true;</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="utf-8"><title>Purple Knight synthetic report</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script>
|
||||||
|
window.reportJSON = {
|
||||||
|
"IndicatorsFiles": ["synthetic-indicator.json"],
|
||||||
|
"reportResultsList": [{
|
||||||
|
"ADType": "AD",
|
||||||
|
"IsCustom": false,
|
||||||
|
"ReportResults": {"TotalScore": 50, "TotalCount": 2},
|
||||||
|
"ReportCategories": [{"Category": {"ID": 1, "Name": "Synthetic Category"}, "Indicators": ["00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000002"]}]
|
||||||
|
}],
|
||||||
|
"GeneralConfig": {"Categories": [{"ID": 1, "Name": "Synthetic Category", "Description": "Synthetic only", "Weight": 1}]},
|
||||||
|
"AppVersion": "synthetic",
|
||||||
|
"ReportName": "Purple Knight synthetic report"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
window.reportJSON.GeneralConfig.Categories.map((c) => "Category_" + c.ID).forEach((n) => { window[n] = {} });
|
||||||
|
window["Category_1"]["00000000-0000-4000-8000-000000000001"] = {
|
||||||
|
"ResIndicator": {
|
||||||
|
"State": "Completed", "Grade": "F", "IsEvaluated": true, "IsPassed": false, "IsFailed": true,
|
||||||
|
"ExposureType": "IOE", "IsFailedToRun": false, "IsAborted": false,
|
||||||
|
"UUID": "00000000-0000-4000-8000-000000000001", "CategoryID": 1,
|
||||||
|
"ShortName": "SI900001", "Name": "Synthetic privileged group exposure",
|
||||||
|
"Description": "<p>A synthetic description.</p>", "Version": "1.0.0", "Weight": 10, "Severity": "High",
|
||||||
|
"SecurityFrameworks": [{"Name": "MITRE ATT&CK", "Tags": ["T0000"]}],
|
||||||
|
"Selected": true, "Targets": ["AD"], "CustomIndicator": false, "DataSources": ["Synthetic"]
|
||||||
|
},
|
||||||
|
"ExecutionResult": {"Status": "Failed", "Score": 10, "ResultMessage": "Synthetic exposure found.", "Remediation": "<p>Apply the synthetic remediation.</p>", "Filename": null},
|
||||||
|
"IndicatorReportObjects": [["Object: Synthetic value", "EventTimestamp: 5/27/2026 12:37:57 PM", "Ignored: False"]],
|
||||||
|
"TotalResultsCount": 1
|
||||||
|
};
|
||||||
|
window["Category_1"]["00000000-0000-4000-8000-000000000002"] = {
|
||||||
|
"ResIndicator": {
|
||||||
|
"State": "Completed", "Grade": "A", "IsEvaluated": true, "IsPassed": true, "IsFailed": false,
|
||||||
|
"ExposureType": "None", "IsFailedToRun": false, "IsAborted": false,
|
||||||
|
"UUID": "00000000-0000-4000-8000-000000000002", "CategoryID": 1,
|
||||||
|
"ShortName": "SI900002", "Name": "Synthetic secure configuration",
|
||||||
|
"Description": "<p>A second synthetic description.</p>", "Version": "1.0.0", "Weight": 5, "Severity": "Medium",
|
||||||
|
"SecurityFrameworks": [], "Selected": true, "Targets": ["AD"], "CustomIndicator": false, "DataSources": ["Synthetic"]
|
||||||
|
},
|
||||||
|
"ExecutionResult": {"Status": "Pass", "Score": 0, "ResultMessage": "Synthetic check passed.", "Remediation": "", "Filename": null},
|
||||||
|
"IndicatorReportObjects": [], "TotalResultsCount": 0
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script>window.__syntheticActiveContentExecuted = true;</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -78,7 +78,10 @@ export function App() {
|
||||||
setDataHubDetailClientName(isDetailView ? clientName : undefined);
|
setDataHubDetailClientName(isDetailView ? clientName : undefined);
|
||||||
}, []);
|
}, []);
|
||||||
const connectionIndicator = <span className={`connection-indicator connection-${backendStatus}`} role="status" aria-label={backendLabel} title={backendLabel}><i className="connection-state-square is-offline" aria-hidden="true"/><i className="connection-state-square is-online" aria-hidden="true"/></span>;
|
const connectionIndicator = <span className={`connection-indicator connection-${backendStatus}`} role="status" aria-label={backendLabel} title={backendLabel}><i className="connection-state-square is-offline" aria-hidden="true"/><i className="connection-state-square is-online" aria-hidden="true"/></span>;
|
||||||
const headerContext = <div className="app-header-context">{connectionIndicator}{headerClientName && <span className="app-header-client-name" title={headerClientName}>{headerClientName}</span>}</div>;
|
const headerContext = <div className="app-header-context">{connectionIndicator}{headerClientName && activeArea !== 'dashboard' && <span className="app-header-client-name" title={headerClientName}>{headerClientName}</span>}</div>;
|
||||||
|
const dashboardClientContext = activeArea === 'dashboard' && headerClientName
|
||||||
|
? <span className="area-client-context-inline"><span className="area-client-marker"><TriangleIcon direction="right" /></span><span className="area-client-name" title={headerClientName}>{headerClientName}</span></span>
|
||||||
|
: null;
|
||||||
|
|
||||||
const readerToolbar = <div className="reader-toolbar">
|
const readerToolbar = <div className="reader-toolbar">
|
||||||
<div className="live-summary" aria-live="polite"><span>Shown <b>{visible.toLocaleString('en-US')}</b></span><span>Marked <b>{marked.toLocaleString('en-US')}</b></span></div>
|
<div className="live-summary" aria-live="polite"><span>Shown <b>{visible.toLocaleString('en-US')}</b></span><span>Marked <b>{marked.toLocaleString('en-US')}</b></span></div>
|
||||||
|
|
@ -119,7 +122,7 @@ export function App() {
|
||||||
<div className="result-dock-slot" id={RESULT_DOCK_SLOT_ID} />
|
<div className="result-dock-slot" id={RESULT_DOCK_SLOT_ID} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</> : <div className="area-topbar"><span className="area-topbar-heading"><strong>{activeArea === 'data-hub' && dataHubClientView ? 'Client' : AREA_TOPBAR_TITLE[activeArea]}</strong>{activeArea === 'graph' && <PathfinderFilterTrail />}</span>{headerContext}</div>}
|
</> : <div className="area-topbar"><span className="area-topbar-heading"><strong>{activeArea === 'data-hub' && dataHubClientView ? 'Client' : AREA_TOPBAR_TITLE[activeArea]}</strong>{dashboardClientContext}{activeArea === 'graph' && <PathfinderFilterTrail />}</span>{headerContext}</div>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|
@ -158,4 +161,3 @@ export function App() {
|
||||||
<ImportWorkflowDialog/>
|
<ImportWorkflowDialog/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,21 @@ export interface ScannerReportSectionCandidate {
|
||||||
attributes: Record<string, JsonValue>;
|
attributes: Record<string, JsonValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A report-native detail table that supports findings but is not itself a
|
||||||
|
* finding source. Importers keep these tables separate from candidates so a
|
||||||
|
* workbook-style result link can be reconstructed without tool logic in the
|
||||||
|
* presentation layer.
|
||||||
|
*/
|
||||||
|
export interface SupportingTableCandidate {
|
||||||
|
version: 1;
|
||||||
|
name: string;
|
||||||
|
headers: string[];
|
||||||
|
rows: JsonValue[][];
|
||||||
|
source: SourceLocator;
|
||||||
|
attributes: Record<string, JsonValue>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnvironmentObservationCandidate {
|
export interface EnvironmentObservationCandidate {
|
||||||
version: 1;
|
version: 1;
|
||||||
subject: EnvironmentSubject;
|
subject: EnvironmentSubject;
|
||||||
|
|
@ -61,6 +76,7 @@ export interface SourceExtractionResult {
|
||||||
extractor: ImportProcessorReference;
|
extractor: ImportProcessorReference;
|
||||||
match: SourceExtractorMatch;
|
match: SourceExtractorMatch;
|
||||||
candidates: FindingCandidate[];
|
candidates: FindingCandidate[];
|
||||||
|
supportingTables?: SupportingTableCandidate[];
|
||||||
reportSections?: ScannerReportSectionCandidate[];
|
reportSections?: ScannerReportSectionCandidate[];
|
||||||
environmentObservations?: EnvironmentObservationCandidate[];
|
environmentObservations?: EnvironmentObservationCandidate[];
|
||||||
diagnostics: ImportDiagnostic[];
|
diagnostics: ImportDiagnostic[];
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||||
import { useApp } from '../../app/AppProvider';
|
import { useApp } from '../../app/AppProvider';
|
||||||
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
|
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
|
||||||
|
|
@ -7,6 +7,7 @@ import type { FindingRecord } from '../../domain/finding';
|
||||||
import type { ImportProfile } from '../../domain/import-profile';
|
import type { ImportProfile } from '../../domain/import-profile';
|
||||||
import type { Review } from '../../domain/review';
|
import type { Review } from '../../domain/review';
|
||||||
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
|
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
|
||||||
|
import { WorkTimeslotControl } from '../search-filter/WorkTimeslotControl';
|
||||||
|
|
||||||
const assessorApi = new AssessorApiClient();
|
const assessorApi = new AssessorApiClient();
|
||||||
|
|
||||||
|
|
@ -55,7 +56,7 @@ function DashboardStatusMetric({ label, value, percentage, tone, large = false,
|
||||||
</div>
|
</div>
|
||||||
<div className="dashboard-status-copy">
|
<div className="dashboard-status-copy">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<strong>{large ? `${roundedPercentage} %` : <>{value.toLocaleString('en-US')}<small>{roundedPercentage}%</small></>}</strong>
|
{!large && <strong>{value.toLocaleString('en-US')}</strong>}
|
||||||
{helper && <small>{helper}</small>}
|
{helper && <small>{helper}</small>}
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
|
|
@ -88,6 +89,12 @@ export interface DashboardRiskTool {
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dashboardCisCatSeverityValue = 'dashboard-cis-cat-without-severity';
|
||||||
|
|
||||||
|
export interface DashboardCisCatSeverityRemainder extends DynamicFacetValue {
|
||||||
|
sourceValues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTool[] {
|
export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTool[] {
|
||||||
const tools = new Map<string, DashboardRiskTool>();
|
const tools = new Map<string, DashboardRiskTool>();
|
||||||
values.forEach(value => {
|
values.forEach(value => {
|
||||||
|
|
@ -102,6 +109,23 @@ export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTo
|
||||||
return [...tools.values()];
|
return [...tools.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function dashboardCisCatSeverityRemainder(
|
||||||
|
sourceValues: DynamicFacetValue[],
|
||||||
|
cisCatSeverityValues: DynamicFacetValue[]
|
||||||
|
): DashboardCisCatSeverityRemainder | undefined {
|
||||||
|
const tool = dashboardRiskTools(sourceValues)
|
||||||
|
.find(item => /^cis-cat(?:\s+pro)?$/i.test(item.label.trim()));
|
||||||
|
if (!tool) return undefined;
|
||||||
|
const classified = cisCatSeverityValues.reduce((sum, value) => sum + value.count, 0);
|
||||||
|
const count = Math.max(0, tool.count - classified);
|
||||||
|
return count > 0 ? {
|
||||||
|
value: dashboardCisCatSeverityValue,
|
||||||
|
label: 'CIS-CAT',
|
||||||
|
count,
|
||||||
|
sourceValues: tool.sourceValues
|
||||||
|
} : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Compact distribution based on the supplied dashboard reference. */
|
/** Compact distribution based on the supplied dashboard reference. */
|
||||||
function SegmentedDistribution({ heading, total, values, severity = false, formatLabel = label => label, onSelect }: SegmentedDistributionProps) {
|
function SegmentedDistribution({ heading, total, values, severity = false, formatLabel = label => label, onSelect }: SegmentedDistributionProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -369,6 +393,194 @@ function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profi
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DashboardEnvironmentMetric {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
detail?: string;
|
||||||
|
restricted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardEnvironmentSummary {
|
||||||
|
directoryFacts: Array<{ label: string; value: string }>;
|
||||||
|
coverage: DashboardEnvironmentMetric[];
|
||||||
|
identity: DashboardEnvironmentMetric[];
|
||||||
|
controlPaths: DashboardEnvironmentMetric[];
|
||||||
|
certificates: DashboardEnvironmentMetric[];
|
||||||
|
gpo: DashboardEnvironmentMetric[];
|
||||||
|
risk: DashboardEnvironmentMetric[];
|
||||||
|
resilienceFacts: Array<{ label: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observationFor(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string) {
|
||||||
|
return snapshot?.observations.find(observation => observation.property === property
|
||||||
|
|| Boolean(label && observation.label === label && (!sectionKey || observation.sectionKey === sectionKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function observationNumber(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string, sectionKey?: string): number {
|
||||||
|
const observation = observationFor(snapshot, property, label, sectionKey);
|
||||||
|
const value = observation?.normalizedValue ?? observation?.originalValue;
|
||||||
|
const numeric = typeof value === 'number' ? value : Number(value);
|
||||||
|
return Number.isFinite(numeric) ? numeric : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observationText(snapshot: EnvironmentSnapshot | undefined, property: string, label?: string): string {
|
||||||
|
const observation = observationFor(snapshot, property, label);
|
||||||
|
return observation ? displayValue(observation) : 'No data';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionObservationCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||||||
|
return snapshot?.observations.filter(observation => observation.sectionKey === sectionKey).length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionFor(snapshot: EnvironmentSnapshot | undefined, sectionKey: string) {
|
||||||
|
return snapshot?.sections.find(section => section.sectionKey === sectionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionTableCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||||||
|
return sectionFor(snapshot, sectionKey)?.tables.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionRowCount(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): number {
|
||||||
|
return sectionFor(snapshot, sectionKey)?.tables.reduce((sum, table) => sum + table.rows.length, 0) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRestricted(snapshot: EnvironmentSnapshot | undefined, sectionKey: string): boolean {
|
||||||
|
return ['license-restricted', 'access-denied'].includes(sectionFor(snapshot, sectionKey)?.state ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dashboardEnvironmentSummary(snapshot?: EnvironmentSnapshot): DashboardEnvironmentSummary {
|
||||||
|
const operatingSystems = [...new Set(snapshot?.observations
|
||||||
|
.filter(observation => observation.property === 'operating_system.name')
|
||||||
|
.map(displayValue) ?? [])];
|
||||||
|
return {
|
||||||
|
directoryFacts: [
|
||||||
|
{ label: 'Domain', value: observationText(snapshot, 'domain.fqdn', 'Domain') },
|
||||||
|
{ label: 'Domain FL', value: observationText(snapshot, 'domain.functional_level', 'Domain Functional Level') },
|
||||||
|
{ label: 'Forest FL', value: observationText(snapshot, 'forest.functional_level', 'Forest Functional Level') },
|
||||||
|
{ label: 'Schema', value: observationText(snapshot, 'forest.schema_version', 'Schema version') },
|
||||||
|
{ label: 'Domain Controllers', value: observationText(snapshot, 'domain.controller_count', 'DC count') },
|
||||||
|
{ label: 'PingCastle', value: observationText(snapshot, 'report.engine_version', 'PingCastle engine version') }
|
||||||
|
],
|
||||||
|
coverage: [
|
||||||
|
{ label: 'Observed', value: snapshot?.coverage.observed ?? 0 },
|
||||||
|
{ label: 'Empty', value: snapshot?.coverage.empty ?? 0 },
|
||||||
|
{ label: 'Explicitly absent', value: snapshot?.coverage.explicitlyAbsent ?? 0 },
|
||||||
|
{ label: 'Restricted', value: snapshot?.coverage.restricted ?? 0 },
|
||||||
|
{ label: 'Incomplete', value: snapshot?.coverage.incomplete ?? 0 }
|
||||||
|
],
|
||||||
|
identity: [
|
||||||
|
{ label: 'Admin groups', value: sectionObservationCount(snapshot, 'sectionAdminGroups'), detail: 'observations', restricted: isRestricted(snapshot, 'sectionAdminGroups') },
|
||||||
|
{ label: 'Never-expiring users', value: sectionObservationCount(snapshot, 'sectionneverexpiresuser') },
|
||||||
|
{ label: 'Account analysis', value: sectionObservationCount(snapshot, 'sectionComputerInformation'), restricted: isRestricted(snapshot, 'sectionComputerInformation') },
|
||||||
|
{ label: 'Honey Pot', value: sectionObservationCount(snapshot, 'sectionUserInformation'), restricted: isRestricted(snapshot, 'sectionUserInformation') },
|
||||||
|
{ label: 'Trusted delegation', value: sectionObservationCount(snapshot, 'sectionenabledtrusteddelegationcomputer') }
|
||||||
|
],
|
||||||
|
controlPaths: [
|
||||||
|
{ label: 'Control paths', value: sectionObservationCount(snapshot, 'sectionControlPathsAnalysis'), detail: 'observations' },
|
||||||
|
{ label: 'Indirect-path tables', value: sectionTableCount(snapshot, 'sectionControlPathsAnalysis') },
|
||||||
|
{ label: 'Foreign-domain rows', value: sectionRowCount(snapshot, 'sectionControlPathsAnalysis') },
|
||||||
|
{ label: 'Trusts score', value: observationNumber(snapshot, 'risk.trusts', 'Trusts score') },
|
||||||
|
{ label: 'Discovered domains', value: sectionRowCount(snapshot, 'sectionTrustsdetails') }
|
||||||
|
],
|
||||||
|
certificates: [
|
||||||
|
{ label: 'PKI', value: sectionObservationCount(snapshot, 'sectionPKI'), detail: 'observations' },
|
||||||
|
{ label: 'Certificate templates', value: observationNumber(snapshot, 'pki.certificate_template_count', 'Certificate Templates list row count', 'sectionPKI') },
|
||||||
|
{ label: 'Certificates', value: observationNumber(snapshot, 'pki.certificate_count', 'Certificates list row count', 'sectionPKI') },
|
||||||
|
{ label: 'DC certificates', value: observationNumber(snapshot, 'pki.dc_certificate_count', 'DC Certificates list row count', 'sectionPKI') },
|
||||||
|
{ label: 'Delegations', value: observationNumber(snapshot, 'pki.delegation_count', 'Delegations list row count', 'sectionPKI') }
|
||||||
|
],
|
||||||
|
gpo: [
|
||||||
|
{ label: 'GPO', value: sectionObservationCount(snapshot, 'sectionGPO'), detail: 'observations' },
|
||||||
|
{ label: 'Privilege assignments', value: observationNumber(snapshot, 'gpo.privilege_count', 'Privileges list row count', 'sectionGPO') },
|
||||||
|
{ label: 'Password policies', value: observationNumber(snapshot, 'password.policy_count', 'Password policies list row count', 'sectionPasswordPolicies') },
|
||||||
|
{ label: 'Audit settings', value: observationNumber(snapshot, 'gpo.audit_setting_count', 'Audit settings list row count', 'sectionGPO') },
|
||||||
|
{ label: 'Defender ASR', value: observationNumber(snapshot, 'gpo.defender_asr_count', 'Defender ASR row count', 'sectionGPO') },
|
||||||
|
{ label: 'Firewall rules', value: observationNumber(snapshot, 'gpo.firewall_rule_count', 'Firewall Rules row count', 'sectionGPO') }
|
||||||
|
],
|
||||||
|
risk: [
|
||||||
|
{ label: 'Domain risk', value: observationNumber(snapshot, 'risk.global', 'Domain Risk Level') },
|
||||||
|
{ label: 'Anomalies', value: observationNumber(snapshot, 'risk.anomalies', 'Anomalies score') },
|
||||||
|
{ label: 'Privileged accounts', value: observationNumber(snapshot, 'risk.privileged_accounts', 'Privileged Accounts score') },
|
||||||
|
{ label: 'Stale objects', value: observationNumber(snapshot, 'risk.stale_objects', 'Stale Objects score') },
|
||||||
|
{ label: 'Trusts', value: observationNumber(snapshot, 'risk.trusts', 'Trusts score') }
|
||||||
|
],
|
||||||
|
resilienceFacts: [
|
||||||
|
{ label: 'AD Recycle Bin', value: observationText(snapshot, 'domain.recycle_bin_enabled', 'Recycle Bin enabled') },
|
||||||
|
{ label: 'Last backup', value: observationText(snapshot, 'backup.last_detected_at', 'Last detected AD backup') },
|
||||||
|
{ label: 'krbtgt changed', value: observationText(snapshot, 'kerberos.krbtgt.password_last_changed', 'krbtgt password last changed') },
|
||||||
|
{ label: 'Legacy LAPS', value: observationText(snapshot, 'laps.legacy.installation_date', 'Legacy LAPS installation date') },
|
||||||
|
{ label: 'Windows LAPS', value: observationText(snapshot, 'laps.windows.installation_date', 'Windows LAPS installation date') },
|
||||||
|
{ label: 'WEF', value: observationText(snapshot, 'logging.wef.configuration_count', 'WEF configuration count') },
|
||||||
|
{ label: 'OS', value: operatingSystems.length ? operatingSystems.join(', ') : 'No data' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardMetricTileProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: string;
|
||||||
|
active?: boolean;
|
||||||
|
onClick?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardMetricTile({ label, value, tone = 'neutral', active, onClick }: DashboardMetricTileProps) {
|
||||||
|
const content = <><span>{label}</span><strong>{value}</strong></>;
|
||||||
|
const className = `client-info-metric tone-${tone}`;
|
||||||
|
return onClick
|
||||||
|
? <button className={className} type="button" aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
|
||||||
|
: <article className={className}>{content}</article>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardDonutSegment {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: string;
|
||||||
|
onClick?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardDonut({ total, centerLabel, segments }: { total: number; centerLabel: string; segments: DashboardDonutSegment[] }) {
|
||||||
|
let offset = 0;
|
||||||
|
return <div className="client-info-donut-layout">
|
||||||
|
<div className="client-info-donut" role="img" aria-label={`${centerLabel}: ${total.toLocaleString('en-US')}`}>
|
||||||
|
<svg viewBox="0 0 120 120" aria-hidden="true">
|
||||||
|
<circle className="client-info-donut-track" cx="60" cy="60" r="48" pathLength="100" />
|
||||||
|
{segments.filter(segment => segment.value > 0).map(segment => {
|
||||||
|
const percentage = share(segment.value, total);
|
||||||
|
const segmentOffset = offset;
|
||||||
|
offset += percentage;
|
||||||
|
return <circle className={`client-info-donut-segment tone-${segment.tone}`} cx="60" cy="60" r="48" pathLength="100" key={segment.label}
|
||||||
|
strokeDasharray={`${percentage} ${100 - percentage}`} strokeDashoffset={-segmentOffset} />;
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<span><strong>{total.toLocaleString('en-US')}</strong><small>{centerLabel}</small></span>
|
||||||
|
</div>
|
||||||
|
<ul className="client-info-legend">
|
||||||
|
{segments.map(segment => <li key={segment.label}>{segment.onClick
|
||||||
|
? <button type="button" onClick={segment.onClick}><i className={`tone-${segment.tone}`} /><span>{segment.label}</span><strong>{segment.value.toLocaleString('en-US')}</strong></button>
|
||||||
|
: <span className="client-info-legend-row"><i className={`tone-${segment.tone}`} /><span>{segment.label}</span><strong>{segment.value.toLocaleString('en-US')}</strong></span>}</li>)}
|
||||||
|
</ul>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardFactList({ items }: { items: Array<{ label: string; value: string }> }) {
|
||||||
|
return <dl className="client-info-facts">{items.map(item => <div key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}</dl>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardMetricBars({ items, tone = 'accent' }: { items: DashboardEnvironmentMetric[]; tone?: string }) {
|
||||||
|
const maximum = Math.max(1, ...items.map(item => item.value));
|
||||||
|
return <ul className="client-info-bars">{items.map(item => <li key={item.label}>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className="client-info-bar-track"><i className={`tone-${tone}`} style={shareStyle(share(item.value, maximum))} /></span>
|
||||||
|
<strong>{item.value.toLocaleString('en-US')}{item.detail ? ` ${item.detail}` : ''}</strong>
|
||||||
|
{item.restricted && <small title="Some source details are restricted">Restricted</small>}
|
||||||
|
</li>)}</ul>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardLargeCard({ title, meta, className = '', children }: { title: string; meta?: string; className?: string; children: ReactNode }) {
|
||||||
|
return <section className={`client-info-card ${className}`.trim()}><header><h2>{title}</h2>{meta && <span>{meta}</span>}</header>{children}</section>;
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const app = useApp();
|
const app = useApp();
|
||||||
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
|
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
|
||||||
|
|
@ -386,6 +598,23 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const categoryFacet: DynamicFacet | undefined = facets.find(facet => facet.role === 'category' || /^(?:category|kategorie)$/i.test(facet.label.trim()));
|
const categoryFacet: DynamicFacet | undefined = facets.find(facet => facet.role === 'category' || /^(?:category|kategorie)$/i.test(facet.label.trim()));
|
||||||
const sourceFacet: DynamicFacet | undefined = facets.find(facet =>
|
const sourceFacet: DynamicFacet | undefined = facets.find(facet =>
|
||||||
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
|
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
|
||||||
|
const cisCatTool = dashboardRiskTools(sourceFacet?.values ?? [])
|
||||||
|
.find(tool => /^cis-cat(?:\s+pro)?$/i.test(tool.label.trim()));
|
||||||
|
const cisCatSeverityValues = cisCatTool && sourceFacet && severityFacet
|
||||||
|
? recountFacetsAgainstActiveFilters({
|
||||||
|
facets: [sourceFacet, severityFacet],
|
||||||
|
records: activeSheet.analysis.records,
|
||||||
|
filters: { [sourceFacet.id]: { values: cisCatTool.sourceValues } },
|
||||||
|
reviews,
|
||||||
|
statusFilter: 'all',
|
||||||
|
matchesStatus: (record, status) => activeProfile.matchesStatus(record, status)
|
||||||
|
}).find(facet => facet.id === severityFacet.id)?.values ?? []
|
||||||
|
: [];
|
||||||
|
const cisCatSeverityRemainder = dashboardCisCatSeverityRemainder(sourceFacet?.values ?? [], cisCatSeverityValues);
|
||||||
|
const severityDistributionValues = [
|
||||||
|
...(severityFacet?.values ?? []),
|
||||||
|
...(cisCatSeverityRemainder ? [cisCatSeverityRemainder] : [])
|
||||||
|
];
|
||||||
const reports = sourceFacet?.values.length ?? 0;
|
const reports = sourceFacet?.values.length ?? 0;
|
||||||
const statusCount = (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0;
|
const statusCount = (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0;
|
||||||
const deviations = statusCount('deviation');
|
const deviations = statusCount('deviation');
|
||||||
|
|
@ -396,6 +625,10 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
const explicitIoeRecordIds = dashboardExplicitIoeRecordIds(activeSheet.analysis.records);
|
const explicitIoeRecordIds = dashboardExplicitIoeRecordIds(activeSheet.analysis.records);
|
||||||
const explicitIoes = explicitIoeRecordIds.length;
|
const explicitIoes = explicitIoeRecordIds.length;
|
||||||
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
|
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
|
||||||
|
const highSeverity = severityFacet?.values.find(value => value.value === 'high')?.count ?? 0;
|
||||||
|
const environmentSummary = dashboardEnvironmentSummary(environment.snapshot);
|
||||||
|
const typedObservationCount = environment.snapshot?.observations.length ?? 0;
|
||||||
|
const directorySectionCount = environment.snapshot?.sections.length ?? 0;
|
||||||
|
|
||||||
const jump = (apply: () => void) => {
|
const jump = (apply: () => void) => {
|
||||||
resetFilters();
|
resetFilters();
|
||||||
|
|
@ -411,70 +644,74 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
||||||
<strong>{dataset.fileName}</strong>
|
<strong>{dataset.fileName}</strong>
|
||||||
<span>{reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'}</span>
|
<span>{reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'}</span>
|
||||||
<span>{total.toLocaleString('en-US')} records</span>
|
<span>{total.toLocaleString('en-US')} records</span>
|
||||||
|
<WorkTimeslotControl />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section className="dashboard-status-rack" aria-label="Result status overview">
|
<div className="client-info-tile-grid">
|
||||||
<DashboardStatusMetric
|
<section className="client-info-metric-grid" aria-label="Client metrics">
|
||||||
label="Compliance Score"
|
<DashboardMetricTile label="Compliance Score" value={`${complianceScore}%`} />
|
||||||
value={complianceScore}
|
<DashboardMetricTile label="Deviation" value={deviations.toLocaleString('en-US')} tone="deviation" active={app.statusFilter === 'deviation'} onClick={() => jump(() => setStatusFilter('deviation'))} />
|
||||||
percentage={complianceScore}
|
<DashboardMetricTile label="Passed" value={passed.toLocaleString('en-US')} tone="passed" active={app.statusFilter === 'passed'} onClick={() => jump(() => setStatusFilter('passed'))} />
|
||||||
tone="all"
|
<DashboardMetricTile label="High Severity" value={highSeverity.toLocaleString('en-US')} tone="high" onClick={() => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: ['high'] }))} />
|
||||||
large
|
<DashboardMetricTile label="Typed Observations" value={typedObservationCount.toLocaleString('en-US')} />
|
||||||
helper="Passed / (Passed + Deviation)"
|
<DashboardMetricTile label="Directory Sections" value={directorySectionCount.toLocaleString('en-US')} />
|
||||||
/>
|
|
||||||
<DashboardStatusMetric label="Deviation" value={deviations} percentage={share(deviations, total)} tone="deviation" active={app.statusFilter === 'deviation'} onClick={() => jump(() => setStatusFilter('deviation'))} />
|
|
||||||
<DashboardStatusMetric label="Passed" value={passed} percentage={share(passed, total)} tone="passed" active={app.statusFilter === 'passed'} onClick={() => jump(() => setStatusFilter('passed'))} />
|
|
||||||
<DashboardStatusMetric label="Not Run" value={notRun} percentage={share(notRun, total)} tone="not-run" active={app.statusFilter === 'not-run'} onClick={() => jump(() => setStatusFilter('not-run'))} />
|
|
||||||
<section className="dashboard-status-summary" aria-label="Other result measures">
|
|
||||||
<div className="dashboard-status-summary-stack">
|
|
||||||
<button type="button" onClick={() => jump(() => setStatusFilter('not-applicable'))}><span>Not Applicable</span><strong>{notApplicable.toLocaleString('en-US')}</strong></button>
|
|
||||||
<button type="button" onClick={() => jump(() => setStatusFilter('error'))}><span>Error</span><strong>{errors.toLocaleString('en-US')}</strong></button>
|
|
||||||
</div>
|
|
||||||
<button className="dashboard-ioe-summary" type="button" disabled={explicitIoes === 0}
|
|
||||||
onClick={() => jump(() => { setStatusFilter('all'); setPathFindingIds(explicitIoeRecordIds); })}
|
|
||||||
title="Open IOEs in Work">
|
|
||||||
<span>IOEs found</span><strong>{explicitIoes.toLocaleString('en-US')}</strong>
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="dashboard-distribution-grid">
|
<div className="client-info-card-grid">
|
||||||
<SegmentedDistribution
|
<DashboardLargeCard title="Assessment Status" meta={`${total.toLocaleString('en-US')} records`}>
|
||||||
heading="Severity distribution"
|
<DashboardDonut total={total} centerLabel="records" segments={[
|
||||||
total={total}
|
{ label: 'Deviation', value: deviations, tone: 'deviation', onClick: () => jump(() => setStatusFilter('deviation')) },
|
||||||
values={severityFacet?.values ?? []}
|
{ label: 'Passed', value: passed, tone: 'passed', onClick: () => jump(() => setStatusFilter('passed')) },
|
||||||
severity
|
{ label: 'Not Run', value: notRun, tone: 'not-run', onClick: () => jump(() => setStatusFilter('not-run')) },
|
||||||
onSelect={value => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: [value] }))}
|
{ label: 'Not Applicable', value: notApplicable, tone: 'neutral', onClick: () => jump(() => setStatusFilter('not-applicable')) },
|
||||||
/>
|
{ label: 'Error', value: errors, tone: 'error', onClick: () => jump(() => setStatusFilter('error')) },
|
||||||
<SegmentedDistribution
|
{ label: 'IOEs found', value: explicitIoes, tone: 'info', onClick: explicitIoes > 0 ? () => jump(() => { setStatusFilter('all'); setPathFindingIds(explicitIoeRecordIds); }) : undefined }
|
||||||
heading="Scan source distribution"
|
]} />
|
||||||
total={total}
|
</DashboardLargeCard>
|
||||||
values={sourceFacet?.values ?? []}
|
|
||||||
formatLabel={dashboardScanSourceLabel}
|
|
||||||
onSelect={value => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: [value] }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-lower-grid">
|
<DashboardLargeCard title="Severity Distribution" meta={`${severityDistributionValues.reduce((sum, value) => sum + value.count, 0).toLocaleString('en-US')} classified`}>
|
||||||
<EnvironmentOverview projectId={app.targetProjectId} snapshot={environment.snapshot} failed={environment.failed} />
|
<DashboardDonut total={Math.max(1, severityDistributionValues.reduce((sum, value) => sum + value.count, 0))} centerLabel="classified" segments={severityDistributionValues.map(value => ({
|
||||||
<RiskProfile
|
label: value.label,
|
||||||
sourceFacet={sourceFacet}
|
value: value.count,
|
||||||
categoryFacet={categoryFacet}
|
tone: value.value,
|
||||||
severityFacet={severityFacet}
|
onClick: () => {
|
||||||
records={activeSheet.analysis.records}
|
if (value.value === dashboardCisCatSeverityValue && sourceFacet && cisCatSeverityRemainder) {
|
||||||
profile={activeProfile}
|
jump(() => setFacetFilter(sourceFacet.id, { values: cisCatSeverityRemainder.sourceValues }));
|
||||||
reviews={reviews}
|
} else if (severityFacet) jump(() => setFacetFilter(severityFacet.id, { values: [value.value] }));
|
||||||
environmentSnapshot={environment.snapshot}
|
}
|
||||||
onSelectTool={sourceValues => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: sourceValues }))}
|
}))} />
|
||||||
onSelectCategory={(value, sourceValues) => categoryFacet && sourceFacet && jump(() => {
|
</DashboardLargeCard>
|
||||||
setFacetFilter(sourceFacet.id, { values: sourceValues });
|
|
||||||
setFacetFilter(categoryFacet.id, { values: [value] });
|
<DashboardLargeCard title="Directory Overview" meta={environment.failed ? 'Unavailable' : undefined}>
|
||||||
})}
|
{!environment.snapshot && !environment.failed ? <p className="dashboard-empty">Loading scanner context…</p> : <DashboardFactList items={environmentSummary.directoryFacts} />}
|
||||||
onSelectSeverity={(value, sourceValues) => severityFacet && sourceFacet && jump(() => {
|
</DashboardLargeCard>
|
||||||
if (sourceValues.length) setFacetFilter(sourceFacet.id, { values: sourceValues });
|
|
||||||
setFacetFilter(severityFacet.id, { values: [value] });
|
<DashboardLargeCard title="Directory Coverage" meta={`${typedObservationCount.toLocaleString('en-US')} observations`}>
|
||||||
})}
|
<DashboardMetricBars items={environmentSummary.coverage} tone="coverage" />
|
||||||
/>
|
<p className="client-info-card-caption">{typedObservationCount.toLocaleString('en-US')} typed observations · {directorySectionCount.toLocaleString('en-US')} sections</p>
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="Identity & Privilege">
|
||||||
|
<DashboardMetricBars items={environmentSummary.identity} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="Control Paths & Trusts">
|
||||||
|
<DashboardMetricBars items={environmentSummary.controlPaths} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="Certificates & PKI">
|
||||||
|
<DashboardMetricBars items={environmentSummary.certificates} tone="pki" />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="GPO & Password Hygiene">
|
||||||
|
<DashboardMetricBars items={environmentSummary.gpo} tone="gpo" />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
|
||||||
|
<DashboardLargeCard title="Infrastructure & Resilience" meta={`${activeSheet.analysis.records.filter(record => reviews[record.id]?.include).length.toLocaleString('en-US')} marked`} className="client-info-resilience">
|
||||||
|
<DashboardMetricBars items={environmentSummary.risk} tone="risk" />
|
||||||
|
<DashboardFactList items={environmentSummary.resilienceFacts} />
|
||||||
|
</DashboardLargeCard>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,12 @@ const RECORD_ROW_HEIGHT = 78;
|
||||||
/** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */
|
/** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */
|
||||||
const TILE_MIN_WIDTH = 320;
|
const TILE_MIN_WIDTH = 320;
|
||||||
/** Einheitlicher Segmentabstand, siehe `--segment-gap`. */
|
/** Einheitlicher Segmentabstand, siehe `--segment-gap`. */
|
||||||
const TILE_GAP = 8;
|
const TILE_GAP = 12;
|
||||||
/** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */
|
/** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */
|
||||||
const TILE_GRID_INSET = 20;
|
const TILE_GRID_INSET = 20;
|
||||||
/** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund
|
/** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund
|
||||||
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
|
* wie {@link RECORD_ROW_HEIGHT} nur eine Naeherung fuer die Scrollmathematik. */
|
||||||
const TILE_ROW_HEIGHT = 168;
|
const TILE_ROW_HEIGHT = 172;
|
||||||
|
|
||||||
const resultColors = [
|
const resultColors = [
|
||||||
'#7c3aed', // Violet
|
'#7c3aed', // Violet
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const normalizeValue = (value: string) => value.trim().replace(/\s+/g, ' ').toLo
|
||||||
*/
|
*/
|
||||||
const isAdditionalOnlyField = (label: string) => {
|
const isAdditionalOnlyField = (label: string) => {
|
||||||
const normalized = normalizeLabel(label);
|
const normalized = normalizeLabel(label);
|
||||||
return /^(?:shortname|si version|target|runtime|points scale|rule id)$/.test(normalized)
|
return /^(?:short\s*name|si version|target|runtime|points scale|rule id)$/.test(normalized)
|
||||||
|| /^(?:quelle|source):? /.test(normalized)
|
|| /^(?:quelle|source):? /.test(normalized)
|
||||||
|| /^anssi \d+$/.test(normalized);
|
|| /^anssi \d+$/.test(normalized);
|
||||||
};
|
};
|
||||||
|
|
@ -62,9 +62,39 @@ const isAdditionalOnlyField = (label: string) => {
|
||||||
const isHeadingOrBadgeField = (label: string) => /^(?:name|title|finding|indicator|results?|status|evaluation|category|severity|criticality|priority|weight|score)$/.test(normalizeLabel(label));
|
const isHeadingOrBadgeField = (label: string) => /^(?:name|title|finding|indicator|results?|status|evaluation|category|severity|criticality|priority|weight|score)$/.test(normalizeLabel(label));
|
||||||
|
|
||||||
/** Werkzeugdetails, die bei strukturierten Results nicht doppelt erscheinen duerfen. */
|
/** Werkzeugdetails, die bei strukturierten Results nicht doppelt erscheinen duerfen. */
|
||||||
const isRedundantDetailField = (label: string) => /^details?$/.test(normalizeLabel(label));
|
const isRedundantDetailField = (label: string) => /^(?:details?|result detail|resultdetail)$/.test(normalizeLabel(label));
|
||||||
const isPingCastleLinkedSectionField = (label: string) => normalizeLabel(label) === 'verwandte daten';
|
const isPingCastleLinkedSectionField = (label: string) => normalizeLabel(label) === 'verwandte daten';
|
||||||
|
|
||||||
|
function legacyPurpleKnightFrameworkFields(record: FindingRecord, toolId?: string): {
|
||||||
|
fields: RecordField[];
|
||||||
|
sourceIndexes: number[];
|
||||||
|
} {
|
||||||
|
if (toolId !== 'purple-knight') return { fields: [], sourceIndexes: [] };
|
||||||
|
const source = record.fields.find(field => normalizeLabel(field.label) === 'framework' && field.value);
|
||||||
|
if (!source || source.value.length > 64 * 1024) return { fields: [], sourceIndexes: [] };
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(source.value);
|
||||||
|
} catch {
|
||||||
|
return { fields: [], sourceIndexes: [] };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed) || parsed.length > 32) return { fields: [], sourceIndexes: [] };
|
||||||
|
const fields: RecordField[] = [];
|
||||||
|
const nextIndex = Math.max(-1, ...record.fields.map(field => field.index)) + 1;
|
||||||
|
parsed.forEach(item => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return;
|
||||||
|
const framework = item as { Name?: unknown; Tags?: unknown };
|
||||||
|
if (typeof framework.Name !== 'string' || !Array.isArray(framework.Tags)) return;
|
||||||
|
const frameworkName = framework.Name;
|
||||||
|
framework.Tags.slice(0, 32).forEach(tag => {
|
||||||
|
if (typeof tag !== 'string' || !tag.trim()) return;
|
||||||
|
const ordinal = fields.filter(field => normalizeLabel(field.label).startsWith(normalizeLabel(frameworkName))).length + 1;
|
||||||
|
fields.push({ index: nextIndex + fields.length, label: `${frameworkName}_${ordinal}`, value: tag.trim() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return fields.length ? { fields, sourceIndexes: [source.index] } : { fields: [], sourceIndexes: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const CIS_CAT_DETAILS_LABEL = 'cis cat details';
|
const CIS_CAT_DETAILS_LABEL = 'cis cat details';
|
||||||
const RULE_RESULT_XML_LABEL = 'rule result xml';
|
const RULE_RESULT_XML_LABEL = 'rule result xml';
|
||||||
const RULE_RESULT_XML_HEADING = /^##\s+Rule Result XML\s*$/im;
|
const RULE_RESULT_XML_HEADING = /^##\s+Rule Result XML\s*$/im;
|
||||||
|
|
@ -137,8 +167,10 @@ function splitReferenceSection(fields: RecordField[]): RecordField[] {
|
||||||
|
|
||||||
export function selectFindingFields(record: FindingRecord, title: string, hasStructuredResults = false, toolId?: string) {
|
export function selectFindingFields(record: FindingRecord, title: string, hasStructuredResults = false, toolId?: string) {
|
||||||
const derivedCategories = cisBenchmarkCategories(record, toolId);
|
const derivedCategories = cisBenchmarkCategories(record, toolId);
|
||||||
|
const legacyFramework = legacyPurpleKnightFrameworkFields(record, toolId);
|
||||||
const resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index));
|
const resultSectionIndexes = new Set(resultSectionFields(record, toolId).map(field => field.index));
|
||||||
const { optionalFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes);
|
const { optionalFields, hiddenFieldIndexes } = cisCatOptionalFields(record, toolId, resultSectionIndexes);
|
||||||
|
legacyFramework.sourceIndexes.forEach(index => hiddenFieldIndexes.add(index));
|
||||||
// Die technische PingCastle-Sektions-ID bleibt im Datensatz, weil der
|
// Die technische PingCastle-Sektions-ID bleibt im Datensatz, weil der
|
||||||
// Results-Linker damit die aufgeloesten Tabellen eindeutig zuordnet. Im
|
// Results-Linker damit die aufgeloesten Tabellen eindeutig zuordnet. Im
|
||||||
// Reader ist sie jedoch reine Implementierungsprovenienz ohne fachlichen
|
// Reader ist sie jedoch reine Implementierungsprovenienz ohne fachlichen
|
||||||
|
|
@ -149,11 +181,16 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
|
||||||
.filter(field => isPingCastleLinkedSectionField(field.label))
|
.filter(field => isPingCastleLinkedSectionField(field.label))
|
||||||
.forEach(field => hiddenFieldIndexes.add(field.index));
|
.forEach(field => hiddenFieldIndexes.add(field.index));
|
||||||
}
|
}
|
||||||
if (hasStructuredResults && toolId === 'pingcastle') {
|
if (hasStructuredResults) {
|
||||||
record.fields
|
record.fields
|
||||||
.filter(field => isRedundantDetailField(field.label))
|
.filter(field => isRedundantDetailField(field.label))
|
||||||
.forEach(field => hiddenFieldIndexes.add(field.index));
|
.forEach(field => hiddenFieldIndexes.add(field.index));
|
||||||
}
|
}
|
||||||
|
if (toolId === 'purple-knight' && (legacyFramework.fields.length || record.fields.some(field => isRedundantDetailField(field.label)))) {
|
||||||
|
record.fields
|
||||||
|
.filter(field => /^(?:grade|exposure type)$/.test(normalizeLabel(field.label)))
|
||||||
|
.forEach(field => hiddenFieldIndexes.add(field.index));
|
||||||
|
}
|
||||||
const statusField = resultStatusFieldFor(record, toolId);
|
const statusField = resultStatusFieldFor(record, toolId);
|
||||||
const badges = record.fields.filter(field => {
|
const badges = record.fields.filter(field => {
|
||||||
if (!field.value) return false;
|
if (!field.value) return false;
|
||||||
|
|
@ -161,7 +198,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
|
||||||
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|
return /^(?:category|severity|weight|score|category score|domain risk level)$/.test(label)
|
||||||
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
|
|| (/^points?$/.test(label) && normalizeNumericValue(field.value) !== null);
|
||||||
});
|
});
|
||||||
const mitre = record.fields.filter(field => mitreBadgeLabel(field.label) && field.value);
|
const mitre = [...record.fields, ...legacyFramework.fields].filter(field => mitreBadgeLabel(field.label) && field.value);
|
||||||
const summaryPriority = (label: string) => {
|
const summaryPriority = (label: string) => {
|
||||||
const normalized = normalizeLabel(label);
|
const normalized = normalizeLabel(label);
|
||||||
if (normalized === 'result message') return 0;
|
if (normalized === 'result message') return 0;
|
||||||
|
|
@ -195,7 +232,7 @@ export function selectFindingFields(record: FindingRecord, title: string, hasStr
|
||||||
...(statusField ? [statusField.index] : [])
|
...(statusField ? [statusField.index] : [])
|
||||||
]);
|
]);
|
||||||
const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue));
|
const displayedTextValues = new Set([title, ...summary.map(field => field.value)].map(normalizeValue));
|
||||||
const remainingFields = record.fields.filter(field => {
|
const remainingFields = [...record.fields, ...legacyFramework.fields.filter(field => !mitreBadgeLabel(field.label))].filter(field => {
|
||||||
if (!field.value || displayedIndexes.has(field.index)) return false;
|
if (!field.value || displayedIndexes.has(field.index)) return false;
|
||||||
if (isAdditionalOnlyField(field.label)) return true;
|
if (isAdditionalOnlyField(field.label)) return true;
|
||||||
if (isHeadingOrBadgeField(field.label)) return false;
|
if (isHeadingOrBadgeField(field.label)) return false;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,48 @@ import { clean } from '../../shared/text';
|
||||||
const RESULT_DETAIL_LABEL =
|
const RESULT_DETAIL_LABEL =
|
||||||
/^(?:result\s*detail|resultdetail|result\s*details|details?|ergebnisdetails?|befund|evidence|nachweis|result|results|ergebnis|ergebnisse)$/i;
|
/^(?:result\s*detail|resultdetail|result\s*details|details?|ergebnisdetails?|befund|evidence|nachweis|result|results|ergebnis|ergebnisse)$/i;
|
||||||
|
|
||||||
|
const MAX_INLINE_JSON_LENGTH = 5 * 1024 * 1024;
|
||||||
|
const MAX_INLINE_JSON_ROWS = 10_000;
|
||||||
|
const MAX_INLINE_JSON_COLUMNS = 256;
|
||||||
|
|
||||||
|
interface InlineTable {
|
||||||
|
headers: string[];
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy PurpleKnight HTML imports retained `IndicatorReportObjects` as a
|
||||||
|
* JSON array whose cells are encoded as `Column: value`. Interpret that inert
|
||||||
|
* value as a table projection; never execute report code and never mutate the
|
||||||
|
* preserved source field.
|
||||||
|
*/
|
||||||
|
function extractLabelValueJsonTable(value: string): InlineTable | undefined {
|
||||||
|
const source = value.trim();
|
||||||
|
if (!source.startsWith('[[') || source.length > MAX_INLINE_JSON_LENGTH) return undefined;
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(source);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed) || !parsed.length || parsed.length > MAX_INLINE_JSON_ROWS) return undefined;
|
||||||
|
const rows = parsed.filter((row): row is unknown[] => Array.isArray(row));
|
||||||
|
if (rows.length !== parsed.length || !rows[0].length || rows[0].length > MAX_INLINE_JSON_COLUMNS) return undefined;
|
||||||
|
const headers = rows[0].map(cell => {
|
||||||
|
if (typeof cell !== 'string') return '';
|
||||||
|
const separator = cell.indexOf(':');
|
||||||
|
return separator > 0 && separator <= 120 ? cell.slice(0, separator).trim() : '';
|
||||||
|
});
|
||||||
|
if (headers.some(header => !header) || new Set(headers).size !== headers.length) return undefined;
|
||||||
|
const tableRows = rows.map(row => headers.map((header, columnIndex) => {
|
||||||
|
const cell = row[columnIndex];
|
||||||
|
const text = cell === null || cell === undefined ? '' : String(cell);
|
||||||
|
const prefix = `${header}:`;
|
||||||
|
return text.startsWith(prefix) ? text.slice(prefix.length).trimStart() : text;
|
||||||
|
}));
|
||||||
|
return { headers, rows: tableRows };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Markdown-Auswertung ist mehrere Regex-Durchlaeufe je Feld (siehe
|
* Die Markdown-Auswertung ist mehrere Regex-Durchlaeufe je Feld (siehe
|
||||||
* `shared/markdown.ts`) und lohnt sich nicht fuer jeden Aufruf neu: bei einem
|
* `shared/markdown.ts`) und lohnt sich nicht fuer jeden Aufruf neu: bei einem
|
||||||
|
|
@ -39,7 +81,11 @@ export function inlineResultTableSheets(record: FindingRecord): DatasetSheet[] {
|
||||||
const sheets: DatasetSheet[] = [];
|
const sheets: DatasetSheet[] = [];
|
||||||
for (const field of record.fields) {
|
for (const field of record.fields) {
|
||||||
if (!field.value || !RESULT_DETAIL_LABEL.test(clean(field.label))) continue;
|
if (!field.value || !RESULT_DETAIL_LABEL.test(clean(field.label))) continue;
|
||||||
extractMarkdownTables(field.value).forEach((table, index) => {
|
const markdownTables = extractMarkdownTables(field.value);
|
||||||
|
const tables: InlineTable[] = markdownTables.length
|
||||||
|
? markdownTables
|
||||||
|
: [extractLabelValueJsonTable(field.value)].filter((table): table is InlineTable => Boolean(table));
|
||||||
|
tables.forEach((table, index) => {
|
||||||
const rows = [table.headers, ...table.rows];
|
const rows = [table.headers, ...table.rows];
|
||||||
const name = index === 0 ? field.label : `${field.label} (${index + 1})`;
|
const name = index === 0 ? field.label : `${field.label} (${index + 1})`;
|
||||||
sheets.push(makeSheet(name, rows, rows.map(row => row.join('\t')).join('\n'), record.id, {
|
sheets.push(makeSheet(name, rows, rows.map(row => row.join('\t')).join('\n'), record.id, {
|
||||||
|
|
|
||||||
|
|
@ -319,9 +319,6 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
? current.includes(timeslotId) ? current : [...current, timeslotId]
|
? current.includes(timeslotId) ? current : [...current, timeslotId]
|
||||||
: current.filter(id => id !== timeslotId));
|
: current.filter(id => id !== timeslotId));
|
||||||
if (group) setExcludedScanRunIds(current => current.filter(runId => !group.scanRunIds.includes(runId)));
|
if (group) setExcludedScanRunIds(current => current.filter(runId => !group.scanRunIds.includes(runId)));
|
||||||
setExpandedTimeslotIds(current => checked
|
|
||||||
? current.includes(timeslotId) ? current : [...current, timeslotId]
|
|
||||||
: current.filter(id => id !== timeslotId));
|
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -566,10 +563,10 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<summary className="tenant-section-bar">
|
<summary className="tenant-section-bar">
|
||||||
<span className="tenant-timeslot-title">
|
<label className="tenant-timeslot-title" onClick={event => event.stopPropagation()}>
|
||||||
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onClick={event => event.stopPropagation()} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
|
<input ref={element => { if (element) element.indeterminate = timeslotIndeterminate; }} type="checkbox" checked={timeslotChecked} aria-label={`Select timeslot ${formatTenantDate(group.fileDate)}`} onChange={event => toggleTimeslot(group.id, event.target.checked)}/>
|
||||||
<strong>{formatTenantDate(group.fileDate)}</strong>
|
<strong>{formatTenantDate(group.fileDate)}</strong>
|
||||||
</span>
|
</label>
|
||||||
<span className="tenant-history-meta"><b>{reports.length.toLocaleString('de-DE')}</b><TriangleIcon direction="right"/></span>
|
<span className="tenant-history-meta"><b>{reports.length.toLocaleString('de-DE')}</b><TriangleIcon direction="right"/></span>
|
||||||
</summary>
|
</summary>
|
||||||
{reports.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No scan reports</h3><p>This timeslot does not contain a scan report yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Scan reports from ${formatTenantDate(group.fileDate)}`}>
|
{reports.length === 0 ? <div className="datahub-empty tenant-files-empty"><h3>No scan reports</h3><p>This timeslot does not contain a scan report yet.</p></div> : <div className="tenant-report-list" role="table" aria-label={`Scan reports from ${formatTenantDate(group.fileDate)}`}>
|
||||||
|
|
@ -614,7 +611,7 @@ export function DataHub({ activeProjectId, onProjectSelected, onProjectDataChang
|
||||||
Use imported {report.tool.name}
|
Use imported {report.tool.name}
|
||||||
</button> : <FileImport
|
</button> : <FileImport
|
||||||
targetProjectId={editedProject.id}
|
targetProjectId={editedProject.id}
|
||||||
targetAssessmentId={group.assessmentIds[0]}
|
targetAssessmentId={report.assessmentIds[0]}
|
||||||
replacesScanRunId={report.scanRunId}
|
replacesScanRunId={report.scanRunId}
|
||||||
label={`Reimport ${report.tool.name}`}
|
label={`Reimport ${report.tool.name}`}
|
||||||
/>}
|
/>}
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,9 @@ export function createCanonicalImportBatch(
|
||||||
|
|
||||||
const sourceTableIds = new Map<number, string>();
|
const sourceTableIds = new Map<number, string>();
|
||||||
dataset.sheets.forEach((sheet, tableIndex) => {
|
dataset.sheets.forEach((sheet, tableIndex) => {
|
||||||
const sourceTableIndex = dataset.intermediate?.tables.find(table => table.id === sheet.id)?.index ?? tableIndex;
|
const sourceTableIndex = sheet.sourceTableIndex
|
||||||
|
?? dataset.intermediate?.tables.find(table => table.id === sheet.id)?.index
|
||||||
|
?? tableIndex;
|
||||||
const tableId = `table-${safeBatchId}-${fileIndex}-${sourceTableIndex}`;
|
const tableId = `table-${safeBatchId}-${fileIndex}-${sourceTableIndex}`;
|
||||||
sourceTableIds.set(sourceTableIndex, tableId);
|
sourceTableIds.set(sourceTableIndex, tableId);
|
||||||
sourceTables.push({
|
sourceTables.push({
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import type { JsonValue } from '../domain/canonical-model';
|
||||||
|
|
||||||
|
const MAX_JSON_DEPTH = 256;
|
||||||
|
|
||||||
|
export interface EmbeddedJsonValue {
|
||||||
|
value: JsonValue;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads a JSON object without evaluating the surrounding script. */
|
||||||
|
export function readEmbeddedJsonObject(text: string, start: number): EmbeddedJsonValue | undefined {
|
||||||
|
if (text[start] !== '{') return undefined;
|
||||||
|
let depth = 0;
|
||||||
|
let quoted = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = start; index < text.length; index += 1) {
|
||||||
|
const character = text[index];
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quoted && character === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (character === '"') {
|
||||||
|
quoted = !quoted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quoted) continue;
|
||||||
|
if (character === '{' || character === '[') {
|
||||||
|
depth += 1;
|
||||||
|
if (depth > MAX_JSON_DEPTH) return undefined;
|
||||||
|
} else if (character === '}' || character === ']') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth < 0) return undefined;
|
||||||
|
if (depth === 0) {
|
||||||
|
const end = index + 1;
|
||||||
|
try {
|
||||||
|
return { value: JSON.parse(text.slice(start, end)) as JsonValue, start, end };
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isJsonObject(value: JsonValue | undefined): value is Record<string, JsonValue> {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function embeddedObjectAfter(text: string, marker: RegExp): EmbeddedJsonValue | undefined {
|
||||||
|
marker.lastIndex = 0;
|
||||||
|
const match = marker.exec(text);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const start = text.indexOf('{', match.index + match[0].length);
|
||||||
|
return start >= 0 ? readEmbeddedJsonObject(text, start) : undefined;
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,11 @@ import { importHtml } from './html-importer';
|
||||||
import { importJson } from './json-importer';
|
import { importJson } from './json-importer';
|
||||||
import { importXml } from './xml-importer';
|
import { importXml } from './xml-importer';
|
||||||
import { mapFindingCandidates } from './candidate-mapping';
|
import { mapFindingCandidates } from './candidate-mapping';
|
||||||
|
import { makeSheet } from './sheet-factory';
|
||||||
import type { ImportRuntimeAdapters } from './runtime-adapters';
|
import type { ImportRuntimeAdapters } from './runtime-adapters';
|
||||||
import { extractSourceDocument } from './source-extractor-registry';
|
import { extractSourceDocument } from './source-extractor-registry';
|
||||||
|
import { rowsToCsv } from '../exports/csv-export';
|
||||||
|
import { cellText } from '../shared/text';
|
||||||
import {
|
import {
|
||||||
resolveImportResourcePolicy, validateDecodedTextResources, validateImportDocumentResources,
|
resolveImportResourcePolicy, validateDecodedTextResources, validateImportDocumentResources,
|
||||||
validateImportSourceResources, type ImportResourcePolicy
|
validateImportSourceResources, type ImportResourcePolicy
|
||||||
|
|
@ -135,14 +138,6 @@ export async function importSource(
|
||||||
}
|
}
|
||||||
imported = attachFormatDetection(imported, detection, decodedEncoding);
|
imported = attachFormatDetection(imported, detection, decodedEncoding);
|
||||||
if (imported.intermediate) validateImportDocumentResources(imported.intermediate, resourcePolicy);
|
if (imported.intermediate) validateImportDocumentResources(imported.intermediate, resourcePolicy);
|
||||||
const hasContent = imported.sheets.some(sheet => sheet.rows.some(row => row.some(cell => cell.trim())));
|
|
||||||
if (!hasContent) {
|
|
||||||
throw new ImportError('Die Datei enthält keine auswertbaren Tabellen.', [{
|
|
||||||
severity: 'error',
|
|
||||||
code: 'empty-import',
|
|
||||||
message: 'Der Parser hat keine nicht-leeren Tabellenzellen geliefert.'
|
|
||||||
}]);
|
|
||||||
}
|
|
||||||
if (!options.profiles.length) {
|
if (!options.profiles.length) {
|
||||||
throw new ImportError('Für den Import ist mindestens ein Profil erforderlich.', [{
|
throw new ImportError('Für den Import ist mindestens ein Profil erforderlich.', [{
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
|
@ -152,8 +147,18 @@ export async function importSource(
|
||||||
}
|
}
|
||||||
if (imported.intermediate) {
|
if (imported.intermediate) {
|
||||||
const extraction = extractSourceDocument(imported.intermediate);
|
const extraction = extractSourceDocument(imported.intermediate);
|
||||||
|
const supportingTableIndexBase = Math.max(-1, ...imported.intermediate.tables.map(table => table.index)) + 1;
|
||||||
|
const supportingSheets = (extraction.supportingTables ?? []).map((table, index) => {
|
||||||
|
const rows = [table.headers, ...table.rows.map(row => row.map(cellText))];
|
||||||
|
return makeSheet(table.name, rows, rowsToCsv(rows), source.metadata.name, {
|
||||||
|
sourceFile: source.metadata.name,
|
||||||
|
sourceSheet: table.name,
|
||||||
|
sourceTableIndex: supportingTableIndexBase + index
|
||||||
|
});
|
||||||
|
});
|
||||||
imported = {
|
imported = {
|
||||||
...imported,
|
...imported,
|
||||||
|
sheets: [...imported.sheets, ...supportingSheets],
|
||||||
extraction,
|
extraction,
|
||||||
intermediate: {
|
intermediate: {
|
||||||
...imported.intermediate,
|
...imported.intermediate,
|
||||||
|
|
@ -162,6 +167,19 @@ export async function importSource(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const hasTableContent = imported.sheets.some(sheet => sheet.rows.some(row => row.some(cell => cell.trim())));
|
||||||
|
const hasExtractedContent = Boolean(imported.extraction && (
|
||||||
|
imported.extraction.candidates.length
|
||||||
|
|| imported.extraction.reportSections?.length
|
||||||
|
|| imported.extraction.environmentObservations?.length
|
||||||
|
));
|
||||||
|
if (!hasTableContent && !hasExtractedContent) {
|
||||||
|
throw new ImportError('Die Datei enthält keine auswertbaren Tabellen.', [{
|
||||||
|
severity: 'error',
|
||||||
|
code: 'empty-import',
|
||||||
|
message: 'Der Parser und die registrierten Quellenextraktoren haben keine auswertbaren Inhalte geliefert.'
|
||||||
|
}]);
|
||||||
|
}
|
||||||
const recognition = imported.intermediate ? recognizeImportDocument(imported.intermediate) : undefined;
|
const recognition = imported.intermediate ? recognizeImportDocument(imported.intermediate) : undefined;
|
||||||
const profile = recognition
|
const profile = recognition
|
||||||
? selectImportProfile(recognition, options.profiles)
|
? selectImportProfile(recognition, options.profiles)
|
||||||
|
|
@ -190,4 +208,4 @@ export async function importSource(
|
||||||
recognition,
|
recognition,
|
||||||
mapping
|
mapping
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import type { JsonValue } from '../domain/canonical-model';
|
||||||
|
import type { DecodedSourceDocument, ImportDiagnostic } from '../domain/import-intermediate';
|
||||||
|
import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction';
|
||||||
|
import { cellText, plainTextValue, stringifyCell } from '../shared/text';
|
||||||
|
import { isJsonObject, readEmbeddedJsonObject } from './embedded-report-json';
|
||||||
|
|
||||||
|
const ASSIGNMENT = /(?:var|const|let)\s+[$A-Za-z_][\w$]*\s*=\s*/g;
|
||||||
|
const MAX_TESTS = 250_000;
|
||||||
|
|
||||||
|
interface MaesterPayload {
|
||||||
|
roots: Array<{ value: Record<string, JsonValue>; path: string }>;
|
||||||
|
recognized: boolean;
|
||||||
|
diagnostics: ImportDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function maesterPayload(document: DecodedSourceDocument): MaesterPayload {
|
||||||
|
if (document.format !== 'HTML') return { roots: [], recognized: false, diagnostics: [] };
|
||||||
|
for (const match of document.rawText.matchAll(ASSIGNMENT)) {
|
||||||
|
const start = document.rawText.indexOf('{', match.index! + match[0].length);
|
||||||
|
if (start < 0) continue;
|
||||||
|
const embedded = readEmbeddedJsonObject(document.rawText, start);
|
||||||
|
if (!embedded || !isJsonObject(embedded.value)) continue;
|
||||||
|
const value = embedded.value;
|
||||||
|
if (Array.isArray(value.Tenants)) {
|
||||||
|
const roots = value.Tenants
|
||||||
|
.map((tenant, index) => isJsonObject(tenant) ? { value: tenant, path: `$.Tenants[${index}]` } : undefined)
|
||||||
|
.filter((item): item is { value: Record<string, JsonValue>; path: string } => Boolean(item));
|
||||||
|
return { roots, recognized: true, diagnostics: [] };
|
||||||
|
}
|
||||||
|
if (Array.isArray(value.Tests) && ('Result' in value || 'EndOfJson' in value)) {
|
||||||
|
return { roots: [{ value, path: '$' }], recognized: true, diagnostics: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { roots: [], recognized: false, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(
|
||||||
|
fileName: string,
|
||||||
|
path: string,
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
value: JsonValue,
|
||||||
|
decodedValue = cellText(value)
|
||||||
|
): SourceFieldCandidate {
|
||||||
|
return { key, label, originalValue: value, decodedValue, source: { fileName, objectPath: `${path}.${key}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalField(
|
||||||
|
fields: SourceFieldCandidate[], fileName: string, path: string, key: string, label: string,
|
||||||
|
value: JsonValue | undefined, decodedValue?: string
|
||||||
|
): void {
|
||||||
|
if (value === undefined || value === null || stringifyCell(value).trim() === '') return;
|
||||||
|
fields.push(field(fileName, path, key, label, value, decodedValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
function testCandidate(fileName: string, test: Record<string, JsonValue>, path: string, index: number): FindingCandidate {
|
||||||
|
const detail = isJsonObject(test.ResultDetail) ? test.ResultDetail : {};
|
||||||
|
const fields: SourceFieldCandidate[] = [];
|
||||||
|
optionalField(fields, fileName, path, 'Id', 'ID', test.Id);
|
||||||
|
optionalField(fields, fileName, path, 'Title', 'Title', test.Title ?? test.Name);
|
||||||
|
optionalField(fields, fileName, path, 'Result', 'Result', test.Result);
|
||||||
|
optionalField(fields, fileName, path, 'Severity', 'Severity', test.Severity ?? detail.Severity);
|
||||||
|
optionalField(fields, fileName, path, 'Tag', 'Tag', test.Tag);
|
||||||
|
optionalField(fields, fileName, path, 'Block', 'Block', test.Block);
|
||||||
|
optionalField(fields, fileName, path, 'Duration', 'Duration', test.Duration);
|
||||||
|
optionalField(fields, fileName, `${path}.ResultDetail`, 'TestDescription', 'Description', detail.TestDescription,
|
||||||
|
plainTextValue(detail.TestDescription));
|
||||||
|
optionalField(fields, fileName, `${path}.ResultDetail`, 'TestResult', 'ResultDetail', detail.TestResult,
|
||||||
|
plainTextValue(detail.TestResult));
|
||||||
|
optionalField(fields, fileName, `${path}.ResultDetail`, 'TestSkipped', 'TestSkipped', detail.TestSkipped);
|
||||||
|
optionalField(fields, fileName, `${path}.ResultDetail`, 'SkippedReason', 'SkippedReason', detail.SkippedReason);
|
||||||
|
optionalField(fields, fileName, path, 'ErrorRecord', 'ErrorMessage', test.ErrorRecord);
|
||||||
|
optionalField(fields, fileName, path, 'Name', 'Name', test.Name);
|
||||||
|
optionalField(fields, fileName, path, 'HelpUrl', 'HelpUrl', test.HelpUrl);
|
||||||
|
optionalField(fields, fileName, path, 'ScriptBlockFile', 'TestScriptFile', test.ScriptBlockFile);
|
||||||
|
optionalField(fields, fileName, path, 'ScriptBlock', 'TestScript', test.ScriptBlock);
|
||||||
|
optionalField(fields, fileName, `${path}.ResultDetail`, 'Service', 'Service', detail.Service);
|
||||||
|
return { version: 1, id: `maester-test-${index}`, source: { fileName, objectPath: path }, fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maesterHtmlSourceExtractor: SourceExtractor = {
|
||||||
|
id: 'maester-html-source-extractor',
|
||||||
|
version: '1.0.0',
|
||||||
|
detect(document) {
|
||||||
|
const payload = maesterPayload(document);
|
||||||
|
const tests = payload.roots.reduce((count, root) => count + (Array.isArray(root.value.Tests) ? root.value.Tests.length : 0), 0);
|
||||||
|
return payload.recognized && tests > 0
|
||||||
|
? { score: 1, evidence: [`Sicher eingebettetes Maester-Ergebnisobjekt mit ${tests} Testeinträgen erkannt.`] }
|
||||||
|
: { score: 0, evidence: [] };
|
||||||
|
},
|
||||||
|
extract(document) {
|
||||||
|
const payload = maesterPayload(document);
|
||||||
|
const candidates: FindingCandidate[] = [];
|
||||||
|
let sourceIndex = 0;
|
||||||
|
for (const root of payload.roots) {
|
||||||
|
const tests = Array.isArray(root.value.Tests) ? root.value.Tests : [];
|
||||||
|
for (let index = 0; index < tests.length && candidates.length < MAX_TESTS; index += 1) {
|
||||||
|
const test = tests[index];
|
||||||
|
if (!isJsonObject(test) || (!test.Id && !test.Title && !test.Name)) continue;
|
||||||
|
const path = `${root.path}.Tests[${index}]`;
|
||||||
|
candidates.push(testCandidate(document.file.name, test, path, sourceIndex++));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diagnostics = [...payload.diagnostics];
|
||||||
|
if (candidates.length >= MAX_TESTS) diagnostics.push({
|
||||||
|
severity: 'warning', code: 'maester-test-limit', stage: 'extraction',
|
||||||
|
message: `Der Maester-Bericht wurde auf ${MAX_TESTS} Testeinträge begrenzt.`
|
||||||
|
});
|
||||||
|
return { version: 1, candidates, diagnostics };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import type { JsonValue } from '../domain/canonical-model';
|
||||||
|
import type { DecodedSourceDocument, ImportDiagnostic } from '../domain/import-intermediate';
|
||||||
|
import type {
|
||||||
|
FindingCandidate, SourceExtractor, SourceFieldCandidate, SupportingTableCandidate
|
||||||
|
} from '../domain/source-extraction';
|
||||||
|
import { cellText, plainTextValue } from '../shared/text';
|
||||||
|
import { embeddedObjectAfter, isJsonObject, readEmbeddedJsonObject } from './embedded-report-json';
|
||||||
|
|
||||||
|
const SCRIPT_GLOBAL = 'win' + 'dow';
|
||||||
|
const CATEGORY_ASSIGNMENT = new RegExp(`${SCRIPT_GLOBAL}\\["Category_(\\d+)"\\]\\["([^"]+)"\\]\\s*=\\s*`, 'g');
|
||||||
|
const REPORT_ASSIGNMENT = new RegExp(`${SCRIPT_GLOBAL}\\.reportJSON\\s*=\\s*`, 'g');
|
||||||
|
const MAX_INDICATORS = 250_000;
|
||||||
|
|
||||||
|
interface PurplePayload {
|
||||||
|
indicators: Array<{ value: Record<string, JsonValue>; path: string }>;
|
||||||
|
categoryNames: Map<number, string>;
|
||||||
|
recognized: boolean;
|
||||||
|
diagnostics: ImportDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXCEL_HEADERS = [
|
||||||
|
'ShortName', 'Name', 'SI version', 'Target', 'Status', 'Runtime', 'Score', 'Severity', 'Weight', 'Category',
|
||||||
|
'MITRE ATT&CK_1', 'MITRE ATT&CK_2', 'MITRE ATT&CK_3', 'MITRE ATT&CK_4',
|
||||||
|
'ANSSI_1', 'ANSSI_2', 'ANSSI_3', 'ANSSI_4', 'MITRE D3FEND_1', 'MITRE D3FEND_2',
|
||||||
|
'Description', 'Likelihood of compromise', 'Result message', 'Number of results', 'Result', 'Remediation'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function purplePayload(document: DecodedSourceDocument): PurplePayload {
|
||||||
|
if (document.format !== 'HTML') return { indicators: [], categoryNames: new Map(), recognized: false, diagnostics: [] };
|
||||||
|
const root = embeddedObjectAfter(document.rawText, REPORT_ASSIGNMENT);
|
||||||
|
if (!root || !isJsonObject(root.value) || !Array.isArray(root.value.reportResultsList)) {
|
||||||
|
return { indicators: [], categoryNames: new Map(), recognized: false, diagnostics: [] };
|
||||||
|
}
|
||||||
|
const categoryNames = new Map<number, string>();
|
||||||
|
const generalConfig = isJsonObject(root.value.GeneralConfig) ? root.value.GeneralConfig : undefined;
|
||||||
|
const categories = generalConfig && Array.isArray(generalConfig.Categories) ? generalConfig.Categories : [];
|
||||||
|
for (const category of categories) {
|
||||||
|
if (!isJsonObject(category) || typeof category.ID !== 'number' || typeof category.Name !== 'string') continue;
|
||||||
|
categoryNames.set(category.ID, category.Name);
|
||||||
|
}
|
||||||
|
const indicators: PurplePayload['indicators'] = [];
|
||||||
|
for (const match of document.rawText.matchAll(CATEGORY_ASSIGNMENT)) {
|
||||||
|
if (indicators.length >= MAX_INDICATORS) break;
|
||||||
|
const start = document.rawText.indexOf('{', match.index! + match[0].length);
|
||||||
|
const embedded = start >= 0 ? readEmbeddedJsonObject(document.rawText, start) : undefined;
|
||||||
|
if (!embedded || !isJsonObject(embedded.value) || !isJsonObject(embedded.value.ResIndicator)) continue;
|
||||||
|
indicators.push({ value: embedded.value, path: `$.Category_${match[1]}[${JSON.stringify(match[2])}]` });
|
||||||
|
}
|
||||||
|
const diagnostics: ImportDiagnostic[] = [];
|
||||||
|
if (indicators.length >= MAX_INDICATORS) diagnostics.push({
|
||||||
|
severity: 'warning', code: 'purple-knight-indicator-limit', stage: 'extraction',
|
||||||
|
message: `Der PurpleKnight-Bericht wurde auf ${MAX_INDICATORS} Indikatoren begrenzt.`
|
||||||
|
});
|
||||||
|
return { indicators, categoryNames, recognized: true, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(
|
||||||
|
fileName: string, path: string, key: string, label: string, value: JsonValue,
|
||||||
|
decodedValue = cellText(value), columnIndex?: number
|
||||||
|
): SourceFieldCandidate {
|
||||||
|
return {
|
||||||
|
key, label, originalValue: value, decodedValue,
|
||||||
|
source: { fileName, objectPath: `${path}.${key}`, columnIndex }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function purpleStatus(indicator: Record<string, JsonValue>, execution: Record<string, JsonValue>): string {
|
||||||
|
if (indicator.IsFailedToRun === true) return 'Failed to run';
|
||||||
|
if (indicator.IsAborted === true) return 'Canceled';
|
||||||
|
if (indicator.Selected === false) return 'Not selected';
|
||||||
|
if (indicator.IsEvaluated === false) return 'Not Relevant';
|
||||||
|
if (indicator.IsFailed === true && typeof indicator.ExposureType === 'string' && /IOE/i.test(indicator.ExposureType)) return 'IOE Found';
|
||||||
|
if (indicator.IsFailed === true && typeof indicator.ExposureType === 'string' && /IOC/i.test(indicator.ExposureType)) return 'IOC Found';
|
||||||
|
const status = execution.Status ?? indicator.State;
|
||||||
|
return typeof status === 'string' ? status.replace(/([a-z])([A-Z])/g, '$1 $2') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function target(indicator: Record<string, JsonValue>): string {
|
||||||
|
const targets = Array.isArray(indicator.Targets) ? indicator.Targets.map(cellText).filter(Boolean) : [];
|
||||||
|
return targets.includes('AAD') && targets.includes('AD') ? 'Hybrid (AD)' : targets.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function category(indicator: Record<string, JsonValue>, categoryNames: Map<number, string>): string {
|
||||||
|
const name = typeof indicator.CategoryID === 'number' ? categoryNames.get(indicator.CategoryID) ?? '' : '';
|
||||||
|
return target(indicator) === 'Hybrid (AD)' && name === 'Hybrid' ? 'Hybrid (AD)' : name || cellText(indicator.CategoryID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtime(indicator: Record<string, JsonValue>): string {
|
||||||
|
if (typeof indicator.StartTime !== 'string' || typeof indicator.CompletionTime !== 'string') return '';
|
||||||
|
const ticks = (value: string): bigint | undefined => {
|
||||||
|
const match = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?Z$/);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const seconds = Date.parse(`${match[1]}Z`);
|
||||||
|
if (!Number.isFinite(seconds)) return undefined;
|
||||||
|
const fraction = BigInt((match[2] ?? '').slice(0, 7).padEnd(7, '0'));
|
||||||
|
return BigInt(seconds) * 10_000n + fraction;
|
||||||
|
};
|
||||||
|
const start = ticks(indicator.StartTime);
|
||||||
|
const completion = ticks(indicator.CompletionTime);
|
||||||
|
if (start === undefined || completion === undefined || completion < start) return '';
|
||||||
|
const totalCentiseconds = Number((completion - start) / 100_000n);
|
||||||
|
const hours = Math.floor(totalCentiseconds / 360_000);
|
||||||
|
const minutes = Math.floor(totalCentiseconds / 6_000) % 60;
|
||||||
|
const seconds = Math.floor(totalCentiseconds / 100) % 60;
|
||||||
|
const centiseconds = totalCentiseconds % 100;
|
||||||
|
return [hours, minutes, seconds, centiseconds].map(value => String(value).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameworkValues(indicator: Record<string, JsonValue>, frameworkName: string, count: number): JsonValue[] {
|
||||||
|
const frameworks = Array.isArray(indicator.SecurityFrameworks) ? indicator.SecurityFrameworks : [];
|
||||||
|
const tags = frameworks.flatMap(item => isJsonObject(item) && item.Name === frameworkName && Array.isArray(item.Tags)
|
||||||
|
? item.Tags : []);
|
||||||
|
return Array.from({ length: count }, (_, index) => tags[index] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workbookResultValue(header: string, value: string): string {
|
||||||
|
if (!/(?:date|timestamp|lastset)$/i.test(header)) return value;
|
||||||
|
const match = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2})(?::\d{2})?\s+(AM|PM)$/i);
|
||||||
|
if (!match) return value;
|
||||||
|
let hour = Number(match[4]) % 12;
|
||||||
|
if (match[6].toUpperCase() === 'PM') hour += 12;
|
||||||
|
return `${match[3]}-${match[1].padStart(2, '0')}-${match[2].padStart(2, '0')} ${String(hour).padStart(2, '0')}:${match[5]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportTable(
|
||||||
|
fileName: string, item: Record<string, JsonValue>, path: string
|
||||||
|
): SupportingTableCandidate | undefined {
|
||||||
|
const indicator = isJsonObject(item.ResIndicator) ? item.ResIndicator : {};
|
||||||
|
const name = typeof indicator.ShortName === 'string' ? indicator.ShortName.trim() : '';
|
||||||
|
const sourceRows = Array.isArray(item.IndicatorReportObjects) ? item.IndicatorReportObjects : [];
|
||||||
|
const rows = sourceRows.filter(Array.isArray);
|
||||||
|
if (!name || !rows.length) return undefined;
|
||||||
|
const headers = rows[0].map((cell, index) => {
|
||||||
|
const value = cellText(cell);
|
||||||
|
const separator = value.indexOf(':');
|
||||||
|
return separator > 0 ? value.slice(0, separator).trim() : `Column ${index + 1}`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
name,
|
||||||
|
headers,
|
||||||
|
rows: rows.map(row => headers.map((header, index) => {
|
||||||
|
const value = cellText(row[index]);
|
||||||
|
const prefix = `${header}:`;
|
||||||
|
const unprefixed = value.startsWith(prefix) ? value.slice(prefix.length).trimStart() : value;
|
||||||
|
return workbookResultValue(header, unprefixed);
|
||||||
|
})),
|
||||||
|
source: { fileName, objectPath: `${path}.IndicatorReportObjects` },
|
||||||
|
attributes: { kind: 'finding-result', findingReference: name }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function indicatorCandidate(
|
||||||
|
fileName: string, item: Record<string, JsonValue>, path: string, categoryNames: Map<number, string>, index: number
|
||||||
|
): FindingCandidate {
|
||||||
|
const indicator = isJsonObject(item.ResIndicator) ? item.ResIndicator : {};
|
||||||
|
const execution = isJsonObject(item.ExecutionResult) ? item.ExecutionResult : {};
|
||||||
|
const hasResults = Array.isArray(item.IndicatorReportObjects) && item.IndicatorReportObjects.length > 0;
|
||||||
|
const frameworkTags = [
|
||||||
|
...frameworkValues(indicator, 'MITRE ATT&CK', 4),
|
||||||
|
...frameworkValues(indicator, 'ANSSI', 4),
|
||||||
|
...frameworkValues(indicator, 'MITRE D3FEND', 2)
|
||||||
|
];
|
||||||
|
const values: Array<{ key: string; value: JsonValue; decoded?: string }> = [
|
||||||
|
{ key: 'ShortName', value: indicator.ShortName ?? null },
|
||||||
|
{ key: 'Name', value: indicator.Name ?? null },
|
||||||
|
{ key: 'Version', value: indicator.Version ?? null },
|
||||||
|
{ key: 'Targets', value: indicator.Targets ?? null, decoded: target(indicator) },
|
||||||
|
{ key: 'Status', value: execution.Status ?? indicator.State ?? null, decoded: purpleStatus(indicator, execution) },
|
||||||
|
{ key: 'Runtime', value: runtime(indicator) },
|
||||||
|
{ key: 'Score', value: execution.Score ?? null },
|
||||||
|
{ key: 'Severity', value: indicator.Severity ?? null },
|
||||||
|
{ key: 'Weight', value: indicator.Weight ?? null },
|
||||||
|
{ key: 'CategoryID', value: indicator.CategoryID ?? null, decoded: category(indicator, categoryNames) },
|
||||||
|
...frameworkTags.map((value, index) => ({ key: `Framework_${index + 1}`, value })),
|
||||||
|
{ key: 'Description', value: indicator.Description ?? null, decoded: plainTextValue(indicator.Description) },
|
||||||
|
{ key: 'LikelihoodOfCompromise', value: indicator.LikelihoodOfCompromise ?? null,
|
||||||
|
decoded: plainTextValue(indicator.LikelihoodOfCompromise) },
|
||||||
|
{ key: 'ResultMessage', value: execution.ResultMessage ?? null },
|
||||||
|
{ key: 'TotalResultsCount', value: item.TotalResultsCount ?? 0 },
|
||||||
|
{ key: 'Result', value: hasResults ? indicator.ShortName ?? null : null },
|
||||||
|
{ key: 'Remediation', value: execution.Remediation ?? null, decoded: plainTextValue(execution.Remediation) }
|
||||||
|
];
|
||||||
|
const fields = values.map((itemValue, columnIndex) => field(
|
||||||
|
fileName, path, itemValue.key, EXCEL_HEADERS[columnIndex], itemValue.value,
|
||||||
|
itemValue.decoded ?? cellText(itemValue.value), columnIndex
|
||||||
|
));
|
||||||
|
return { version: 1, id: `purple-knight-indicator-${index}`, source: { fileName, objectPath: path }, fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const purpleKnightHtmlSourceExtractor: SourceExtractor = {
|
||||||
|
id: 'purple-knight-html-source-extractor',
|
||||||
|
version: '1.1.0',
|
||||||
|
detect(document) {
|
||||||
|
const payload = purplePayload(document);
|
||||||
|
return payload.recognized && payload.indicators.length > 0
|
||||||
|
? { score: 1, evidence: [`Sicher eingebettetes PurpleKnight-Reportmodell mit ${payload.indicators.length} Indikatoren erkannt.`] }
|
||||||
|
: { score: 0, evidence: [] };
|
||||||
|
},
|
||||||
|
extract(document) {
|
||||||
|
const payload = purplePayload(document);
|
||||||
|
const candidates = payload.indicators.map((item, index) =>
|
||||||
|
indicatorCandidate(document.file.name, item.value, item.path, payload.categoryNames, index));
|
||||||
|
const supportingTables = payload.indicators.flatMap(item => {
|
||||||
|
const table = reportTable(document.file.name, item.value, item.path);
|
||||||
|
return table ? [table] : [];
|
||||||
|
});
|
||||||
|
return { version: 1, candidates, supportingTables, diagnostics: payload.diagnostics };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,8 @@ import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '..
|
||||||
import { stringifyCell } from '../shared/text';
|
import { stringifyCell } from '../shared/text';
|
||||||
import { cisCatSourceExtractor } from './cis-cat-source-extractor';
|
import { cisCatSourceExtractor } from './cis-cat-source-extractor';
|
||||||
import { pingCastleSourceExtractor } from './pingcastle-source-extractor';
|
import { pingCastleSourceExtractor } from './pingcastle-source-extractor';
|
||||||
|
import { maesterHtmlSourceExtractor } from './maester-html-source-extractor';
|
||||||
|
import { purpleKnightHtmlSourceExtractor } from './purple-knight-html-source-extractor';
|
||||||
|
|
||||||
function safeId(value: string): string {
|
function safeId(value: string): string {
|
||||||
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
|
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
|
@ -91,6 +93,8 @@ export const genericTableSourceExtractor: SourceExtractor = {
|
||||||
export const sourceExtractors: SourceExtractor[] = [
|
export const sourceExtractors: SourceExtractor[] = [
|
||||||
pingCastleSourceExtractor,
|
pingCastleSourceExtractor,
|
||||||
cisCatSourceExtractor,
|
cisCatSourceExtractor,
|
||||||
|
purpleKnightHtmlSourceExtractor,
|
||||||
|
maesterHtmlSourceExtractor,
|
||||||
nativeObjectSourceExtractor,
|
nativeObjectSourceExtractor,
|
||||||
genericTableSourceExtractor
|
genericTableSourceExtractor
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ h4 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-heigh
|
||||||
svg { display: block; width: 17px; height: 17px; }
|
svg { display: block; width: 17px; height: 17px; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
|
||||||
td { font-size: var(--type-body-size); font-weight: var(--fw-regular); }
|
td { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
|
||||||
th { position: sticky; top: 0; z-index: 1; color: var(--muted); background: var(--panel-soft); font-size: var(--type-table-head-size); font-weight: var(--fw-semibold); letter-spacing: var(--ls-caps); text-transform: uppercase; }
|
th { position: sticky; top: 0; z-index: 1; color: var(--muted); background: var(--panel-soft); font-size: var(--type-table-head-size); font-weight: var(--fw-semibold); letter-spacing: var(--ls-caps); text-transform: uppercase; }
|
||||||
tbody tr:hover td { background: var(--panel-hover); }
|
tbody tr:hover td { background: var(--panel-hover); }
|
||||||
pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
|
@ -30,7 +30,7 @@ button, .btn, .icon-btn, .tab-btn, .bookmark-btn, .results-btn {
|
||||||
}
|
}
|
||||||
.select, .input {
|
.select, .input {
|
||||||
min-height: var(--control-height); border: var(--border-hair) solid var(--line); border-radius: 0;
|
min-height: var(--control-height); border: var(--border-hair) solid var(--line); border-radius: 0;
|
||||||
color: var(--text); background-color: var(--panel-raised); font-size: var(--type-label-size); font-weight: var(--fw-regular);
|
color: var(--text); background-color: var(--panel-raised); font-size: var(--type-button-size); font-weight: var(--fw-regular);
|
||||||
}
|
}
|
||||||
/* Verbindliche Buttonform: eine Hoehe, ein Radius, ein waagerechtes Polster.
|
/* Verbindliche Buttonform: eine Hoehe, ein Radius, ein waagerechtes Polster.
|
||||||
Senkrecht traegt der Knopf kein eigenes Polster - die Hoehe kommt aus
|
Senkrecht traegt der Knopf kein eigenes Polster - die Hoehe kommt aus
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
|
||||||
--heading ist im hellen Skin dunkelgrau, im dunklen nahezu weiss. */
|
--heading ist im hellen Skin dunkelgrau, im dunklen nahezu weiss. */
|
||||||
/* Der Fundname als Ueberschrift des Fensterrumpfs - dieselbe Typografie wie
|
/* Der Fundname als Ueberschrift des Fensterrumpfs - dieselbe Typografie wie
|
||||||
die Finding-Ueberschrift im Reader. */
|
die Finding-Ueberschrift im Reader. */
|
||||||
.linked-results-heading { margin: 0; color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.35; }
|
.linked-results-heading { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--ui-strong-weight); line-height: 1.35; }
|
||||||
.linked-results-empty { padding: 18px; border-left: 3px solid var(--warning); background: color-mix(in srgb, var(--warning) 12%, transparent); line-height: 1.55; }
|
.linked-results-empty { padding: 18px; border-left: 3px solid var(--warning); background: color-mix(in srgb, var(--warning) 12%, transparent); line-height: 1.55; }
|
||||||
/* Ergebnisabschnitte bleiben bewusst rahmenlos: Tabellen besitzen bereits
|
/* Ergebnisabschnitte bleiben bewusst rahmenlos: Tabellen besitzen bereits
|
||||||
eigene Zelllinien. Ein zusaetzlicher Kasten samt Innenabstand verbraucht im
|
eigene Zelllinien. Ein zusaetzlicher Kasten samt Innenabstand verbraucht im
|
||||||
|
|
@ -336,9 +336,9 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
|
||||||
(nowrap-Text hat als min-content seine volle Laenge) und sprengte damit
|
(nowrap-Text hat als min-content seine volle Laenge) und sprengte damit
|
||||||
die Fensterbreite, statt den Namen per Ellipsis zu kuerzen. */
|
die Fensterbreite, statt den Namen per Ellipsis zu kuerzen. */
|
||||||
.results-window-head { display: flex; align-items: stretch; justify-content: space-between; gap: 0; min-height: 32px; min-width: 0; padding: 0 8px 0 0; color: var(--heading); background: var(--panel-soft); border-bottom: 1px solid var(--line); cursor: move; touch-action: none; user-select: none; }
|
.results-window-head { display: flex; align-items: stretch; justify-content: space-between; gap: 0; min-height: 32px; min-width: 0; padding: 0 8px 0 0; color: var(--heading); background: var(--panel-soft); border-bottom: 1px solid var(--line); cursor: move; touch-action: none; user-select: none; }
|
||||||
/* Der Fenstertitel traegt dieselbe Typografie wie die Finding-Ueberschrift
|
/* Nur die beiden wiederholten Resulttitel verwenden die kompakte H4-Stufe;
|
||||||
(.reader-summary h3) - es ist derselbe Satz aus demselben Fund. */
|
Finding- und Tabellenhierarchie ausserhalb des Resultfensters bleibt gleich. */
|
||||||
.results-window-head h2 { display: flex; align-items: stretch; gap: 10px; min-width: 0; margin: 0; color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.35; }
|
.results-window-head h2 { display: flex; align-items: stretch; gap: 10px; min-width: 0; margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--ui-strong-weight); line-height: 1.35; }
|
||||||
.results-window-result-summary { display: inline-flex; align-items: center; align-self: center; flex: 0 0 auto; gap: 5px; padding-left: 8px; color: var(--heading); font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.results-window-result-summary { display: inline-flex; align-items: center; align-self: center; flex: 0 0 auto; gap: 5px; padding-left: 8px; color: var(--heading); font-family: var(--font-mono); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
.results-window-toggle, .results-window-toggle-icon { display: grid; place-items: center; width: 20px; height: 20px; flex: 0 0 auto; color: var(--result-color); }
|
.results-window-toggle, .results-window-toggle-icon { display: grid; place-items: center; width: 20px; height: 20px; flex: 0 0 auto; color: var(--result-color); }
|
||||||
.results-window-toggle { padding: 0; border: 0; border-radius: 3px; background: transparent; }
|
.results-window-toggle { padding: 0; border: 0; border-radius: 3px; background: transparent; }
|
||||||
|
|
@ -445,17 +445,6 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
|
||||||
background: var(--tile-surface);
|
background: var(--tile-surface);
|
||||||
box-shadow: var(--tile-card-shadow);
|
box-shadow: var(--tile-card-shadow);
|
||||||
}
|
}
|
||||||
.donut-metric-card::before {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-1);
|
|
||||||
left: var(--space-3);
|
|
||||||
width: 48%;
|
|
||||||
height: var(--bw-emphasis);
|
|
||||||
background: linear-gradient(90deg, currentColor 0 72%, transparent 100%);
|
|
||||||
transform-origin: left center;
|
|
||||||
animation: donut-accent-build var(--dur-data-build) var(--ease-data-build) both;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
.donut-metric-card__title {
|
.donut-metric-card__title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -493,10 +482,6 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
|
||||||
from { stroke-dasharray: 0 100; }
|
from { stroke-dasharray: 0 100; }
|
||||||
to { stroke-dasharray: var(--donut-progress-dash); }
|
to { stroke-dasharray: var(--donut-progress-dash); }
|
||||||
}
|
}
|
||||||
@keyframes donut-accent-build {
|
|
||||||
from { opacity: 0; transform: scaleX(0); }
|
|
||||||
to { opacity: 1; transform: scaleX(1); }
|
|
||||||
}
|
|
||||||
.donut-metric-card__values {
|
.donut-metric-card__values {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
@ -586,17 +571,15 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
|
||||||
left: calc(100% + var(--donut-compact-unit-gap));
|
left: calc(100% + var(--donut-compact-unit-gap));
|
||||||
font-size: var(--donut-compact-percent-size);
|
font-size: var(--donut-compact-percent-size);
|
||||||
}
|
}
|
||||||
.sidebar-severity-card:hover,
|
.sidebar-severity-card:hover { border-color: transparent; }
|
||||||
.sidebar-severity-card[aria-pressed="true"] {
|
.sidebar-severity-card[aria-pressed="true"] {
|
||||||
border-color: currentColor;
|
border-color: currentColor;
|
||||||
}
|
|
||||||
.sidebar-severity-card[aria-pressed="true"] {
|
|
||||||
outline: var(--bw-emphasis) solid currentColor;
|
outline: var(--bw-emphasis) solid currentColor;
|
||||||
outline-offset: calc(-1 * var(--bw-emphasis));
|
outline-offset: calc(-1 * var(--bw-emphasis));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.donut-metric-card__progress, .donut-metric-card::before { animation: none; }
|
.donut-metric-card__progress { animation: none; }
|
||||||
}
|
}
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.donut-metric-rack { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.donut-metric-rack { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
.datahub-tenant-card { position: relative; min-height: var(--donut-card-min-h); padding: 0; transition: background .12s ease, border-color .12s ease; }
|
.datahub-tenant-card { position: relative; min-height: var(--donut-card-min-h); padding: 0; transition: background .12s ease, border-color .12s ease; }
|
||||||
.datahub-tenant-card::before { content: none; }
|
.datahub-tenant-card::before { content: none; }
|
||||||
.datahub-tenant-card:hover,
|
.datahub-tenant-card:hover,
|
||||||
.datahub-tenant-card:focus-within { border-color: var(--line-strong); background: var(--tile-surface-hover); box-shadow: var(--tile-card-shadow); }
|
.datahub-tenant-card:focus-within { border-color: transparent; background: var(--tile-surface-hover); box-shadow: var(--tile-card-shadow); }
|
||||||
.datahub-tenant-card.is-selected { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
.datahub-tenant-card.is-selected { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
.datahub-tenant-processing-dot { position: absolute; z-index: 3; top: var(--datahub-readiness-dot-inset); left: var(--datahub-readiness-dot-inset); width: var(--datahub-readiness-dot-size); height: var(--datahub-readiness-dot-size); border: var(--border-hair) solid var(--surface-app); border-radius: 50%; background: var(--state-online); pointer-events: none; }
|
.datahub-tenant-processing-dot { position: absolute; z-index: 3; top: var(--datahub-readiness-dot-inset); left: var(--datahub-readiness-dot-inset); width: var(--datahub-readiness-dot-size); height: var(--datahub-readiness-dot-size); border: var(--border-hair) solid var(--surface-app); border-radius: 50%; background: var(--state-online); pointer-events: none; }
|
||||||
.datahub-tenant-processing-dot[data-status="update"] { background: var(--warning); }
|
.datahub-tenant-processing-dot[data-status="update"] { background: var(--warning); }
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
.datahub-section-heading > strong { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 22px; padding: 1px 6px; border: 1px solid var(--border-default); border-radius: 0; color: var(--text-primary); background: var(--surface-input); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
.datahub-section-heading > strong { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 22px; padding: 1px 6px; border: 1px solid var(--border-default); border-radius: 0; color: var(--text-primary); background: var(--surface-input); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
.datahub-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(0, var(--tile-content-width))); justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
.datahub-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(0, var(--tile-content-width))); justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.datahub-card { display: grid; grid-template-columns: 40px minmax(0,1fr) auto; align-items: center; gap: var(--legacy-space-3); min-width: 0; min-height: 96px; padding: var(--legacy-space-4); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); text-align: left; transition: border-color .12s ease, background .12s ease; }
|
.datahub-card { display: grid; grid-template-columns: 40px minmax(0,1fr) auto; align-items: center; gap: var(--legacy-space-3); min-width: 0; min-height: 96px; padding: var(--legacy-space-4); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); text-align: left; transition: border-color .12s ease, background .12s ease; }
|
||||||
.datahub-card:hover { border-color: var(--line-strong); background: var(--tile-surface-hover); }
|
.datahub-card:hover { border-color: transparent; background: var(--tile-surface-hover); }
|
||||||
.datahub-card-mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--legacy-radius-sm); color: var(--text-on-accent); background: var(--legacy-accent); font-size: var(--ui-control-size); font-weight: var(--ui-strong-weight); letter-spacing: .05em; }
|
.datahub-card-mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--legacy-radius-sm); color: var(--text-on-accent); background: var(--legacy-accent); font-size: var(--ui-control-size); font-weight: var(--ui-strong-weight); letter-spacing: .05em; }
|
||||||
.datahub-card-body { display: grid; gap: var(--legacy-space-1); min-width: 0; }
|
.datahub-card-body { display: grid; gap: var(--legacy-space-1); min-width: 0; }
|
||||||
.datahub-card-body strong { color: var(--heading); font-size: var(--ui-body-size); overflow: hidden; text-overflow: ellipsis; }
|
.datahub-card-body strong { color: var(--heading); font-size: var(--ui-body-size); overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
@ -162,13 +162,14 @@
|
||||||
|
|
||||||
/* Dashboard: kompaktes Lagebild des geoeffneten Mandanten. Statuskarten und
|
/* Dashboard: kompaktes Lagebild des geoeffneten Mandanten. Statuskarten und
|
||||||
Verteilungen bleiben direkte Einstiege in Work mit passendem Filter. */
|
Verteilungen bleiben direkte Einstiege in Work mit passendem Filter. */
|
||||||
.dashboard-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--space-4); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
|
.dashboard-page { box-sizing: border-box; display: grid; align-content: start; gap: var(--space-4); width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); container-type: inline-size; }
|
||||||
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; padding: 0 var(--space-2); color: var(--muted); font-size: var(--ui-label-size); }
|
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-5); margin: 0; padding: 0 var(--space-2); color: var(--muted); font-size: var(--ui-label-size); }
|
||||||
.dashboard-context strong { color: var(--heading); font-size: var(--ui-body-size); }
|
.dashboard-context strong { color: var(--heading); font-size: var(--ui-body-size); }
|
||||||
|
.dashboard-context .work-timeslot-select { margin-left: auto; }
|
||||||
.dashboard-status-rack { display: grid; grid-template-columns: 290px repeat(3, 210px) 250px; justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
.dashboard-status-rack { display: grid; grid-template-columns: 290px repeat(3, 210px) 250px; justify-content: start; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.dashboard-status-metric { display: grid; grid-template-columns: 98px minmax(0, 1fr); align-items: center; gap: var(--space-3); min-width: 0; min-height: 190px; padding: var(--space-3); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); text-align: left; }
|
.dashboard-status-metric { display: grid; grid-template-columns: 98px minmax(0, 1fr); align-items: center; gap: var(--space-3); min-width: 0; min-height: 190px; padding: var(--space-3); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); text-align: left; }
|
||||||
button.dashboard-status-metric { cursor: pointer; }
|
button.dashboard-status-metric { cursor: pointer; }
|
||||||
button.dashboard-status-metric:hover { border-color: currentColor; background: var(--tile-surface-hover); }
|
button.dashboard-status-metric:hover { border-color: transparent; background: var(--tile-surface-hover); }
|
||||||
button.dashboard-status-metric:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
|
button.dashboard-status-metric:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
|
||||||
button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
.dashboard-status-metric-large { grid-template-columns: 154px minmax(0, 1fr); }
|
.dashboard-status-metric-large { grid-template-columns: 154px minmax(0, 1fr); }
|
||||||
|
|
@ -192,7 +193,7 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
.dashboard-status-copy { display: grid; gap: var(--space-3); min-width: 0; max-width: 100%; overflow: hidden; }
|
.dashboard-status-copy { display: grid; gap: var(--space-3); min-width: 0; max-width: 100%; overflow: hidden; }
|
||||||
.dashboard-status-copy > span { min-width: 0; color: var(--heading); font-size: var(--ui-body-size); overflow-wrap: normal; word-break: normal; hyphens: manual; }
|
.dashboard-status-copy > span { min-width: 0; color: var(--heading); font-size: var(--ui-body-size); overflow-wrap: normal; word-break: normal; hyphens: manual; }
|
||||||
.dashboard-status-copy > strong { display: grid; gap: var(--space-1); min-width: 0; color: var(--heading); font-size: calc(var(--ui-heading-size) * 1.35); font-variant-numeric: tabular-nums; white-space: normal; }
|
.dashboard-status-copy > strong { display: grid; gap: var(--space-1); min-width: 0; color: var(--heading); font-size: calc(var(--ui-heading-size) * 1.35); font-variant-numeric: tabular-nums; white-space: normal; }
|
||||||
.dashboard-status-copy > strong small, .dashboard-status-copy > small { display: block; color: var(--muted); font-size: var(--ui-body-size); font-weight: var(--fw-regular); line-height: 1.35; overflow-wrap: normal; word-break: normal; hyphens: manual; }
|
.dashboard-status-copy > small { display: block; color: var(--muted); font-size: var(--ui-body-size); font-weight: var(--fw-regular); line-height: 1.35; overflow-wrap: normal; word-break: normal; hyphens: manual; }
|
||||||
.dashboard-status-summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(100px, .8fr); align-items: stretch; min-width: 0; min-height: 190px; padding: var(--space-4); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
.dashboard-status-summary { display: grid; grid-template-columns: minmax(0, 1fr) minmax(100px, .8fr); align-items: stretch; min-width: 0; min-height: 190px; padding: var(--space-4); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||||
.dashboard-status-summary-stack { display: grid; align-content: center; padding-right: var(--space-4); border-right: var(--border-hair) solid var(--line-strong); }
|
.dashboard-status-summary-stack { display: grid; align-content: center; padding-right: var(--space-4); border-right: var(--border-hair) solid var(--line-strong); }
|
||||||
.dashboard-status-summary-stack button { display: grid; gap: var(--space-2); min-height: 64px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
|
.dashboard-status-summary-stack button { display: grid; gap: var(--space-2); min-height: 64px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
|
||||||
|
|
@ -207,7 +208,7 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
.dashboard-ioe-summary strong { color: var(--accent); font: var(--fw-semibold) calc(var(--donut-value-size) * 1.45) / 1 var(--font-donut-display); font-variant-numeric: tabular-nums; }
|
.dashboard-ioe-summary strong { color: var(--accent); font: var(--fw-semibold) calc(var(--donut-value-size) * 1.45) / 1 var(--font-donut-display); font-variant-numeric: tabular-nums; }
|
||||||
.dashboard-distribution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
.dashboard-distribution-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.dashboard-lower-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(420px, 1fr); align-items: stretch; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
.dashboard-lower-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(420px, 1fr); align-items: stretch; gap: var(--segment-gap); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.dashboard-environment { min-width: 0; margin: 0; padding: var(--space-4); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-left: 3px solid var(--legacy-accent); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
.dashboard-environment { min-width: 0; margin: 0; padding: var(--space-4); overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); }
|
||||||
.dashboard-environment .datahub-section-heading h2, .dashboard-block h2 { margin: 0; color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
|
.dashboard-environment .datahub-section-heading h2, .dashboard-block h2 { margin: 0; color: var(--heading); font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
|
||||||
.dashboard-coverage-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: var(--space-4); padding-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--line); }
|
.dashboard-coverage-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: var(--space-4); padding-bottom: var(--space-4); border-bottom: var(--border-hair) solid var(--line); }
|
||||||
.dashboard-coverage-summary > span { display: grid; gap: var(--space-1); min-width: 0; padding: 0 var(--space-3); text-align: center; }
|
.dashboard-coverage-summary > span { display: grid; gap: var(--space-1); min-width: 0; padding: 0 var(--space-3); text-align: center; }
|
||||||
|
|
@ -238,6 +239,7 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
.dashboard-viz-4 { --severity: var(--viz-4); }
|
.dashboard-viz-4 { --severity: var(--viz-4); }
|
||||||
.dashboard-viz-5 { --severity: var(--viz-5); }
|
.dashboard-viz-5 { --severity: var(--viz-5); }
|
||||||
.dashboard-viz-6 { --severity: var(--viz-6); }
|
.dashboard-viz-6 { --severity: var(--viz-6); }
|
||||||
|
.severity-dashboard-cis-cat-without-severity { --severity: var(--donut-not-run); }
|
||||||
.dashboard-empty { margin: 0; color: var(--muted); font-size: var(--ui-control-size); }
|
.dashboard-empty { margin: 0; color: var(--muted); font-size: var(--ui-control-size); }
|
||||||
.dashboard-risk-profile { display: grid; align-content: start; }
|
.dashboard-risk-profile { display: grid; align-content: start; }
|
||||||
.dashboard-risk-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 32px; }
|
.dashboard-risk-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 32px; }
|
||||||
|
|
@ -283,6 +285,161 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
|
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Client Info folgt dem SOC-Board mit genau zwei quadratischen
|
||||||
|
Kachelgroessen. Das gemeinsame dichte Raster nutzt auch Luecken neben den
|
||||||
|
kleinen Kennzahlen, statt getrennte, ueber die Breite gestreckte Zeilen zu
|
||||||
|
erzeugen. */
|
||||||
|
.client-info-tile-grid {
|
||||||
|
--client-info-columns: 12;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--client-info-columns), minmax(0, 1fr));
|
||||||
|
grid-auto-rows: calc((100cqw - 11 * var(--segment-gap)) / 12);
|
||||||
|
grid-auto-flow: row dense;
|
||||||
|
gap: var(--segment-gap);
|
||||||
|
}
|
||||||
|
.client-info-metric-grid {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.client-info-metric {
|
||||||
|
display: grid;
|
||||||
|
align-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--tile-surface);
|
||||||
|
box-shadow: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
button.client-info-metric { cursor: pointer; }
|
||||||
|
button.client-info-metric:hover { border-color: transparent; background: var(--tile-surface-hover); }
|
||||||
|
button.client-info-metric:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
|
button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
|
.client-info-metric > span { color: var(--heading); font-size: var(--type-label-size); font-weight: var(--fw-semibold); line-height: 1.25; }
|
||||||
|
.client-info-metric > strong { color: var(--chart-color, var(--heading)); font-size: calc(var(--type-h1-size) * .72); font-weight: var(--fw-regular); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.client-info-card-grid {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.client-info-card {
|
||||||
|
grid-column: span 2;
|
||||||
|
grid-row: span 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--tile-surface);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.client-info-card > header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-height: 24px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.client-info-card > header h2 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.35; }
|
||||||
|
.client-info-card > header span { color: var(--muted); font-size: var(--type-meta-size); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
|
||||||
|
.client-info-donut-layout { display: grid; grid-template-columns: minmax(120px, .8fr) minmax(130px, 1fr); align-items: center; gap: var(--space-3); flex: 1 1 auto; min-width: 0; }
|
||||||
|
.client-info-donut { position: relative; width: min(160px, 100%); aspect-ratio: 1; justify-self: center; }
|
||||||
|
.client-info-donut svg { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }
|
||||||
|
.client-info-donut circle { fill: none; stroke-width: var(--donut-ring-stroke); }
|
||||||
|
.client-info-donut-track { stroke: var(--donut-track); }
|
||||||
|
.client-info-donut-segment { stroke: var(--chart-color, var(--info)); }
|
||||||
|
.client-info-donut > span { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; color: var(--heading); text-align: center; }
|
||||||
|
.client-info-donut > span strong { font-size: calc(var(--type-h2-size) * 1.08); font-weight: var(--fw-regular); line-height: 1; font-variant-numeric: tabular-nums; }
|
||||||
|
.client-info-donut > span small { margin-top: var(--space-1); color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
|
.client-info-legend { display: grid; align-content: center; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
|
||||||
|
.client-info-legend button, .client-info-legend-row { display: grid; grid-template-columns: 11px minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); width: 100%; min-height: 36px; padding: var(--space-1) var(--space-2); border: 0; border-radius: var(--btn-radius); color: var(--text); background: transparent; text-align: left; }
|
||||||
|
.client-info-legend button { cursor: pointer; }
|
||||||
|
.client-info-legend button:hover { background: var(--panel-hover); }
|
||||||
|
.client-info-legend button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
|
||||||
|
.client-info-legend i { width: 11px; height: 11px; background: var(--chart-color, var(--muted)); }
|
||||||
|
.client-info-legend span { min-width: 0; font-size: var(--type-table-body-size); line-height: 1.25; overflow-wrap: normal; word-break: normal; }
|
||||||
|
.client-info-legend strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.client-info-facts { display: grid; align-content: start; margin: 0; }
|
||||||
|
.client-info-facts > div { display: grid; grid-template-columns: minmax(100px, .8fr) minmax(0, 1.2fr); align-items: baseline; gap: var(--space-3); min-height: 32px; padding: var(--space-1) 0; border-bottom: var(--border-hair) solid var(--line); }
|
||||||
|
.client-info-facts > div:last-child { border-bottom: 0; }
|
||||||
|
.client-info-facts dt { color: var(--muted); font-size: var(--type-table-body-size); }
|
||||||
|
.client-info-facts dd { margin: 0; color: var(--heading); font-size: var(--type-table-body-size); line-height: 1.35; overflow-wrap: normal; word-break: normal; }
|
||||||
|
|
||||||
|
.client-info-bars { display: grid; align-content: start; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
|
||||||
|
.client-info-bars li { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(50px, .9fr) auto; align-items: center; gap: var(--space-2); min-height: 36px; }
|
||||||
|
.client-info-bars li > span:first-child { color: var(--text); font-size: var(--type-table-body-size); line-height: 1.25; }
|
||||||
|
.client-info-bar-track { height: 14px; background: var(--surface-active); }
|
||||||
|
.client-info-bar-track i { --share: 0%; display: block; width: var(--share); min-width: 2px; height: 100%; background: var(--chart-color, var(--accent)); }
|
||||||
|
.client-info-bars strong { color: var(--heading); font-size: var(--type-table-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
.client-info-bars small { grid-column: 3; color: var(--warning); font-size: var(--type-meta-size); text-align: right; }
|
||||||
|
.client-info-card-caption { margin: auto 0 0; padding-top: var(--space-3); border-top: var(--border-hair) solid var(--line); color: var(--muted); font-size: var(--type-meta-size); }
|
||||||
|
.client-info-resilience { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto minmax(0, 1fr); gap: var(--space-3); }
|
||||||
|
.client-info-resilience > header { grid-column: 1; }
|
||||||
|
.client-info-resilience .client-info-bars { grid-column: 1; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); align-self: start; gap: var(--space-1); padding-bottom: var(--space-3); border-bottom: var(--border-hair) solid var(--line); }
|
||||||
|
.client-info-resilience .client-info-bars li { display: grid; grid-template-columns: minmax(0, 1fr); align-content: space-between; gap: var(--space-1); min-height: 58px; padding: var(--space-1); border-right: var(--border-hair) solid var(--line); }
|
||||||
|
.client-info-resilience .client-info-bars li:last-child { border-right: 0; }
|
||||||
|
.client-info-resilience .client-info-bars li > span:first-child { font-size: var(--type-meta-size); }
|
||||||
|
.client-info-resilience .client-info-bar-track { display: none; }
|
||||||
|
.client-info-resilience .client-info-bars strong { grid-column: 1; text-align: left; }
|
||||||
|
.client-info-resilience .client-info-facts { grid-column: 1; align-self: start; }
|
||||||
|
.client-info-resilience .client-info-facts > div { grid-template-columns: minmax(90px, .8fr) minmax(0, 1.2fr); gap: var(--space-2); min-height: 24px; padding: var(--space-1) 0; border-bottom: 0; }
|
||||||
|
|
||||||
|
.tone-deviation, .tone-error, .tone-critical { --chart-color: var(--critical); }
|
||||||
|
.tone-passed, .tone-low, .tone-coverage { --chart-color: var(--success); }
|
||||||
|
.tone-high { --chart-color: var(--high); }
|
||||||
|
.tone-medium { --chart-color: var(--medium); }
|
||||||
|
.tone-info { --chart-color: var(--info); }
|
||||||
|
.tone-not-run, .tone-neutral, .tone-dashboard-cis-cat-without-severity { --chart-color: var(--donut-not-run); }
|
||||||
|
.tone-accent, .tone-pki { --chart-color: var(--accent); }
|
||||||
|
.tone-gpo { --chart-color: var(--high); }
|
||||||
|
.tone-risk { --chart-color: var(--warning); }
|
||||||
|
|
||||||
|
@media (max-width: 1800px) {
|
||||||
|
.client-info-tile-grid {
|
||||||
|
--client-info-columns: 10;
|
||||||
|
grid-auto-rows: calc((100cqw - 9 * var(--segment-gap)) / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.client-info-tile-grid {
|
||||||
|
--client-info-columns: 8;
|
||||||
|
grid-auto-rows: calc((100cqw - 7 * var(--segment-gap)) / 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.client-info-tile-grid {
|
||||||
|
--client-info-columns: 6;
|
||||||
|
grid-auto-rows: calc((100cqw - 5 * var(--segment-gap)) / 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.client-info-tile-grid {
|
||||||
|
--client-info-columns: 4;
|
||||||
|
grid-auto-rows: calc((100cqw - 3 * var(--segment-gap)) / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.client-info-tile-grid {
|
||||||
|
--client-info-columns: 2;
|
||||||
|
grid-auto-rows: calc((100cqw - var(--segment-gap)) / 2);
|
||||||
|
}
|
||||||
|
.client-info-donut-layout { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.client-info-bars li { grid-template-columns: minmax(110px, 1fr) minmax(60px, 1fr) auto; }
|
||||||
|
.client-info-resilience .client-info-bars { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
/* DB-Struktur panel */
|
/* DB-Struktur panel */
|
||||||
.db-structure-page { width: 100%; max-width: none; }
|
.db-structure-page { width: 100%; max-width: none; }
|
||||||
/* Der Abstand nach oben gehoert der Zeile, nicht dem Text darin: die Metazeile
|
/* Der Abstand nach oben gehoert der Zeile, nicht dem Text darin: die Metazeile
|
||||||
|
|
@ -759,7 +916,10 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.tenant-timeslot-list {
|
.tenant-timeslot-list {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
gap: var(--space-3);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: var(--space-2) var(--space-2);
|
padding: var(--space-2) var(--space-2);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -772,23 +932,18 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
}
|
}
|
||||||
.tenant-scan-history {
|
.tenant-scan-history {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 0;
|
overflow: hidden;
|
||||||
border-radius: 0;
|
border: var(--bw-hairline) solid var(--border-default);
|
||||||
|
border-radius: var(--radius-nav-item);
|
||||||
background: var(--surface-panel-raised);
|
background: var(--surface-panel-raised);
|
||||||
}
|
}
|
||||||
.tenant-scan-history::after {
|
.tenant-scan-history::after {
|
||||||
position: absolute;
|
content: none;
|
||||||
right: var(--space-4);
|
|
||||||
bottom: 0;
|
|
||||||
left: var(--space-4);
|
|
||||||
height: var(--bw-hairline);
|
|
||||||
background: var(--border-subtle);
|
|
||||||
content: "";
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
.tenant-timeslot.is-selected {
|
.tenant-timeslot.is-selected {
|
||||||
border-color: var(--border-default);
|
border-color: var(--state-active);
|
||||||
outline: 0;
|
outline: var(--bw-hairline) solid var(--state-active);
|
||||||
|
outline-offset: calc(-1 * var(--bw-hairline));
|
||||||
box-shadow: inset var(--marker-width) 0 0 var(--state-active);
|
box-shadow: inset var(--marker-width) 0 0 var(--state-active);
|
||||||
}
|
}
|
||||||
.tenant-timeslot.is-selected::after {
|
.tenant-timeslot.is-selected::after {
|
||||||
|
|
@ -809,7 +964,17 @@ button.dashboard-status-metric[aria-pressed="true"] { border-color: var(--tile-s
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
.tenant-timeslot-title {
|
.tenant-timeslot-title {
|
||||||
|
align-self: stretch;
|
||||||
|
flex: 0 0 calc(var(--space-8) * 4.25);
|
||||||
|
padding-left: var(--space-1);
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tenant-timeslot > .tenant-section-bar > .tenant-history-meta {
|
||||||
|
align-self: stretch;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tenant-timeslot-title input[type="checkbox"],
|
.tenant-timeslot-title input[type="checkbox"],
|
||||||
.tenant-report-row input[type="checkbox"] {
|
.tenant-report-row input[type="checkbox"] {
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
|
||||||
.tile-view-scroll { padding: var(--panel-inset); }
|
.tile-view-scroll { padding: var(--panel-inset); }
|
||||||
.tile-grid { box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fill, minmax(0, var(--tile-content-width))); align-content: start; justify-content: start; gap: var(--segment-gap); min-height: 100%; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
.tile-grid { box-sizing: border-box; display: grid; grid-template-columns: repeat(auto-fill, minmax(0, var(--tile-content-width))); align-content: start; justify-content: start; gap: var(--segment-gap); min-height: 100%; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
|
||||||
.table-scroll { max-width: 100%; overflow: auto; background: var(--surface-app); }
|
.table-scroll { max-width: 100%; overflow: auto; background: var(--surface-app); }
|
||||||
.raw-view { min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); font: var(--fs-lg)/1.55 var(--font-mono); }
|
.raw-view { min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); font: var(--type-table-body-size)/1.55 var(--font-mono); }
|
||||||
@media (max-width: 1500px) { .topbar-kpi { min-width: 74px; padding-inline: 10px; } .topbar-kpi.kpi-format { min-width: 190px; } .delimiter-select { width: 170px; } .tab-btn { min-width: 68px; } }
|
@media (max-width: 1500px) { .topbar-kpi { min-width: 74px; padding-inline: 10px; } .topbar-kpi.kpi-format { min-width: 190px; } .delimiter-select { width: 170px; } .tab-btn { min-width: 68px; } }
|
||||||
@media (max-width: 1200px) { .status-line-tools { position: static; width: min(520px, 100%); margin-left: auto; transform: none; } }
|
@media (max-width: 1200px) { .status-line-tools { position: static; width: min(520px, 100%); margin-left: auto; transform: none; } }
|
||||||
@media (max-width: 1050px) { .workbar { grid-template-columns: minmax(0, 1fr); } .workbar-action { grid-column: 1; justify-content: flex-end; } .records-layout { grid-template-columns: minmax(320px, 42%) minmax(0, 1fr); } }
|
@media (max-width: 1050px) { .workbar { grid-template-columns: minmax(0, 1fr); } .workbar-action { grid-column: 1; justify-content: flex-end; } .records-layout { grid-template-columns: minmax(320px, 42%) minmax(0, 1fr); } }
|
||||||
|
|
@ -147,6 +147,10 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
|
||||||
.area-topbar strong { margin-right: auto; color: var(--heading); font-size: var(--type-h1-size); font-weight: var(--fw-semibold); line-height: 1.1; letter-spacing: -.04em; }
|
.area-topbar strong { margin-right: auto; color: var(--heading); font-size: var(--type-h1-size); font-weight: var(--fw-semibold); line-height: 1.1; letter-spacing: -.04em; }
|
||||||
.area-topbar-heading { display: flex; align-items: center; gap: 18px; min-width: 0; margin-right: auto; overflow: hidden; }
|
.area-topbar-heading { display: flex; align-items: center; gap: 18px; min-width: 0; margin-right: auto; overflow: hidden; }
|
||||||
.area-topbar-heading > strong { flex: 0 0 auto; margin-right: 0; }
|
.area-topbar-heading > strong { flex: 0 0 auto; margin-right: 0; }
|
||||||
|
.area-client-context-inline { display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
||||||
|
.area-client-marker { flex: 0 0 auto; color: var(--state-active); }
|
||||||
|
.area-client-marker .triangle-icon { width: 24px; height: 24px; }
|
||||||
|
.area-client-name { min-width: 0; overflow: hidden; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pathfinder-filter-trail { display: flex; align-items: center; gap: 12px; min-width: 0; color: var(--heading); font-size: calc(var(--fs-xl) * 1.5); font-weight: var(--fw-semibold); line-height: 1.15; white-space: nowrap; }
|
.pathfinder-filter-trail { display: flex; align-items: center; gap: 12px; min-width: 0; color: var(--heading); font-size: calc(var(--fs-xl) * 1.5); font-weight: var(--fw-semibold); line-height: 1.15; white-space: nowrap; }
|
||||||
.pathfinder-filter-trail-marker, .pathfinder-filter-trail-plus { flex: 0 0 auto; color: var(--state-active); }
|
.pathfinder-filter-trail-marker, .pathfinder-filter-trail-plus { flex: 0 0 auto; color: var(--state-active); }
|
||||||
.pathfinder-filter-trail-marker { display: inline-flex; }
|
.pathfinder-filter-trail-marker { display: inline-flex; }
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,14 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
.record-card {
|
.record-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34px minmax(0, 1fr);
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: auto auto;
|
||||||
column-gap: var(--legacy-space-3);
|
column-gap: var(--space-2);
|
||||||
row-gap: var(--legacy-space-2);
|
row-gap: var(--legacy-space-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 74px;
|
min-height: 74px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 13px 16px;
|
padding: 11px 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
/* Die Auswahlkante ist immer vorhanden, nur unsichtbar. Sonst versetzt die
|
/* Die Auswahlkante ist immer vorhanden, nur unsichtbar. Sonst versetzt die
|
||||||
aktive Zeile ihren Inhalt gegen alle anderen. */
|
aktive Zeile ihren Inhalt gegen alle anderen. */
|
||||||
|
|
@ -155,9 +155,9 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
align-self: end;
|
align-self: end;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: var(--heading);
|
color: var(--heading);
|
||||||
font-size: var(--ui-heading-size);
|
font-size: var(--type-h4-size);
|
||||||
font-weight: var(--ui-strong-weight);
|
font-weight: var(--fw-regular);
|
||||||
line-height: 1.35;
|
line-height: 1.28;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.record-meta {
|
.record-meta {
|
||||||
|
|
@ -294,13 +294,21 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background .12s ease, border-color .12s ease;
|
transition: background .12s ease, border-color .12s ease;
|
||||||
}
|
}
|
||||||
.data-tile:hover, .data-tile:focus-visible {
|
.data-tile:hover {
|
||||||
border-color: var(--line-strong);
|
border-color: transparent;
|
||||||
border-radius: var(--tile-radius);
|
border-radius: var(--tile-radius);
|
||||||
background: var(--tile-surface-hover);
|
background: var(--tile-surface-hover);
|
||||||
box-shadow: var(--tile-card-shadow);
|
box-shadow: var(--tile-card-shadow);
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
.data-tile:focus-visible {
|
||||||
|
border-color: transparent;
|
||||||
|
border-radius: var(--tile-radius);
|
||||||
|
background: var(--tile-surface-hover);
|
||||||
|
box-shadow: var(--tile-card-shadow);
|
||||||
|
outline: var(--bw-emphasis) solid var(--focus-ring);
|
||||||
|
outline-offset: calc(-1 * var(--bw-emphasis));
|
||||||
|
}
|
||||||
.data-tile-hex {
|
.data-tile-hex {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 26px;
|
width: 26px;
|
||||||
|
|
@ -364,7 +372,7 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
`.table-scroll`, statt sich in die Panelbreite zu quetschen. Zuvor erzwang
|
`.table-scroll`, statt sich in die Panelbreite zu quetschen. Zuvor erzwang
|
||||||
`table { width: 100% }` aus base.css bei rund dreissig Spalten Spalten von
|
`table { width: 100% }` aus base.css bei rund dreissig Spalten Spalten von
|
||||||
wenigen Pixeln. */
|
wenigen Pixeln. */
|
||||||
.view-table table { width: max-content; min-width: 100%; border-color: var(--line); background: var(--panel); color: var(--text); font-size: var(--fs-lg); }
|
.view-table table { width: max-content; min-width: 100%; border-color: var(--line); background: var(--panel); color: var(--text); font-size: var(--type-table-body-size); }
|
||||||
.view-table th, .view-table td {
|
.view-table th, .view-table td {
|
||||||
min-width: var(--table-col-min-w);
|
min-width: var(--table-col-min-w);
|
||||||
max-width: min(440px, 46vw);
|
max-width: min(440px, 46vw);
|
||||||
|
|
@ -379,13 +387,13 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
.view-table th {
|
.view-table th {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
font-size: var(--fs-lg);
|
font-size: var(--type-table-head-size);
|
||||||
font-weight: var(--ui-label-weight);
|
font-weight: var(--ui-label-weight);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: var(--ui-label-tracking);
|
letter-spacing: var(--ui-label-tracking);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.view-table td { color: var(--text); background: var(--panel); font-size: var(--fs-lg); font-weight: 400; line-height: 1.35; }
|
.view-table td { color: var(--text); background: var(--panel); font-size: var(--type-table-body-size); font-weight: 400; line-height: 1.35; }
|
||||||
/* Zeilenwechsel als ruhige Lesehilfe, in beiden Skins aus denselben Tokens. */
|
/* Zeilenwechsel als ruhige Lesehilfe, in beiden Skins aus denselben Tokens. */
|
||||||
.view-table tbody tr:nth-child(even) td { background: var(--panel-soft); }
|
.view-table tbody tr:nth-child(even) td { background: var(--panel-soft); }
|
||||||
.view-table tbody tr:hover td { color: var(--heading); background: var(--panel-hover); }
|
.view-table tbody tr:hover td { color: var(--heading); background: var(--panel-hover); }
|
||||||
|
|
@ -399,7 +407,7 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--fs-lg);
|
font-size: var(--type-table-body-size);
|
||||||
font-weight: var(--fw-regular);
|
font-weight: var(--fw-regular);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
@ -421,5 +429,3 @@ button, input, select, textarea { font-family: inherit; }
|
||||||
.data-tile, .record-card { transition: none; }
|
.data-tile, .record-card { transition: none; }
|
||||||
.record-card:hover { transform: none; }
|
.record-card:hover { transform: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
.finding-tactic-column > header span { display: grid; place-items: center; min-width: 26px; height: 26px; border-radius: var(--radius-pill); color: var(--muted); background: var(--surface-inset); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); }
|
.finding-tactic-column > header span { display: grid; place-items: center; min-width: 26px; height: 26px; border-radius: var(--radius-pill); color: var(--muted); background: var(--surface-inset); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); }
|
||||||
.finding-tactic-column > div { display: grid; gap: 7px; max-height: 560px; padding: 8px; overflow-y: auto; }
|
.finding-tactic-column > div { display: grid; gap: 7px; max-height: 560px; padding: 8px; overflow-y: auto; }
|
||||||
.finding-path-card { position: relative; display: grid; gap: 7px; width: 100%; min-width: 0; min-height: 126px; padding: 11px 11px 10px 14px; overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); text-align: left; }
|
.finding-path-card { position: relative; display: grid; gap: 7px; width: 100%; min-width: 0; min-height: 126px; padding: 11px 11px 10px 14px; overflow: hidden; border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); color: var(--text); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); text-align: left; }
|
||||||
.finding-path-card:hover { border-color: var(--line-strong); background: var(--tile-surface-hover); }
|
.finding-path-card:hover { border-color: transparent; background: var(--tile-surface-hover); }
|
||||||
.finding-path-card.is-path-step { border-left: var(--marker-width) solid var(--severity); padding-left: 7px; }
|
.finding-path-card.is-path-step { border-left: var(--marker-width) solid var(--severity); padding-left: 7px; }
|
||||||
.finding-path-card.is-selected { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
.finding-path-card.is-selected { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); }
|
||||||
.finding-path-card-head { display: flex; align-items: center; gap: 7px; color: var(--severity); font-size: var(--ui-label-size); font-weight: var(--fw-semibold); letter-spacing: var(--ls-caps); text-transform: uppercase; }
|
.finding-path-card-head { display: flex; align-items: center; gap: 7px; color: var(--severity); font-size: var(--ui-label-size); font-weight: var(--fw-semibold); letter-spacing: var(--ls-caps); text-transform: uppercase; }
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
--state-selected-flat-text: var(--text-selected-flat);
|
--state-selected-flat-text: var(--text-selected-flat);
|
||||||
--state-selected-flat-muted: var(--text-muted-selected-flat);
|
--state-selected-flat-muted: var(--text-muted-selected-flat);
|
||||||
--state-selected-flat-border: var(--border-selected-flat);
|
--state-selected-flat-border: var(--border-selected-flat);
|
||||||
--state-active: var(--band-surface);
|
--state-active: var(--marking-color);
|
||||||
--state-active-text: var(--band-text);
|
--state-active-text: var(--band-text);
|
||||||
--result-color: var(--state-active);
|
--result-color: var(--state-active);
|
||||||
|
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
--ui-small-size: var(--type-meta-size);
|
--ui-small-size: var(--type-meta-size);
|
||||||
--ui-value-size: var(--type-label-size);
|
--ui-value-size: var(--type-label-size);
|
||||||
--ui-control-size: var(--type-button-size);
|
--ui-control-size: var(--type-button-size);
|
||||||
--ui-body-size: var(--type-body-size);
|
--ui-body-size: var(--type-table-body-size);
|
||||||
--ui-heading-size: var(--type-h3-size);
|
--ui-heading-size: var(--type-h3-size);
|
||||||
--ui-reading-size: var(--type-body-size);
|
--ui-reading-size: var(--type-body-size);
|
||||||
--ui-reading-heading-size: var(--type-h2-size);
|
--ui-reading-heading-size: var(--type-h2-size);
|
||||||
|
|
|
||||||
|
|
@ -40,31 +40,30 @@
|
||||||
--type-detail-title-size: 20px;
|
--type-detail-title-size: 20px;
|
||||||
--type-h4-size: 16px;
|
--type-h4-size: 16px;
|
||||||
--type-body-size: 16px;
|
--type-body-size: 16px;
|
||||||
--type-label-size: 14px;
|
--type-table-body-size: 14px;
|
||||||
--type-meta-size: 13px;
|
--type-label-size: 12px;
|
||||||
--type-badge-size: 12px;
|
--type-meta-size: 12px;
|
||||||
--type-table-head-size: 13px;
|
--type-badge-size: 11px;
|
||||||
--type-button-size: 14px;
|
--type-table-head-size: 12px;
|
||||||
|
--type-button-size: 13px;
|
||||||
|
|
||||||
/* ---------- Verbindlicher Donut-Standard ---------- */
|
/* ---------- Verbindlicher Donut-Standard ---------- */
|
||||||
/* Der Donut-Rack ist ein Panel wie jedes andere: Rack = Hintergrund,
|
/* Racks und andere Segmente sind reine, transparente Layoutgruppen.
|
||||||
Karte = Element, Ringspur = eingesenkt. Geometrie und Datenfarben des
|
Karte = Element, Ringspur = eingesenkt. */
|
||||||
Donut-Standards bleiben unveraendert, nur die Grundtoene folgen jetzt
|
|
||||||
dem Panelstandard. */
|
|
||||||
/* ---------- Verbindliche Segment-/Kachelgrammatik ---------- */
|
/* ---------- Verbindliche Segment-/Kachelgrammatik ---------- */
|
||||||
--segment-surface: #242426;
|
--segment-surface: transparent;
|
||||||
--segment-border: #383840;
|
--segment-border: transparent;
|
||||||
--segment-radius: 10px;
|
--segment-radius: 10px;
|
||||||
--segment-inset: var(--space-3);
|
--segment-inset: var(--space-3);
|
||||||
--segment-gap: var(--space-2);
|
--segment-gap: var(--space-3);
|
||||||
--tile-surface: #323238;
|
--tile-surface: #323238;
|
||||||
--tile-surface-hover: #3A3A42;
|
--tile-surface-hover: #3A3A42;
|
||||||
--tile-border: #555B65;
|
--tile-border: transparent;
|
||||||
--tile-selected-border: #A68AF9;
|
--tile-selected-border: var(--marking-color);
|
||||||
--tile-radius: 8px;
|
--tile-radius: var(--radius-sm);
|
||||||
--tile-compact-width: 186px;
|
--tile-compact-width: 186px;
|
||||||
--tile-content-width: 320px;
|
--tile-content-width: 320px;
|
||||||
--tile-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.24);
|
--tile-card-shadow: none;
|
||||||
|
|
||||||
--donut-rack-surface: var(--segment-surface);
|
--donut-rack-surface: var(--segment-surface);
|
||||||
--donut-card-surface: var(--tile-surface);
|
--donut-card-surface: var(--tile-surface);
|
||||||
|
|
@ -96,10 +95,10 @@
|
||||||
--datahub-readiness-dot-size: 14px;
|
--datahub-readiness-dot-size: 14px;
|
||||||
--datahub-readiness-dot-inset: var(--space-1);
|
--datahub-readiness-dot-inset: var(--space-1);
|
||||||
|
|
||||||
--fs-micro: var(--type-meta-size);
|
--fs-micro: var(--type-badge-size);
|
||||||
--fs-sm: var(--type-meta-size);
|
--fs-sm: var(--type-meta-size);
|
||||||
--fs-base: var(--type-label-size);
|
--fs-base: var(--type-button-size);
|
||||||
--fs-md: var(--type-label-size);
|
--fs-md: var(--type-table-body-size);
|
||||||
--fs-lg: var(--type-body-size);
|
--fs-lg: var(--type-body-size);
|
||||||
--fs-xl: var(--type-h3-size);
|
--fs-xl: var(--type-h3-size);
|
||||||
|
|
||||||
|
|
@ -265,7 +264,7 @@
|
||||||
--surface-selected-flat: #43434C;
|
--surface-selected-flat: #43434C;
|
||||||
--text-selected-flat: #E2DFE6;
|
--text-selected-flat: #E2DFE6;
|
||||||
--text-muted-selected-flat: #B7BAC0;
|
--text-muted-selected-flat: #B7BAC0;
|
||||||
--border-selected-flat: #7C5CF5;
|
--border-selected-flat: var(--marking-color);
|
||||||
--surface-tab-active: #323238; /* Grundfarbe Element */
|
--surface-tab-active: #323238; /* Grundfarbe Element */
|
||||||
--text-tab-active: #E2DFE6;
|
--text-tab-active: #E2DFE6;
|
||||||
--surface-results-action: #242426;
|
--surface-results-action: #242426;
|
||||||
|
|
@ -283,7 +282,7 @@
|
||||||
--graph-technique: #7E8D9C;
|
--graph-technique: #7E8D9C;
|
||||||
--graph-tactic: #668BB3;
|
--graph-tactic: #668BB3;
|
||||||
--graph-alert: #E45E63;
|
--graph-alert: #E45E63;
|
||||||
--graph-selected: #FFFFFF;
|
--graph-selected: var(--marking-color);
|
||||||
|
|
||||||
/* Rahmen: Weiche Konturen; dezenter heller Trennstrich fuer Listen */
|
/* Rahmen: Weiche Konturen; dezenter heller Trennstrich fuer Listen */
|
||||||
--border-subtle: rgba(255, 255, 255, 0.14); /* Zellen-, Zeilentrenner */
|
--border-subtle: rgba(255, 255, 255, 0.14); /* Zellen-, Zeilentrenner */
|
||||||
|
|
@ -300,6 +299,7 @@
|
||||||
--button-accent: #E9A63C;
|
--button-accent: #E9A63C;
|
||||||
--button-accent-hover: #F0B252;
|
--button-accent-hover: #F0B252;
|
||||||
--button-accent-text: #000000;
|
--button-accent-text: #000000;
|
||||||
|
--marking-color: #E9A63C;
|
||||||
|
|
||||||
/* Sidebar-Navigation (Ableton-Preferences-Muster).
|
/* Sidebar-Navigation (Ableton-Preferences-Muster).
|
||||||
Nicht gewaehlte Eintraege liegen erhoben ueber der Shell, der gewaehlte
|
Nicht gewaehlte Eintraege liegen erhoben ueber der Shell, der gewaehlte
|
||||||
|
|
@ -376,8 +376,8 @@
|
||||||
|
|
||||||
/* Interaktion */
|
/* Interaktion */
|
||||||
--focus-ring: #4CC2FF; /* eigenstaendig, kollidiert mit nichts */
|
--focus-ring: #4CC2FF; /* eigenstaendig, kollidiert mit nichts */
|
||||||
--selection-border: var(--accent);
|
--selection-border: var(--marking-color);
|
||||||
--selection-marker: var(--accent);
|
--selection-marker: var(--marking-color);
|
||||||
--search-highlight-bg: #FFE600;
|
--search-highlight-bg: #FFE600;
|
||||||
--search-highlight-text: #000000;
|
--search-highlight-text: #000000;
|
||||||
/* Markierung einer vom Bericht selbst ausgewerteten Belegzeile (z. B.
|
/* Markierung einer vom Bericht selbst ausgewerteten Belegzeile (z. B.
|
||||||
|
|
@ -415,13 +415,13 @@
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/* Donut-Standard: identische Geometrie, helle Referenzpalette. */
|
/* Donut-Standard: identische Geometrie, helle Referenzpalette. */
|
||||||
--segment-surface: #E7E9ED;
|
--segment-surface: transparent;
|
||||||
--segment-border: #B9BEC7;
|
--segment-border: transparent;
|
||||||
--tile-surface: #FFFFFF;
|
--tile-surface: #FFFFFF;
|
||||||
--tile-surface-hover: #F3F4F6;
|
--tile-surface-hover: #F3F4F6;
|
||||||
--tile-border: #B9BEC7;
|
--tile-border: transparent;
|
||||||
--tile-selected-border: #D99A14;
|
--tile-selected-border: var(--marking-color);
|
||||||
--tile-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86), 0 2px 7px rgba(23, 25, 29, 0.12);
|
--tile-card-shadow: none;
|
||||||
--donut-rack-surface: var(--segment-surface);
|
--donut-rack-surface: var(--segment-surface);
|
||||||
--donut-card-surface: var(--tile-surface);
|
--donut-card-surface: var(--tile-surface);
|
||||||
--donut-card-surface-end: var(--tile-surface);
|
--donut-card-surface-end: var(--tile-surface);
|
||||||
|
|
@ -450,7 +450,7 @@
|
||||||
--surface-selected-flat: #E4E4EC;
|
--surface-selected-flat: #E4E4EC;
|
||||||
--text-selected-flat: #17171C;
|
--text-selected-flat: #17171C;
|
||||||
--text-muted-selected-flat: #55555F;
|
--text-muted-selected-flat: #55555F;
|
||||||
--border-selected-flat: #7C3AED;
|
--border-selected-flat: var(--marking-color);
|
||||||
/* Aktive Ansicht (View-Tabs): neutrale erhobene Flaeche. Das Chrome bleibt
|
/* Aktive Ansicht (View-Tabs): neutrale erhobene Flaeche. Das Chrome bleibt
|
||||||
farblos, Farbe gehoert den Daten. */
|
farblos, Farbe gehoert den Daten. */
|
||||||
--surface-tab-active: #FFFFFF;
|
--surface-tab-active: #FFFFFF;
|
||||||
|
|
@ -472,7 +472,7 @@
|
||||||
--graph-technique: #71808E;
|
--graph-technique: #71808E;
|
||||||
--graph-tactic: #426C97;
|
--graph-tactic: #426C97;
|
||||||
--graph-alert: #C83E45;
|
--graph-alert: #C83E45;
|
||||||
--graph-selected: #111116;
|
--graph-selected: var(--marking-color);
|
||||||
|
|
||||||
--border-subtle: #E2E2E8;
|
--border-subtle: #E2E2E8;
|
||||||
--border-default: #CFCFD8;
|
--border-default: #CFCFD8;
|
||||||
|
|
@ -487,6 +487,7 @@
|
||||||
--button-accent: #E9A63C;
|
--button-accent: #E9A63C;
|
||||||
--button-accent-hover: #F0B252;
|
--button-accent-hover: #F0B252;
|
||||||
--button-accent-text: #000000;
|
--button-accent-text: #000000;
|
||||||
|
--marking-color: #E9A63C;
|
||||||
|
|
||||||
/* Sidebar-Navigation, siehe Dark-Skin. Gleiche Rollen, eigene Werte:
|
/* Sidebar-Navigation, siehe Dark-Skin. Gleiche Rollen, eigene Werte:
|
||||||
der gewaehlte Eintrag ist hier heller als die Shell, weil die
|
der gewaehlte Eintrag ist hier heller als die Shell, weil die
|
||||||
|
|
@ -543,8 +544,8 @@
|
||||||
--state-offline: #C60F3E;
|
--state-offline: #C60F3E;
|
||||||
|
|
||||||
--focus-ring: #0A66C2;
|
--focus-ring: #0A66C2;
|
||||||
--selection-border: var(--accent);
|
--selection-border: var(--marking-color);
|
||||||
--selection-marker: var(--accent);
|
--selection-marker: var(--marking-color);
|
||||||
--search-highlight-bg: #FFE600;
|
--search-highlight-bg: #FFE600;
|
||||||
--search-highlight-text: #000000;
|
--search-highlight-text: #000000;
|
||||||
--evidence-highlight-bg: #CBF8FF;
|
--evidence-highlight-bg: #CBF8FF;
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ describe('binding ASSESSOR typography system', () => {
|
||||||
expect(tokens).toContain('--type-h3-size: 18px;');
|
expect(tokens).toContain('--type-h3-size: 18px;');
|
||||||
expect(tokens).toContain('--type-h4-size: 16px;');
|
expect(tokens).toContain('--type-h4-size: 16px;');
|
||||||
expect(tokens).toContain('--type-body-size: 16px;');
|
expect(tokens).toContain('--type-body-size: 16px;');
|
||||||
expect(tokens).toContain('--type-label-size: 14px;');
|
expect(tokens).toContain('--type-table-body-size: 14px;');
|
||||||
expect(tokens).toContain('--type-meta-size: 13px;');
|
expect(tokens).toContain('--type-label-size: 12px;');
|
||||||
expect(tokens).toContain('--type-badge-size: 12px;');
|
expect(tokens).toContain('--type-meta-size: 12px;');
|
||||||
expect(tokens).toContain('--type-table-head-size: 13px;');
|
expect(tokens).toContain('--type-badge-size: 11px;');
|
||||||
expect(tokens).toContain('--type-button-size: 14px;');
|
expect(tokens).toContain('--type-table-head-size: 12px;');
|
||||||
|
expect(tokens).toContain('--type-button-size: 13px;');
|
||||||
expect(tokens).toContain('--fw-regular: 400;');
|
expect(tokens).toContain('--fw-regular: 400;');
|
||||||
expect(tokens).toContain('--fw-semibold: 600;');
|
expect(tokens).toContain('--fw-semibold: 600;');
|
||||||
expect(tokens).toContain('--fw-bold: 700;');
|
expect(tokens).toContain('--fw-bold: 700;');
|
||||||
|
|
@ -29,7 +30,7 @@ describe('binding ASSESSOR typography system', () => {
|
||||||
|
|
||||||
it('uses title case for semantic headings and labels while reserving uppercase for table heads', () => {
|
it('uses title case for semantic headings and labels while reserving uppercase for table heads', () => {
|
||||||
expect(base).toMatch(/h1\s*\{[^}]*font-size:\s*var\(--type-h1-size\);[^}]*font-weight:\s*var\(--fw-semibold\);/s);
|
expect(base).toMatch(/h1\s*\{[^}]*font-size:\s*var\(--type-h1-size\);[^}]*font-weight:\s*var\(--fw-semibold\);/s);
|
||||||
expect(base).toMatch(/td\s*\{[^}]*font-size:\s*var\(--type-body-size\);/s);
|
expect(base).toMatch(/td\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
||||||
expect(base).toMatch(/th\s*\{[^}]*font-size:\s*var\(--type-table-head-size\);[^}]*text-transform:\s*uppercase;/s);
|
expect(base).toMatch(/th\s*\{[^}]*font-size:\s*var\(--type-table-head-size\);[^}]*text-transform:\s*uppercase;/s);
|
||||||
expect(datahub).toMatch(/\.tenant-timeslots-heading\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*text-transform:\s*none;/s);
|
expect(datahub).toMatch(/\.tenant-timeslots-heading\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*text-transform:\s*none;/s);
|
||||||
expect(datahub).toMatch(/\.tenant-information-title > strong\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*text-transform:\s*none;/s);
|
expect(datahub).toMatch(/\.tenant-information-title > strong\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*text-transform:\s*none;/s);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ describe('DonutMetricCard binding design contract', () => {
|
||||||
expect(styles).toMatch(/\.donut-metric-card__primary\s*\{[^}]*position:\s*relative;/s);
|
expect(styles).toMatch(/\.donut-metric-card__primary\s*\{[^}]*position:\s*relative;/s);
|
||||||
expect(styles).not.toContain('stroke-linecap: round;');
|
expect(styles).not.toContain('stroke-linecap: round;');
|
||||||
expect(styles).toContain('@keyframes donut-progress-build');
|
expect(styles).toContain('@keyframes donut-progress-build');
|
||||||
expect(styles).toContain('@keyframes donut-accent-build');
|
expect(styles).not.toContain('.donut-metric-card::before');
|
||||||
|
expect(styles).not.toContain('@keyframes donut-accent-build');
|
||||||
expect(styles).toContain('animation: donut-progress-build var(--dur-data-build) var(--ease-data-build) both;');
|
expect(styles).toContain('animation: donut-progress-build var(--dur-data-build) var(--ease-data-build) both;');
|
||||||
expect(styles).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s);
|
expect(styles).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s);
|
||||||
expect(tileContract).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s);
|
expect(tileContract).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s);
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,78 @@
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { FindingRecord } from '../../src/domain/finding';
|
import type { FindingRecord } from '../../src/domain/finding';
|
||||||
import { dashboardExplicitIoeCount, dashboardExplicitIoeRecordIds, dashboardPingCastleRiskCategories, dashboardRiskTools, dashboardScanSourceLabel } from '../../src/features/dashboard/DashboardPanel';
|
import type { EnvironmentSnapshot } from '../../src/domain/environment-context';
|
||||||
|
import { dashboardCisCatSeverityRemainder, dashboardEnvironmentSummary, dashboardExplicitIoeCount, dashboardExplicitIoeRecordIds, dashboardPingCastleRiskCategories, dashboardRiskTools, dashboardScanSourceLabel } from '../../src/features/dashboard/DashboardPanel';
|
||||||
|
|
||||||
const component = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
const component = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
||||||
const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
|
const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
|
||||||
|
|
||||||
describe('dashboard reference layout', () => {
|
describe('dashboard reference layout', () => {
|
||||||
it('uses the reference metric strip for compliance, primary statuses and the compact summary', () => {
|
it('uses exactly six compact square metrics with the existing timeslot context', () => {
|
||||||
expect(component).toContain('className="dashboard-status-rack"');
|
expect(component).toContain('className="client-info-tile-grid"');
|
||||||
expect(component).toContain('label="Compliance Score"');
|
expect(component).toContain('className="client-info-metric-grid"');
|
||||||
expect(component).toContain('<DashboardStatusMetric label="Deviation"');
|
expect(component).toContain('<WorkTimeslotControl />');
|
||||||
expect(component).toContain('<DashboardStatusMetric label="Passed"');
|
expect(styles).toMatch(/\.dashboard-page\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||||
expect(component).toContain('<DashboardStatusMetric label="Not Run"');
|
expect(styles).toContain('.dashboard-context .work-timeslot-select { margin-left: auto; }');
|
||||||
expect(component).toContain('className="dashboard-status-summary"');
|
expect(component.match(/<DashboardMetricTile/g)).toHaveLength(6);
|
||||||
expect(component).toContain('<span>Not Applicable</span>');
|
expect(component).toContain('<DashboardMetricTile label="Compliance Score"');
|
||||||
expect(component).toContain('<span>Error</span>');
|
expect(component).toContain('<DashboardMetricTile label="Deviation"');
|
||||||
expect(component).toContain('className="dashboard-ioe-summary" type="button"');
|
expect(component).toContain('<DashboardMetricTile label="Passed"');
|
||||||
|
expect(component).toContain('<DashboardMetricTile label="High Severity"');
|
||||||
|
expect(component).toContain('<DashboardMetricTile label="Typed Observations"');
|
||||||
|
expect(component).toContain('<DashboardMetricTile label="Directory Sections"');
|
||||||
expect(component).toContain('setPathFindingIds(explicitIoeRecordIds)');
|
expect(component).toContain('setPathFindingIds(explicitIoeRecordIds)');
|
||||||
expect(styles).toMatch(/\.dashboard-status-rack\s*\{[^}]*grid-template-columns:\s*290px repeat\(3, 210px\) 250px;/s);
|
expect(styles).toMatch(/\.client-info-tile-grid\s*\{[^}]*grid-template-columns:\s*repeat\(var\(--client-info-columns\), minmax\(0, 1fr\)\);[^}]*grid-auto-flow:\s*row dense;/s);
|
||||||
expect(styles).toMatch(/\.dashboard-status-copy > strong\s*\{[^}]*display:\s*grid;[^}]*white-space:\s*normal;/s);
|
expect(styles).toMatch(/\.client-info-metric-grid\s*\{[^}]*display:\s*contents;/s);
|
||||||
expect(styles).toMatch(/\.dashboard-status-copy > strong small, \.dashboard-status-copy > small\s*\{[^}]*display:\s*block;/s);
|
expect(styles).toMatch(/\.client-info-metric\s*\{[^}]*aspect-ratio:\s*1;[^}]*min-height:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
||||||
expect(styles).toContain('grid-template-columns: repeat(2, minmax(0, 290px));');
|
|
||||||
expect(component).toContain("{value.toLocaleString('en-US')}<small>{roundedPercentage}%</small>");
|
|
||||||
expect(styles).toContain('.dashboard-status-ring-track { stroke: var(--donut-track); }');
|
|
||||||
expect(styles).toContain('button.dashboard-status-metric:hover { border-color: currentColor; background: var(--tile-surface-hover); }');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses paired segmented distributions and a compact lower dashboard row', () => {
|
it('uses exactly nine square information cards in the same dense grid', () => {
|
||||||
expect(component).toContain('heading="Severity distribution"');
|
expect(component).toContain('className="client-info-card-grid"');
|
||||||
expect(component).toContain('heading="Scan source distribution"');
|
expect(component.match(/<DashboardLargeCard/g)).toHaveLength(9);
|
||||||
expect(component).toContain('className="dashboard-lower-grid"');
|
['Assessment Status', 'Severity Distribution', 'Directory Overview', 'Directory Coverage', 'Identity & Privilege', 'Control Paths & Trusts', 'Certificates & PKI', 'GPO & Password Hygiene', 'Infrastructure & Resilience']
|
||||||
expect(styles).toMatch(/\.dashboard-distribution-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/s);
|
.forEach(title => expect(component).toContain(`title="${title}"`));
|
||||||
expect(styles).toContain('.dashboard-segmented-bar');
|
expect(styles).toMatch(/\.client-info-card-grid\s*\{[^}]*display:\s*contents;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-card\s*\{[^}]*grid-column:\s*span 2;[^}]*grid-row:\s*span 2;[^}]*aspect-ratio:\s*1;[^}]*min-height:\s*0;[^}]*border:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*none;/s);
|
||||||
|
expect(styles).toMatch(/\.client-info-card > header\s*\{[^}]*padding-bottom:\s*0;[^}]*border-bottom:\s*0;/s);
|
||||||
|
expect(component).not.toContain('className="dashboard-status-rack"');
|
||||||
|
expect(component).not.toContain('className="dashboard-lower-grid"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches the reference structure for directory coverage and risk profile', () => {
|
it('projects normalized scanner context into reusable fact, bar and donut displays', () => {
|
||||||
expect(component).toContain('className="dashboard-coverage-summary"');
|
expect(component).toContain('dashboardEnvironmentSummary(environment.snapshot)');
|
||||||
expect(component).toContain('<small>typed observations</small>');
|
expect(component).toContain('className="client-info-donut"');
|
||||||
expect(component).toContain('className="dashboard-block dashboard-risk-profile"');
|
expect(component).toContain('className="client-info-bars"');
|
||||||
expect(component).toContain('className="dashboard-marked-summary"');
|
expect(component).toContain('className="client-info-facts"');
|
||||||
expect(component).toContain('className="dashboard-risk-tool"');
|
expect(component).toContain("sectionObservationCount(snapshot, 'sectionAdminGroups')");
|
||||||
expect(component).toContain('<option value={fullRiskProfileId}>Full Risk Profile</option>');
|
expect(component).toContain("observationNumber(snapshot, 'risk.anomalies', 'Anomalies score')");
|
||||||
expect(component).toContain('className="dashboard-risk-donut"');
|
expect(styles).toContain('.client-info-donut-track { stroke: var(--donut-track); }');
|
||||||
expect(component).toContain('<span className="dashboard-donut-primary is-compact"><strong>100</strong><small>%</small></span>');
|
expect(styles).not.toMatch(/\.client-info-card\s*\{[^}]*border-left:/s);
|
||||||
expect(styles).toContain('.dashboard-risk-donut-track { stroke: var(--donut-track); }');
|
expect(styles).not.toMatch(/\.client-info-card\s*\{[^}]*overflow:\s*hidden;/s);
|
||||||
expect(styles).toMatch(/\.dashboard-donut-primary\s*\{[^}]*position:\s*relative;/s);
|
});
|
||||||
expect(styles).toMatch(/\.dashboard-status-ring > span small\s*\{[^}]*left:\s*calc\(100% \+ var\(--space-1\)\);/s);
|
|
||||||
expect(component).toContain('categories.slice(0, 4)');
|
it('derives card values only from normalized environment snapshot contracts', () => {
|
||||||
expect(component).toContain('matchesDynamicFacets(record, [sourceFacet]');
|
const observation = (property: string, label: string, value: string | number | boolean, sectionKey?: string) => ({
|
||||||
expect(component).not.toContain('dashboard-review-block');
|
version: 1 as const, id: `${property}-${label}`, scanRunId: 'run-1', importFileId: 'file-1',
|
||||||
expect(styles).toMatch(/\.dashboard-facts\s*\{[^}]*grid-template-columns:\s*repeat\(4,/s);
|
tool: { id: 'synthetic', name: 'Synthetic', version: '1' }, subject: { type: 'directory' },
|
||||||
expect(styles).toMatch(/\.dashboard-risk-body\s*\{[^}]*grid-template-columns:/s);
|
property, label, originalValue: value, normalizedValue: value, valueType: typeof value === 'number' ? 'number' as const : typeof value === 'boolean' ? 'boolean' as const : 'string' as const,
|
||||||
|
state: 'observed' as const, sectionKey, source: { rowIndex: 0 }, mappingVersion: '1', attributes: {}
|
||||||
|
});
|
||||||
|
const snapshot = {
|
||||||
|
version: 1, projectId: 'project-1', scanRunIds: ['run-1'], generatedAt: '2026-08-25T00:00:00.000Z',
|
||||||
|
coverage: { observed: 4, explicitlyAbsent: 1, empty: 2, restricted: 3, incomplete: 0 },
|
||||||
|
sections: [{ version: 1, id: 'section-1', scanRunId: 'run-1', importFileId: 'file-1', tool: { id: 'synthetic', name: 'Synthetic', version: '1' }, sectionKey: 'sectionAdminGroups', label: 'Groups', ordinal: 1, state: 'license-restricted', tables: [], source: { rowIndex: 0 }, extractorVersion: '1', attributes: {} }],
|
||||||
|
observations: [
|
||||||
|
observation('domain.fqdn', 'Domain', 'example.local'),
|
||||||
|
observation('risk.anomalies', 'Anomalies score', 42),
|
||||||
|
observation('admin.name', 'Name', 'Admin A', 'sectionAdminGroups')
|
||||||
|
]
|
||||||
|
} satisfies EnvironmentSnapshot;
|
||||||
|
const summary = dashboardEnvironmentSummary(snapshot);
|
||||||
|
expect(summary.directoryFacts[0]).toEqual({ label: 'Domain', value: 'example.local' });
|
||||||
|
expect(summary.coverage.map(item => item.value)).toEqual([4, 2, 1, 3, 0]);
|
||||||
|
expect(summary.identity[0]).toMatchObject({ label: 'Admin groups', value: 1, restricted: true });
|
||||||
|
expect(summary.risk[1]).toMatchObject({ label: 'Anomalies', value: 42 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes only a trailing scan timestamp from source labels', () => {
|
it('removes only a trailing scan timestamp from source labels', () => {
|
||||||
|
|
@ -71,6 +92,22 @@ describe('dashboard reference layout', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adds only the severity-less CIS-CAT share as a neutral distribution segment', () => {
|
||||||
|
expect(dashboardCisCatSeverityRemainder([
|
||||||
|
{ value: 'cis-cat-run', label: 'CIS-CAT Pro · 2026-08-17', count: 411 },
|
||||||
|
{ value: 'maester-run', label: 'Maester · 2026-07-28', count: 318 }
|
||||||
|
], [])).toEqual({
|
||||||
|
value: 'dashboard-cis-cat-without-severity',
|
||||||
|
label: 'CIS-CAT',
|
||||||
|
count: 411,
|
||||||
|
sourceValues: ['cis-cat-run']
|
||||||
|
});
|
||||||
|
expect(dashboardCisCatSeverityRemainder([
|
||||||
|
{ value: 'cis-cat-run', label: 'CIS-CAT Pro · 2026-08-17', count: 411 }
|
||||||
|
], [{ value: 'high', label: 'High', count: 12 }])?.count).toBe(399);
|
||||||
|
expect(styles).toContain('.severity-dashboard-cis-cat-without-severity { --severity: var(--donut-not-run); }');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the complete PingCastle risk catalog including zero scores', () => {
|
it('keeps the complete PingCastle risk catalog including zero scores', () => {
|
||||||
expect(dashboardPingCastleRiskCategories([
|
expect(dashboardPingCastleRiskCategories([
|
||||||
{ property: 'risk.anomalies', normalizedValue: 20, originalValue: '20' },
|
{ property: 'risk.anomalies', normalizedValue: 20, originalValue: '20' },
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ describe('Datahub centered reference rack layout', () => {
|
||||||
expect(datahubStyles).toContain('outline: var(--bw-emphasis) solid var(--tile-selected-border);');
|
expect(datahubStyles).toContain('outline: var(--bw-emphasis) solid var(--tile-selected-border);');
|
||||||
expect(datahubStyles).toContain('.datahub-tenant-card::before { content: none; }');
|
expect(datahubStyles).toContain('.datahub-tenant-card::before { content: none; }');
|
||||||
expect(datahubStyles).toContain('transition: background .12s ease, border-color .12s ease;');
|
expect(datahubStyles).toContain('transition: background .12s ease, border-color .12s ease;');
|
||||||
expect(datahubStyles).toMatch(/\.datahub-tenant-card:hover,\s*\.datahub-tenant-card:focus-within\s*\{[^}]*border-color:\s*var\(--line-strong\);[^}]*background:\s*var\(--tile-surface-hover\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
expect(datahubStyles).toMatch(/\.datahub-tenant-card:hover,\s*\.datahub-tenant-card:focus-within\s*\{[^}]*border-color:\s*transparent;[^}]*background:\s*var\(--tile-surface-hover\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
||||||
expect(datahubStyles).not.toMatch(/\.datahub-tenant-card\s*\{[^}]*box-shadow/s);
|
expect(datahubStyles).not.toMatch(/\.datahub-tenant-card\s*\{[^}]*box-shadow/s);
|
||||||
expect(datahubStyles).toContain('box-shadow: var(--tile-card-shadow);');
|
expect(datahubStyles).toContain('box-shadow: var(--tile-card-shadow);');
|
||||||
expect(datahubStyles).not.toContain('translateY(-2px)');
|
expect(datahubStyles).not.toContain('translateY(-2px)');
|
||||||
|
|
@ -50,7 +50,7 @@ describe('Datahub centered reference rack layout', () => {
|
||||||
expect(componentStyles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr));');
|
expect(componentStyles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr));');
|
||||||
expect(tileContractStyles).toMatch(/@media \(min-width: 1101px\)\s*\{\s*\.datahub-reference-controls\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s);
|
expect(tileContractStyles).toMatch(/@media \(min-width: 1101px\)\s*\{\s*\.datahub-reference-controls\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s);
|
||||||
expect(componentStyles).toContain('padding: var(--segment-inset);');
|
expect(componentStyles).toContain('padding: var(--segment-inset);');
|
||||||
expect(componentStyles).toContain('.donut-metric-card::before');
|
expect(componentStyles).not.toContain('.donut-metric-card::before');
|
||||||
expect(componentStyles).toContain('border-radius: var(--tile-radius);');
|
expect(componentStyles).toContain('border-radius: var(--tile-radius);');
|
||||||
expect(componentStyles).toContain('background: var(--tile-surface);');
|
expect(componentStyles).toContain('background: var(--tile-surface);');
|
||||||
expect(componentStyles).toContain('.donut-metric-card__progress');
|
expect(componentStyles).toContain('.donut-metric-card__progress');
|
||||||
|
|
@ -71,7 +71,8 @@ describe('Datahub centered reference rack layout', () => {
|
||||||
expect(tokens).toContain('--datahub-readiness-dot-size: 14px;');
|
expect(tokens).toContain('--datahub-readiness-dot-size: 14px;');
|
||||||
expect(tokens).toContain('--datahub-readiness-dot-inset: var(--space-1);');
|
expect(tokens).toContain('--datahub-readiness-dot-inset: var(--space-1);');
|
||||||
expect(tokens).toContain('Donut-Standard: identische Geometrie, helle Referenzpalette.');
|
expect(tokens).toContain('Donut-Standard: identische Geometrie, helle Referenzpalette.');
|
||||||
expect(tokens).toContain('--segment-surface: #E7E9ED;');
|
expect(tokens.match(/--segment-surface: transparent;/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--segment-border: transparent;/g)).toHaveLength(2);
|
||||||
expect(tokens).toContain('--donut-rack-surface: var(--segment-surface);');
|
expect(tokens).toContain('--donut-rack-surface: var(--segment-surface);');
|
||||||
expect(tokens).toContain('--donut-deviation: #9A6500;');
|
expect(tokens).toContain('--donut-deviation: #9A6500;');
|
||||||
expect(donut).toContain('<circle className="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100" />');
|
expect(donut).toContain('<circle className="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100" />');
|
||||||
|
|
@ -80,7 +81,7 @@ describe('Datahub centered reference rack layout', () => {
|
||||||
expect(donut).toContain('key={label + \'-\' + value + \'-\' + boundedPercentage}');
|
expect(donut).toContain('key={label + \'-\' + value + \'-\' + boundedPercentage}');
|
||||||
expect(donut).not.toContain('<animate');
|
expect(donut).not.toContain('<animate');
|
||||||
expect(componentStyles).toContain('@keyframes donut-progress-build');
|
expect(componentStyles).toContain('@keyframes donut-progress-build');
|
||||||
expect(componentStyles).toContain('@keyframes donut-accent-build');
|
expect(componentStyles).not.toContain('@keyframes donut-accent-build');
|
||||||
|
|
||||||
expect(overview).toContain('datahub-reference-controls donut-metric-rack');
|
expect(overview).toContain('datahub-reference-controls donut-metric-rack');
|
||||||
expect(overview).toContain('DonutMetricCard className="datahub-reference-knob" label="Results All" value={resultMetrics.all} percentage={resultMetrics.all > 0 ? 100 : 0} tone="all"');
|
expect(overview).toContain('DonutMetricCard className="datahub-reference-knob" label="Results All" value={resultMetrics.all} percentage={resultMetrics.all > 0 ? 100 : 0} tone="all"');
|
||||||
|
|
@ -108,6 +109,7 @@ describe('Datahub centered reference rack layout', () => {
|
||||||
expect(datahub).toContain('New ASSESSOR processing profile available');
|
expect(datahub).toContain('New ASSESSOR processing profile available');
|
||||||
expect(datahub).toContain('ASSESSOR has updated its internal scanner-processing profile.');
|
expect(datahub).toContain('ASSESSOR has updated its internal scanner-processing profile.');
|
||||||
expect(datahub).toContain('replacesScanRunId={report.scanRunId}');
|
expect(datahub).toContain('replacesScanRunId={report.scanRunId}');
|
||||||
|
expect(datahub).toContain('targetAssessmentId={report.assessmentIds[0]}');
|
||||||
expect(datahub).toContain('Use imported {report.tool.name}');
|
expect(datahub).toContain('Use imported {report.tool.name}');
|
||||||
expect(datahub).toContain("compareReimportSourceHashes(affected.files.map(file => file.sha256), report.files.map(file => file.sha256)) === 'match'");
|
expect(datahub).toContain("compareReimportSourceHashes(affected.files.map(file => file.sha256), report.files.map(file => file.sha256)) === 'match'");
|
||||||
expect(datahub).toContain('adoptScanRunReplacement');
|
expect(datahub).toContain('adoptScanRunReplacement');
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ describe('Mandant detail reference', () => {
|
||||||
expect(styles).toMatch(/\.tenant-detail-page > \.tenant-detail-layout\s*\{[^}]*grid-template-columns:\s*clamp\(420px, 27vw, 520px\) minmax\(0, 1fr\);/s);
|
expect(styles).toMatch(/\.tenant-detail-page > \.tenant-detail-layout\s*\{[^}]*grid-template-columns:\s*clamp\(420px, 27vw, 520px\) minmax\(0, 1fr\);/s);
|
||||||
expect(styles).toMatch(/\.tenant-information-sheet\s*\{[^}]*width:\s*min\(794px, 100%\);[^}]*background:\s*transparent;/s);
|
expect(styles).toMatch(/\.tenant-information-sheet\s*\{[^}]*width:\s*min\(794px, 100%\);[^}]*background:\s*transparent;/s);
|
||||||
expect(styles).toMatch(/\.tenant-timeslot-list\s*\{[^}]*overflow-y:\s*auto;/s);
|
expect(styles).toMatch(/\.tenant-timeslot-list\s*\{[^}]*overflow-y:\s*auto;/s);
|
||||||
expect(styles).toMatch(/\.tenant-scan-history\s*\{[^}]*border:\s*0;[^}]*background:\s*var\(--surface-panel-raised\);/s);
|
expect(styles).toMatch(/\.tenant-timeslot-list\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-3\);[^}]*overflow-y:\s*auto;/s);
|
||||||
|
expect(styles).toMatch(/\.tenant-scan-history\s*\{[^}]*border:\s*var\(--bw-hairline\) solid var\(--border-default\);[^}]*background:\s*var\(--surface-panel-raised\);/s);
|
||||||
expect(styles).toMatch(/\.tenant-detail-information\s*\{[^}]*overflow-y:\s*auto;/s);
|
expect(styles).toMatch(/\.tenant-detail-information\s*\{[^}]*overflow-y:\s*auto;/s);
|
||||||
expect(styles).toMatch(/\.tenant-timeslot\.is-selected\s*\{[^}]*var\(--marker-width\)[^}]*var\(--state-active\)/s);
|
expect(styles).toMatch(/\.tenant-timeslot\.is-selected\s*\{[^}]*var\(--marker-width\)[^}]*var\(--state-active\)/s);
|
||||||
});
|
});
|
||||||
|
|
@ -42,6 +43,7 @@ describe('Mandant detail reference', () => {
|
||||||
expect(dataHub).toContain('label="Import Scan"');
|
expect(dataHub).toContain('label="Import Scan"');
|
||||||
expect(dataHub).toContain('label="Import GPO Backup Folder (Zip)"');
|
expect(dataHub).toContain('label="Import GPO Backup Folder (Zip)"');
|
||||||
expect(dataHub).toContain('targetAssessmentId={group.assessmentIds[0]}');
|
expect(dataHub).toContain('targetAssessmentId={group.assessmentIds[0]}');
|
||||||
|
expect(dataHub).toContain('targetAssessmentId={report.assessmentIds[0]}');
|
||||||
expect(dataHub).toContain('assessmentId={group.assessmentIds[0]}');
|
expect(dataHub).toContain('assessmentId={group.assessmentIds[0]}');
|
||||||
expect(dataHub).toContain('showStatus={false}');
|
expect(dataHub).toContain('showStatus={false}');
|
||||||
expect(gpoImport).toContain('<button className="btn primary"');
|
expect(gpoImport).toContain('<button className="btn primary"');
|
||||||
|
|
@ -73,6 +75,10 @@ describe('Mandant detail reference', () => {
|
||||||
expect(dataHub).toContain('const [excludedScanRunIds, setExcludedScanRunIds]');
|
expect(dataHub).toContain('const [excludedScanRunIds, setExcludedScanRunIds]');
|
||||||
expect(dataHub).toMatch(/const openEdit = \(project: AssessmentProject\) => \{[\s\S]*?setSelectedTimeslotIds\(latestTimeslot \? \[latestTimeslot\.id\] : \[\]\);[\s\S]*?setExpandedTimeslotIds\(affectedTimeslotIds\);/);
|
expect(dataHub).toMatch(/const openEdit = \(project: AssessmentProject\) => \{[\s\S]*?setSelectedTimeslotIds\(latestTimeslot \? \[latestTimeslot\.id\] : \[\]\);[\s\S]*?setExpandedTimeslotIds\(affectedTimeslotIds\);/);
|
||||||
expect(dataHub).toContain('const toggleScanReport =');
|
expect(dataHub).toContain('const toggleScanReport =');
|
||||||
|
expect(dataHub).toContain('<label className="tenant-timeslot-title" onClick={event => event.stopPropagation()}>');
|
||||||
|
expect(dataHub).not.toMatch(/const toggleTimeslot[\s\S]*?setExpandedTimeslotIds[\s\S]*?const toggleScanReport/);
|
||||||
|
expect(styles).toMatch(/\.tenant-timeslot-title\s*\{[^}]*flex:\s*0 0 calc\(var\(--space-8\) \* 4\.25\);/s);
|
||||||
|
expect(styles).toMatch(/\.tenant-timeslot > \.tenant-section-bar > \.tenant-history-meta\s*\{[^}]*flex:\s*1 1 auto;[^}]*cursor:\s*pointer;/s);
|
||||||
expect(dataHub).toContain('aria-label={`Select ${report.tool.name} scan report`}');
|
expect(dataHub).toContain('aria-label={`Select ${report.tool.name} scan report`}');
|
||||||
expect(dataHub).toContain('selectedTimeslots[0].scanRunIds.filter');
|
expect(dataHub).toContain('selectedTimeslots[0].scanRunIds.filter');
|
||||||
expect(dataHub).toContain('runIds: group.scanRunIds.filter');
|
expect(dataHub).toContain('runIds: group.scanRunIds.filter');
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,20 @@ describe('Datahub topbar context', () => {
|
||||||
expect(app).toContain('const handleDataHubDetailViewChanged = useCallback((isDetailView: boolean, clientName?: string) =>');
|
expect(app).toContain('const handleDataHubDetailViewChanged = useCallback((isDetailView: boolean, clientName?: string) =>');
|
||||||
expect(app.match(/className="app-header-client-name"/g)).toHaveLength(1);
|
expect(app.match(/className="app-header-client-name"/g)).toHaveLength(1);
|
||||||
expect(app).toContain('const headerContext = <div className="app-header-context">');
|
expect(app).toContain('const headerContext = <div className="app-header-context">');
|
||||||
|
expect(app).toContain("headerClientName && activeArea !== 'dashboard'");
|
||||||
expect(app).toContain('<div className="work-header-row">');
|
expect(app).toContain('<div className="work-header-row">');
|
||||||
expect(layout).toMatch(/\.app-header-panel:not\(\.app-header-panel-work\) \.app-header-context\s*\{[^}]*position:\s*absolute;[^}]*top:\s*3px;[^}]*right:\s*0;/s);
|
expect(layout).toMatch(/\.app-header-panel:not\(\.app-header-panel-work\) \.app-header-context\s*\{[^}]*position:\s*absolute;[^}]*top:\s*3px;[^}]*right:\s*0;/s);
|
||||||
expect(layout).toMatch(/\.app-header-client-name\s*\{[^}]*margin-right:\s*16px;[^}]*font-size:\s*var\(--fs-xl\);[^}]*text-align:\s*right;[^}]*white-space:\s*nowrap;/s);
|
expect(layout).toMatch(/\.app-header-client-name\s*\{[^}]*margin-right:\s*16px;[^}]*font-size:\s*var\(--fs-xl\);[^}]*text-align:\s*right;[^}]*white-space:\s*nowrap;/s);
|
||||||
const clientNameRule = layout.match(/\.app-header-client-name\s*\{([^}]*)\}/)?.[1] ?? '';
|
const clientNameRule = layout.match(/\.app-header-client-name\s*\{([^}]*)\}/)?.[1] ?? '';
|
||||||
expect(clientNameRule).not.toMatch(/(?:border|background)\s*:/);
|
expect(clientNameRule).not.toMatch(/(?:border|background)\s*:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the dashboard client beside its title with the active triangle marker', () => {
|
||||||
|
expect(app).toContain("const dashboardClientContext = activeArea === 'dashboard' && headerClientName");
|
||||||
|
expect(app).toContain('<span className="area-client-marker"><TriangleIcon direction="right" /></span>');
|
||||||
|
expect(app).toContain('<span className="area-client-name" title={headerClientName}>{headerClientName}</span>');
|
||||||
|
expect(app).toContain('{dashboardClientContext}{activeArea === \'graph\' && <PathfinderFilterTrail />}');
|
||||||
|
expect(layout).toMatch(/\.area-client-marker\s*\{[^}]*color:\s*var\(--state-active\);/s);
|
||||||
|
expect(layout).toMatch(/\.area-client-name\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*white-space:\s*nowrap;/s);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,45 @@ describe('selectFindingFields', () => {
|
||||||
expect(html).toContain('<div class="finding-badges"><span class="outcome-mark" data-outcome="fail">Failed</span>');
|
expect(html).toContain('<div class="finding-badges"><span class="outcome-mark" data-outcome="fail">Failed</span>');
|
||||||
expect(html).not.toContain('data-outcome="unknown">SI000067</span>');
|
expect(html).not.toContain('data-outcome="unknown">SI000067</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ordnet auch die alte PurpleKnight-Schreibweise Short Name den Zusatzinformationen zu', () => {
|
||||||
|
const legacyShortName: FindingRecord = {
|
||||||
|
...record,
|
||||||
|
fields: record.fields.map(field => field.label === 'ShortName' ? { ...field, label: 'Short Name' } : field)
|
||||||
|
};
|
||||||
|
const selected = selectFindingFields(legacyShortName, legacyShortName.title, false, 'purple-knight');
|
||||||
|
|
||||||
|
expect(selected.summary.map(field => field.label)).not.toContain('Short Name');
|
||||||
|
expect(selected.remainingFields.map(field => field.label)).toContain('Short Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verschiebt alte PurpleKnight-HTML-Details in Results und loest Framework-JSON auf', () => {
|
||||||
|
const legacy: FindingRecord = {
|
||||||
|
...record,
|
||||||
|
id: 'purple-knight-legacy-html',
|
||||||
|
fields: [
|
||||||
|
{ index: 0, label: 'Name', value: 'Synthetic legacy indicator' },
|
||||||
|
{ index: 1, label: 'Status', value: 'IOE Found' },
|
||||||
|
{ index: 2, label: 'ResultDetail', value: '[["Object: Synthetic","Ignored: False"]]' },
|
||||||
|
{ index: 3, label: 'Framework', value: JSON.stringify([
|
||||||
|
{ Name: 'MITRE ATT&CK', Tags: ['Discovery'] },
|
||||||
|
{ Name: 'MITRE D3FEND', Tags: ['Harden'] },
|
||||||
|
{ Name: 'ANSSI', Tags: ['vuln_synthetic'] }
|
||||||
|
]) },
|
||||||
|
{ index: 4, label: 'Grade', value: 'F' },
|
||||||
|
{ index: 5, label: 'Exposure Type', value: 'IOE' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const selected = selectFindingFields(legacy, legacy.title, true, 'purple-knight');
|
||||||
|
|
||||||
|
expect(selected.mitre.map(field => [field.label, field.value])).toEqual([
|
||||||
|
['MITRE ATT&CK_1', 'Discovery'], ['MITRE D3FEND_1', 'Harden']
|
||||||
|
]);
|
||||||
|
expect(selected.remainingFields.map(field => [field.label, field.value])).toContainEqual(['ANSSI_1', 'vuln_synthetic']);
|
||||||
|
expect([...selected.summary, ...selected.remainingFields].map(field => field.label)).not.toEqual(
|
||||||
|
expect.arrayContaining(['ResultDetail', 'Framework', 'Grade', 'Exposure Type'])
|
||||||
|
);
|
||||||
|
});
|
||||||
it('shows PingCastle rule points and report scores as badges while leaving score formulas readable', () => {
|
it('shows PingCastle rule points and report scores as badges while leaving score formulas readable', () => {
|
||||||
const scoringRecord: FindingRecord = {
|
const scoringRecord: FindingRecord = {
|
||||||
...record,
|
...record,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ describe('Finding title typography', () => {
|
||||||
it('uses the readable 16px content-title role in list and matrix cards', () => {
|
it('uses the readable 16px content-title role in list and matrix cards', () => {
|
||||||
expect(components).toMatch(/\.record-list \.finding-card h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
expect(components).toMatch(/\.record-list \.finding-card h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
expect(components).toMatch(/\.record-list \.finding-card h3\s*\{[^}]*font-weight:\s*var\(--fw-content-medium\);/s);
|
expect(components).toMatch(/\.record-list \.finding-card h3\s*\{[^}]*font-weight:\s*var\(--fw-content-medium\);/s);
|
||||||
|
expect(legacyViews).toMatch(/\.record-title\s*\{[^}]*font-size:\s*var\(--type-h4-size\);[^}]*font-weight:\s*var\(--fw-regular\);[^}]*line-height:\s*1\.28;/s);
|
||||||
|
expect(legacyViews).toMatch(/\.record-card\s*\{[^}]*grid-template-columns:\s*28px minmax\(0, 1fr\);[^}]*column-gap:\s*var\(--space-2\);/s);
|
||||||
expect(components).toMatch(/\.finding-card h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
expect(components).toMatch(/\.finding-card h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
expect(legacyViews).toMatch(/\.data-tile-content h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
expect(legacyViews).toMatch(/\.data-tile-content h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,14 @@ const legacyViews = readFileSync(new URL('../../src/styles/legacy-views.css', im
|
||||||
const layout = readFileSync(new URL('../../src/styles/layout.css', import.meta.url), 'utf8');
|
const layout = readFileSync(new URL('../../src/styles/layout.css', import.meta.url), 'utf8');
|
||||||
|
|
||||||
describe('Grid and Raw Data typography', () => {
|
describe('Grid and Raw Data typography', () => {
|
||||||
it('uses the larger readable text size throughout the Grid view', () => {
|
it('uses compact structured-data roles throughout the Grid view', () => {
|
||||||
expect(legacyViews).toMatch(/\.view-table table\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s);
|
expect(legacyViews).toMatch(/\.view-table table\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
||||||
expect(legacyViews).toMatch(/\.view-table th\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s);
|
expect(legacyViews).toMatch(/\.view-table th\s*\{[^}]*font-size:\s*var\(--type-table-head-size\);/s);
|
||||||
expect(legacyViews).toMatch(/\.view-table td\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s);
|
expect(legacyViews).toMatch(/\.view-table td\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the larger readable text size in Raw Data regardless of stylesheet order', () => {
|
it('uses the compact structured-data role in Raw Data regardless of stylesheet order', () => {
|
||||||
expect(legacyViews).toMatch(/\.raw-view\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s);
|
expect(legacyViews).toMatch(/\.raw-view\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
||||||
expect(layout).toMatch(/\.raw-view\s*\{[^}]*font:\s*var\(--fs-lg\)\/1\.55 var\(--font-mono\);/s);
|
expect(layout).toMatch(/\.raw-view\s*\{[^}]*font:\s*var\(--type-table-body-size\)\/1\.55 var\(--font-mono\);/s);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ describe('LinkedResults', () => {
|
||||||
expect(html).toContain('class="rich-text-table"');
|
expect(html).toContain('class="rich-text-table"');
|
||||||
});
|
});
|
||||||
it('zeigt den Finding-Titel zusätzlich im Result-Fenster', () => {
|
it('zeigt den Finding-Titel zusätzlich im Result-Fenster', () => {
|
||||||
|
const styles = readFileSync(new URL('../../src/styles/components.css', import.meta.url), 'utf8');
|
||||||
const html = renderToStaticMarkup(createElement(LinkedResults, {
|
const html = renderToStaticMarkup(createElement(LinkedResults, {
|
||||||
sheets: [sheet],
|
sheets: [sheet],
|
||||||
heading: 'Synthetic result',
|
heading: 'Synthetic result',
|
||||||
|
|
@ -120,6 +121,8 @@ describe('LinkedResults', () => {
|
||||||
}));
|
}));
|
||||||
expect(html).toContain('<h3 class="linked-results-heading">Synthetic result</h3>');
|
expect(html).toContain('<h3 class="linked-results-heading">Synthetic result</h3>');
|
||||||
expect(html).not.toContain('>Privileged Users with Weak Password Policy</h4>');
|
expect(html).not.toContain('>Privileged Users with Weak Password Policy</h4>');
|
||||||
|
expect(styles).toMatch(/\.results-window-head h2\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
|
expect(styles).toMatch(/\.linked-results-heading\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('benennt den Ergebnisblock eines Blattes nach dem Blatt', () => {
|
it('benennt den Ergebnisblock eines Blattes nach dem Blatt', () => {
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,21 @@ describe('binding segment and tile grammar', () => {
|
||||||
it('defines one semantic geometry and surface contract for both themes', () => {
|
it('defines one semantic geometry and surface contract for both themes', () => {
|
||||||
for (const token of [
|
for (const token of [
|
||||||
'--segment-surface:', '--segment-border:', '--segment-radius: 10px;',
|
'--segment-surface:', '--segment-border:', '--segment-radius: 10px;',
|
||||||
'--segment-inset: var(--space-3);', '--segment-gap: var(--space-2);',
|
'--segment-inset: var(--space-3);', '--segment-gap: var(--space-3);',
|
||||||
'--tile-surface:', '--tile-surface-hover:', '--tile-border:',
|
'--tile-surface:', '--tile-surface-hover:', '--tile-border:',
|
||||||
'--tile-selected-border:', '--tile-radius: 8px;',
|
'--tile-selected-border: var(--marking-color);', '--tile-radius: var(--radius-sm);',
|
||||||
'--tile-compact-width: 186px;', '--tile-content-width: 320px;',
|
'--tile-compact-width: 186px;', '--tile-content-width: 320px;',
|
||||||
'--tile-card-shadow:'
|
'--tile-card-shadow:'
|
||||||
]) expect(tokens).toContain(token);
|
]) expect(tokens).toContain(token);
|
||||||
|
|
||||||
|
expect(tokens.match(/--segment-surface: transparent;/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--segment-border: transparent;/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--tile-border: transparent;/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--tile-card-shadow: none;/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--marking-color: #E9A63C;/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--selection-border: var\(--marking-color\);/g)).toHaveLength(2);
|
||||||
|
expect(tokens.match(/--selection-marker: var\(--marking-color\);/g)).toHaveLength(2);
|
||||||
|
|
||||||
expect(tokens).toContain('--donut-rack-surface: var(--segment-surface);');
|
expect(tokens).toContain('--donut-rack-surface: var(--segment-surface);');
|
||||||
expect(tokens).toContain('--donut-card-surface: var(--tile-surface);');
|
expect(tokens).toContain('--donut-card-surface: var(--tile-surface);');
|
||||||
expect(tokens).toContain('--donut-card-shadow: var(--tile-card-shadow);');
|
expect(tokens).toContain('--donut-card-shadow: var(--tile-card-shadow);');
|
||||||
|
|
@ -51,6 +59,8 @@ describe('binding segment and tile grammar', () => {
|
||||||
expect(legacy).toMatch(/\.tile-grid\s*\{[^}]*justify-content:\s*start;[^}]*border-radius:\s*var\(--segment-radius\);/s);
|
expect(legacy).toMatch(/\.tile-grid\s*\{[^}]*justify-content:\s*start;[^}]*border-radius:\s*var\(--segment-radius\);/s);
|
||||||
expect(legacy).toMatch(/\.data-tile\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
expect(legacy).toMatch(/\.data-tile\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
||||||
expect(viewer).toContain('gridTemplateColumns: `repeat(${tileColumns}, minmax(var(--tile-content-width), 1fr))`');
|
expect(viewer).toContain('gridTemplateColumns: `repeat(${tileColumns}, minmax(var(--tile-content-width), 1fr))`');
|
||||||
|
expect(viewer).toContain('const TILE_GAP = 12;');
|
||||||
|
expect(viewer).toContain('const TILE_ROW_HEIGHT = 172;');
|
||||||
|
|
||||||
expect(policy).toContain('grid-template-columns: repeat(auto-fill, var(--tile-compact-width));');
|
expect(policy).toContain('grid-template-columns: repeat(auto-fill, var(--tile-compact-width));');
|
||||||
expect(pathfinder).toMatch(/\.finding-path-card\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
expect(pathfinder).toMatch(/\.finding-path-card\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
|
||||||
|
|
@ -58,6 +68,10 @@ describe('binding segment and tile grammar', () => {
|
||||||
|
|
||||||
it('keeps the rules binding and prevents status-filled or stretched substitute tiles', () => {
|
it('keeps the rules binding and prevents status-filled or stretched substitute tiles', () => {
|
||||||
expect(guidance).toContain('Treat segments and tiles as a binding two-level visual grammar independent of their content.');
|
expect(guidance).toContain('Treat segments and tiles as a binding two-level visual grammar independent of their content.');
|
||||||
|
expect(guidance).toContain('Segments are visually transparent and have no visible border or shadow.');
|
||||||
|
expect(guidance).toContain('without a visible normal-state border, radius, or shadow');
|
||||||
|
expect(guidance).toContain('Hover changes the surface only');
|
||||||
|
expect(guidance).toContain('persistent marking and selection color is always the shared yellow');
|
||||||
expect(guidance).toContain('never stretch a partial final row merely to fill the container');
|
expect(guidance).toContain('never stretch a partial final row merely to fill the container');
|
||||||
expect(guidance).toContain('Never fill the complete tile with a status color.');
|
expect(guidance).toContain('Never fill the complete tile with a status color.');
|
||||||
expect(guidance).toContain('Tile content must never cross the tile boundary or cover a neighboring tile.');
|
expect(guidance).toContain('Tile content must never cross the tile boundary or cover a neighboring tile.');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import maesterHtmlFixture from '../../fixtures/maester/test-results.synthetic.html?raw';
|
||||||
|
import purpleKnightHtmlFixture from '../../fixtures/purple-knight/security-assessment.synthetic.html?raw';
|
||||||
|
import { assertCanonicalAssessmentModel } from '../../src/domain/canonical-validation';
|
||||||
|
import { confirmEntryMappings, previewEntryFromDataset } from '../../src/features/import-workflow/import-workflow';
|
||||||
|
import { createCanonicalImportBatch } from '../../src/importers/canonical-import';
|
||||||
|
import { importFile } from '../../src/importers/importer';
|
||||||
|
import { importProfiles } from '../../src/profiles/profile-catalog';
|
||||||
|
import { linkResultSheets } from '../../src/profiles/purple-knight/result-linker';
|
||||||
|
import { canonicalModelToDataset } from '../../src/features/project-import/canonical-model-to-dataset';
|
||||||
|
|
||||||
|
function candidateValue(
|
||||||
|
candidate: NonNullable<Awaited<ReturnType<typeof importFile>>['extraction']>['candidates'][number],
|
||||||
|
label: string
|
||||||
|
): string | undefined {
|
||||||
|
return candidate.fields.find(field => field.label === label)?.decodedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canonicalCount(content: string, fileName: string, type: string): Promise<number> {
|
||||||
|
const dataset = await importFile(new File([content], fileName, { type }), { profiles: importProfiles });
|
||||||
|
const preview = previewEntryFromDataset(dataset, {
|
||||||
|
sha256: fileName.padEnd(64, '0').slice(0, 64), fileName, sizeBytes: content.length, lastModified: 0
|
||||||
|
});
|
||||||
|
const entry = confirmEntryMappings(preview, importProfiles);
|
||||||
|
const model = createCanonicalImportBatch(fileName, [entry], '2026-08-25T08:00:00.000Z').model;
|
||||||
|
assertCanonicalAssessmentModel(model);
|
||||||
|
return model.findingOccurrences.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('script-embedded scanner HTML source extractors', () => {
|
||||||
|
it('extracts Maester tests from inert embedded JSON without executing report scripts', async () => {
|
||||||
|
delete (window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted;
|
||||||
|
const imported = await importFile(new File([maesterHtmlFixture], 'Maester-Synthetic-Report.html', { type: 'text/html' }), {
|
||||||
|
profiles: importProfiles
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted).toBeUndefined();
|
||||||
|
expect(imported.profileId).toBe('maester');
|
||||||
|
expect(imported.extraction?.extractor.id).toBe('maester-html-source-extractor');
|
||||||
|
expect(imported.extraction?.candidates).toHaveLength(2);
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'ID')).toBe('MT.9001');
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'Result')).toBe('Failed');
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'ResultDetail')).toBe('The synthetic policy needs review.');
|
||||||
|
expect(imported.extraction!.candidates[0].source.objectPath).toBe('$.Tests[0]');
|
||||||
|
expect(await canonicalCount(maesterHtmlFixture, 'Maester-Synthetic-Report.html', 'text/html')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts PurpleKnight indicators and preserves source-object provenance', async () => {
|
||||||
|
delete (window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted;
|
||||||
|
const imported = await importFile(new File(
|
||||||
|
[purpleKnightHtmlFixture], 'Security_Assessment_Report_synthetic.html', { type: 'text/html' }
|
||||||
|
), { profiles: importProfiles });
|
||||||
|
|
||||||
|
expect((window as Window & { __syntheticActiveContentExecuted?: boolean }).__syntheticActiveContentExecuted).toBeUndefined();
|
||||||
|
expect(imported.profileId).toBe('purple-knight');
|
||||||
|
expect(imported.extraction?.extractor.id).toBe('purple-knight-html-source-extractor');
|
||||||
|
expect(imported.extraction?.candidates).toHaveLength(2);
|
||||||
|
expect(imported.extraction?.candidates[0].fields.map(field => field.label)).toEqual([
|
||||||
|
'ShortName', 'Name', 'SI version', 'Target', 'Status', 'Runtime', 'Score', 'Severity', 'Weight', 'Category',
|
||||||
|
'MITRE ATT&CK_1', 'MITRE ATT&CK_2', 'MITRE ATT&CK_3', 'MITRE ATT&CK_4',
|
||||||
|
'ANSSI_1', 'ANSSI_2', 'ANSSI_3', 'ANSSI_4', 'MITRE D3FEND_1', 'MITRE D3FEND_2',
|
||||||
|
'Description', 'Likelihood of compromise', 'Result message', 'Number of results', 'Result', 'Remediation'
|
||||||
|
]);
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'ShortName')).toBe('SI900001');
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'Status')).toBe('IOE Found');
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'Result')).toBe('SI900001');
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'Description')).toBe('A synthetic description.');
|
||||||
|
expect(candidateValue(imported.extraction!.candidates[0], 'Category')).toBe('Synthetic Category');
|
||||||
|
expect(imported.extraction!.candidates[0].source.objectPath).toContain('$.Category_1');
|
||||||
|
expect(imported.extraction?.supportingTables).toEqual([expect.objectContaining({
|
||||||
|
name: 'SI900001', headers: ['Object', 'EventTimestamp', 'Ignored'],
|
||||||
|
rows: [['Synthetic value', '2026-05-27 12:37', 'False']]
|
||||||
|
})]);
|
||||||
|
expect(imported.sheets.find(sheet => sheet.sourceSheet === 'SI900001')?.rows)
|
||||||
|
.toEqual([['Object', 'EventTimestamp', 'Ignored'], ['Synthetic value', '2026-05-27 12:37', 'False']]);
|
||||||
|
expect(await canonicalCount(
|
||||||
|
purpleKnightHtmlFixture, 'Security_Assessment_Report_synthetic.html', 'text/html'
|
||||||
|
)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links embedded PurpleKnight HTML result tables like workbook result sheets', async () => {
|
||||||
|
const imported = await importFile(new File(
|
||||||
|
[purpleKnightHtmlFixture], 'Security_Assessment_Report_synthetic.html', { type: 'text/html' }
|
||||||
|
), { profiles: importProfiles });
|
||||||
|
const preview = previewEntryFromDataset(imported, {
|
||||||
|
sha256: 'purple-knight-html-result'.padEnd(64, '0').slice(0, 64),
|
||||||
|
fileName: imported.fileName, sizeBytes: purpleKnightHtmlFixture.length, lastModified: 0
|
||||||
|
});
|
||||||
|
const model = createCanonicalImportBatch(
|
||||||
|
'purple-knight-html-result', [confirmEntryMappings(preview, importProfiles)], '2026-08-25T08:00:00.000Z'
|
||||||
|
).model;
|
||||||
|
const dataset = canonicalModelToDataset(model)!;
|
||||||
|
const mainSheet = dataset.sheets.find(sheet => sheet.analysis.records.some(record =>
|
||||||
|
record.fields.some(field => field.label === 'ShortName' && field.value === 'SI900001')))!;
|
||||||
|
const record = mainSheet.analysis.records.find(item =>
|
||||||
|
item.fields.some(field => field.label === 'ShortName' && field.value === 'SI900001'))!;
|
||||||
|
|
||||||
|
const linked = linkResultSheets(record, dataset.sheets, mainSheet.id);
|
||||||
|
expect(linked).toHaveLength(1);
|
||||||
|
expect(linked[0].sourceSheet).toBe('SI900001');
|
||||||
|
expect(linked[0].rows).toEqual([
|
||||||
|
['Object', 'EventTimestamp', 'Ignored'], ['Synthetic value', '2026-05-27 12:37', 'False']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat an empty Maester report template as a finding export', async () => {
|
||||||
|
const template = '<!doctype html><html><body><script>var placeholder={"Tests":[],"EndOfJson":true};</script></body></html>';
|
||||||
|
await expect(importFile(new File([template], 'ReportTemplate.html', { type: 'text/html' }), { profiles: importProfiles }))
|
||||||
|
.rejects.toThrow(/keine auswertbaren Tabellen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts all tenants from a Maester multi-tenant HTML report', async () => {
|
||||||
|
const report = `<!doctype html><html><body><div>Maester Security Tests</div><script>
|
||||||
|
const data={"Tenants":[
|
||||||
|
{"Result":"Passed","Tests":[{"Id":"MT.9101","Title":"Synthetic tenant one","Result":"Passed","Severity":"Low"}],"EndOfJson":true},
|
||||||
|
{"Result":"Failed","Tests":[{"Id":"MT.9102","Title":"Synthetic tenant two","Result":"Failed","Severity":"High"}],"EndOfJson":true}
|
||||||
|
],"EndOfJson":true};
|
||||||
|
</script></body></html>`;
|
||||||
|
const imported = await importFile(new File([report], 'Maester-MultiTenant-Synthetic.html', { type: 'text/html' }), {
|
||||||
|
profiles: importProfiles
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(imported.profileId).toBe('maester');
|
||||||
|
expect(imported.extraction?.candidates.map(candidate => candidate.source.objectPath)).toEqual([
|
||||||
|
'$.Tenants[0].Tests[0]', '$.Tenants[1].Tests[0]'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -341,6 +341,21 @@ describe('Ergebnistabellen aus Textfeldern', () => {
|
||||||
expect(sheets[0].analysis.records).toHaveLength(2);
|
expect(sheets[0].analysis.records).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('macht altes PurpleKnight-ResultDetail-JSON als Results-Tabelle verfuegbar', () => {
|
||||||
|
const value = JSON.stringify([
|
||||||
|
['DistinguishedName: CN=Synthetic,DC=example,DC=test', 'MinLength: 14', 'Ignored: False'],
|
||||||
|
['DistinguishedName: CN=Second,DC=example,DC=test', 'MinLength: 16', 'Ignored: True']
|
||||||
|
]);
|
||||||
|
const sheets = inlineResultTableSheets(recordWith([['ResultDetail', value]]));
|
||||||
|
|
||||||
|
expect(sheets).toHaveLength(1);
|
||||||
|
expect(sheets[0].rows).toEqual([
|
||||||
|
['DistinguishedName', 'MinLength', 'Ignored'],
|
||||||
|
['CN=Synthetic,DC=example,DC=test', '14', 'False'],
|
||||||
|
['CN=Second,DC=example,DC=test', '16', 'True']
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('erzeugt kein Blatt fuer Felder ohne Tabelle', () => {
|
it('erzeugt kein Blatt fuer Felder ohne Tabelle', () => {
|
||||||
expect(inlineResultTableSheets(recordWith([['ResultDetail', 'Keine Auffaelligkeiten.']]))).toEqual([]);
|
expect(inlineResultTableSheets(recordWith([['ResultDetail', 'Keine Auffaelligkeiten.']]))).toEqual([]);
|
||||||
expect(inlineResultTableSheets(recordWith([['Description', RESULT_DETAIL]]))).toEqual([]);
|
expect(inlineResultTableSheets(recordWith([['Description', RESULT_DETAIL]]))).toEqual([]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue