Compare commits

...

10 Commits

Author SHA1 Message Date
Binh 7a96ed3ae1 fix(datahub): moved all charts in one line
Deploy / Test, build & deploy (push) Successful in 2m17s Details
2026-08-24 16:20:21 +02:00
Binh c179ac1b2c Merge branch 'Michael' into experimental
Deploy / Test, build & deploy (push) Successful in 2m3s Details
# Conflicts:
#	docs/IMPLEMENTATION_PLAN.md
#	src/components/ResizableResultTable/ResizableResultTable.tsx
#	src/styles/datahub-redesign.css
#	src/styles/datahub.css
#	src/styles/obsidian-graph.css
#	tests/features/datahub-centered-layout.test.ts
#	tests/features/obsidian-graph.test.ts
2026-08-24 16:08:03 +02:00
Binh 200a8ac381 fix(server): use configured PostgreSQL host in containers
Deploy / Test, build & deploy (push) Successful in 2m0s Details
2026-08-24 15:47:10 +02:00
Binh 7ba69494e1 fix(ui): resolve design checks and cross-platform test failures
Deploy / Test, build & deploy (push) Failing after 2m10s Details
2026-08-24 15:36:41 +02:00
Binh df59048587 ci: add VPN-only Gitea deployment with isolated PostgreSQL stacks
Deploy / Test, build & deploy (push) Failing after 1m4s Details
2026-08-24 15:21:10 +02:00
Michael 664ed3e89e 2 2026-08-24 15:17:46 +02:00
Michael 6cfe0c1fd5 Design Anpassungen Dashboard + Rep. PC Importer 2026-08-24 15:17:22 +02:00
Michael 0effadb736 shore: add png in gitignore 2026-08-24 12:59:28 +02:00
Binh d74d0a5c56 feat(dashboard): align metrics and risk profiles with reference layout 2026-08-24 12:48:35 +02:00
Binh 5a73e27c90 feat(pingcastle): map environment sections to hierarchical result subjects 2026-08-24 11:31:11 +02:00
63 changed files with 2025 additions and 485 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git
.env*
.assessor-data
.tmp
artifacts
coverage
dist
dist-server
node_modules
*.log

View File

@ -10,3 +10,16 @@ ASSESSOR_GPO_JOB_LEASE_MS=60000
ASSESSOR_GPO_JOB_RETRY_MS=5000
ASSESSOR_GPO_JOB_POLL_MS=1000
# Optional AI Overview. Leave ASSESSOR_AI_MODE unset to keep outbound AI disabled.
# ASSESSOR_AI_MODE=local
# ASSESSOR_AI_BASE_URL=http://127.0.0.1:11434/v1/
# ASSESSOR_AI_MODEL=local-model-name
# ASSESSOR_AI_API_KEY=
# ASSESSOR_AI_TIMEOUT_MS=120000
# ASSESSOR_AI_MAX_PROMPT_CHARS=12000
# ASSESSOR_AI_MAX_CHUNK_CHARS=60000
# ASSESSOR_AI_MAX_OUTPUT_CHARS=24000
# ASSESSOR_AI_MAX_OUTPUT_TOKENS=3000
# ASSESSOR_AI_JOB_POLL_MS=1000
# ASSESSOR_AI_JOB_LEASE_MS=300000

25
.gitattributes vendored Normal file
View File

@ -0,0 +1,25 @@
* text=auto
*.css text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.mjs text eol=lf
*.sql text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.xml text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.gif binary
*.ico binary
*.jpeg binary
*.jpg binary
*.pdf binary
*.png binary
*.woff binary
*.woff2 binary
*.xlsx binary
*.zip binary

201
.gitea/workflows/deploy.yml Normal file
View File

@ -0,0 +1,201 @@
# Parallel deployment of the new ASSESSOR application.
# main -> stable (10.0.0.154:4173)
# experimental -> experimental (10.0.0.154:4174)
#
# The existing HTTPS production deployment is not managed by this workflow.
# Both new environments use separate Compose projects, databases and artifact
# volumes. Their complete environment files come from different Gitea secrets.
name: Deploy
on:
push:
branches:
- main
- experimental
jobs:
deploy:
name: Test, build & deploy
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Configure stable environment
if: github.ref_name == 'main'
env:
ASSESSOR_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}
run: |
set -eu
if [ -z "$ASSESSOR_ENV_FILE" ]; then
echo "::error::PROD_ENV_FILE is empty or not configured."
exit 1
fi
printf '%s\n' "$ASSESSOR_ENV_FILE" > .env
chmod 600 .env
for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD; do
if ! grep -q "^${key}=." .env; then
echo "::error::PROD_ENV_FILE does not contain a value for ${key}."
exit 1
fi
done
echo "ASSESSOR_ENVIRONMENT=stable" >> "$GITHUB_ENV"
echo "ASSESSOR_STACK=assessor_prod" >> "$GITHUB_ENV"
echo "ASSESSOR_PUBLISH_PORT=4173" >> "$GITHUB_ENV"
echo "ASSESSOR_BIND=10.0.0.154" >> "$GITHUB_ENV"
echo "Stable configuration validated; secret values were not printed."
- name: Configure experimental environment
if: github.ref_name == 'experimental'
env:
ASSESSOR_ENV_FILE: ${{ secrets.STAGING_ENV_FILE }}
run: |
set -eu
if [ -z "$ASSESSOR_ENV_FILE" ]; then
echo "::error::STAGING_ENV_FILE is empty or not configured."
exit 1
fi
printf '%s\n' "$ASSESSOR_ENV_FILE" > .env
chmod 600 .env
for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD; do
if ! grep -q "^${key}=." .env; then
echo "::error::STAGING_ENV_FILE does not contain a value for ${key}."
exit 1
fi
done
echo "ASSESSOR_ENVIRONMENT=experimental" >> "$GITHUB_ENV"
echo "ASSESSOR_STACK=assessor_experimental" >> "$GITHUB_ENV"
echo "ASSESSOR_PUBLISH_PORT=4174" >> "$GITHUB_ENV"
echo "ASSESSOR_BIND=10.0.0.154" >> "$GITHUB_ENV"
echo "Experimental configuration validated; secret values were not printed."
- name: Install dependencies
run: npm ci
- name: Test
run: npm test
- name: Build
run: npm run build
# Gitea currently ignores the GitHub-compatible concurrency key. An
# atomic Docker container name therefore serializes each target stack on
# the shared Docker host. --rm releases a crashed job after two hours.
- name: Acquire environment deployment lock
run: |
set -eu
lock_name="${ASSESSOR_STACK}_deployment_lock"
attempt=0
until docker run -d --rm \
--name "$lock_name" \
--label "de.assessor.deployment-lock=$ASSESSOR_STACK" \
postgres:16-alpine sleep 7200 >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 180 ]; then
echo "::error::Timed out waiting for the $ASSESSOR_STACK deployment lock."
exit 1
fi
sleep 10
done
echo "ASSESSOR_LOCK_ACQUIRED=true" >> "$GITHUB_ENV"
echo "Environment=$ASSESSOR_ENVIRONMENT deployment-lock=acquired"
- name: Validate Compose configuration
run: docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml config --quiet
- name: Check deployment isolation
run: |
set -eu
published_containers="$(docker ps --filter "publish=$ASSESSOR_PUBLISH_PORT" --format '{{.ID}}')"
for container_id in $published_containers; do
project="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container_id")"
if [ "$project" != "$ASSESSOR_STACK" ]; then
echo "::error::Port $ASSESSOR_PUBLISH_PORT is already published outside $ASSESSOR_STACK."
exit 1
fi
done
services="$(docker ps -a --filter "label=com.docker.compose.project=$ASSESSOR_STACK" --format '{{.Label "com.docker.compose.service"}}' | sort -u)"
for service in $services; do
case "$service" in
app|postgres) ;;
*)
echo "::error::Compose project $ASSESSOR_STACK contains an unexpected service."
exit 1
;;
esac
done
echo "Environment=$ASSESSOR_ENVIRONMENT stack=$ASSESSOR_STACK port=$ASSESSOR_PUBLISH_PORT isolation=ok"
- name: Back up existing environment
run: |
set -euo pipefail
postgres_id="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps -a -q postgres)"
if [ -z "$postgres_id" ]; then
echo "Environment=$ASSESSOR_ENVIRONMENT backup=not-required-first-deployment"
exit 0
fi
if [ "$(docker inspect --format '{{.State.Running}}' "$postgres_id")" != "true" ]; then
echo "::error::Existing $ASSESSOR_STACK database is not running; deployment stopped before migration."
exit 1
fi
backup_volume=assessor_deployment_backups
backup_stamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_path="$ASSESSOR_STACK/$backup_stamp"
docker volume create "$backup_volume" >/dev/null
docker run --rm -v "$backup_volume:/backups" postgres:16-alpine \
mkdir -p "/backups/$backup_path"
docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml exec -T postgres \
sh -c 'pg_dump --format=custom --no-owner --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \
| docker run --rm -i -v "$backup_volume:/backups" postgres:16-alpine \
sh -c "cat > '/backups/$backup_path/postgres.dump'"
artifact_volume="$(docker volume ls \
--filter "label=com.docker.compose.project=$ASSESSOR_STACK" \
--filter "label=com.docker.compose.volume=assessor-artifacts" \
--format '{{.Name}}')"
if [ -z "$artifact_volume" ]; then
echo "::error::Existing $ASSESSOR_STACK artifact volume was not found."
exit 1
fi
docker run --rm \
-v "$artifact_volume:/artifacts:ro" \
-v "$backup_volume:/backups" \
postgres:16-alpine \
tar -C /artifacts -czf "/backups/$backup_path/artifacts.tar.gz" .
docker run --rm -v "$backup_volume:/backups" postgres:16-alpine \
sh -c "cd '/backups/$backup_path' && sha256sum postgres.dump artifacts.tar.gz > SHA256SUMS"
echo "Environment=$ASSESSOR_ENVIRONMENT backup=created timestamp=$backup_stamp"
- name: Deploy
run: |
docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml up -d --build --remove-orphans \
--wait --wait-timeout 180
published_port="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml port app 4173)"
echo "Environment=$ASSESSOR_ENVIRONMENT stack=$ASSESSOR_STACK published=$published_port state=started"
# A containerized Actions runner does not share the Docker host's loopback
# namespace, so readiness is checked inside the application container.
- name: Check application health
run: |
docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml exec -T app \
node -e "fetch('http://127.0.0.1:4173/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
echo "Environment=$ASSESSOR_ENVIRONMENT health=ok"
- name: Show content-free diagnostics on failure
if: failure()
run: |
docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps || true
app_id="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps -a -q app 2>/dev/null || true)"
if [ -n "$app_id" ]; then
docker inspect --format 'app={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$app_id" || true
fi
- name: Release environment deployment lock
if: always()
run: |
if [ "${ASSESSOR_LOCK_ACQUIRED:-false}" = "true" ]; then
docker rm -f "${ASSESSOR_STACK}_deployment_lock" >/dev/null 2>&1 || true
fi

1
.gitignore vendored
View File

@ -9,6 +9,7 @@ src-tauri/gen/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
*.png
.env.postgres
.assessor-data/

View File

@ -20,6 +20,10 @@
## UI typography
- 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 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.
- 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.
- 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.
@ -31,6 +35,31 @@
- Apply the inset exactly once on the view's root content container. Split panes and edge-to-edge structures belong inside that inset and must not add a second outer inset.
- Keep the 20px outer inset at responsive breakpoints unless the user explicitly changes this standard.
## 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.
- 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.
- 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.
- 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.
- 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.
- 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.
## UI donut charts
- Every donut chart uses the same full 360-degree geometry: a continuous neutral-gray track and a colored clockwise value arc starting at 12 o'clock.
- Donut arcs use flat ends and the shared `--donut-ring-stroke`, `--donut-track`, value, and typography tokens. Do not introduce 270-degree gauges, open gaps, rounded arc caps, or local substitute geometry.
- Place the primary numeric value itself on the exact visual center of the ring. Append a percent sign or other short unit to its right on the same baseline in the smaller shared UI size without letting that unit shift the number away from the center. Supporting counts may remain as subdued secondary text inside the ring, but every text element must stay fully within the clear inner area and must never touch or overlap the ring at any supported value width.
- Multi-segment distributions retain their categorical segments, but use the same track, stroke proportion, start position, centered value treatment, and shared tokens as single-value donuts.
## UI branding position
- Use the Data Hub header as the binding position reference for the ASSESSOR logo, wordmark, and version.
- Keep the brand container geometry identical across every application view; do not add area- or view-specific brand padding, margin, alignment, height, or transforms.
- Switching views must never move the logo, wordmark, or version.
## Security and verification
- Preserve local-only behavior until a server transition is explicitly implemented and documented.

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node --from=build /app/dist-server ./dist-server
RUN mkdir -p /var/lib/assessor/artifacts \
&& chown -R node:node /var/lib/assessor
USER node
EXPOSE 4173
CMD ["node", "dist-server/server.mjs"]

View File

@ -0,0 +1,58 @@
# nginx-Reverse-Proxy-vhosts für ASSESSOR.
#
# In den bestehenden Proxy einfügen (z. B. /etc/nginx/conf.d/assessor.conf oder
# ein sites-available-Symlink), die ssl_certificate-Pfade an das Zertifikat
# anpassen, das assessor.soc-ops.de bereits terminiert, dann:
# nginx -t && systemctl reload nginx
#
# WICHTIG: proxy_set_header Host $host ist zwingend. ASSESSOR lehnt jeden
# Schreibzugriff ab (403 cross_origin_rejected), wenn der weitergeleitete Host
# nicht zum Browser-Origin passt. Den Host-Header nicht fest setzen oder entfernen.
# ---- Produktion: Branch main -> 127.0.0.1:4173 ----
server {
listen 443 ssl;
server_name assessor.soc-ops.de;
ssl_certificate /etc/ssl/assessor/assessor.crt; # an vorhandenes Zertifikat anpassen
ssl_certificate_key /etc/ssl/assessor/assessor.key; # an vorhandenen Schlüssel anpassen
# Uploads akzeptieren Dateien bis 100 MiB (≈140 MiB base64); Reserve lassen.
client_max_body_size 200m;
location / {
proxy_pass http://127.0.0.1:4173;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
# ---- Staging: Branch experimental -> 127.0.0.1:4174 ----
server {
listen 443 ssl;
server_name staging.assessor.soc-ops.de;
ssl_certificate /etc/ssl/assessor/assessor.crt; # SAN- oder Wildcard-Zertifikat, das staging.* abdeckt
ssl_certificate_key /etc/ssl/assessor/assessor.key;
client_max_body_size 200m;
location / {
proxy_pass http://127.0.0.1:4174;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
}
# Optional: einfaches HTTP auf HTTPS umleiten für beide Hosts.
server {
listen 80;
server_name assessor.soc-ops.de staging.assessor.soc-ops.de;
return 301 https://$host$request_uri;
}

56
docker-compose.deploy.yml Normal file
View File

@ -0,0 +1,56 @@
# Shared stable/experimental deployment definition.
# The workflow always supplies a distinct project name and VPN-only host port:
# assessor_prod -> 10.0.0.154:4173
# assessor_experimental -> 10.0.0.154:4174
# Compose scopes both named volumes by project, so the environments never share
# PostgreSQL or artifacts. PostgreSQL is intentionally not published to the host.
name: ${ASSESSOR_STACK:-assessor_prod}
services:
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
ASSESSOR_HOST: 0.0.0.0
ASSESSOR_PORT: "4173"
ASSESSOR_ARTIFACT_ROOT: /var/lib/assessor/artifacts
ASSESSOR_POSTGRES_HOST: postgres
ASSESSOR_POSTGRES_PORT: "5432"
ASSESSOR_POSTGRES_DB: ${ASSESSOR_POSTGRES_DB:?set ASSESSOR_POSTGRES_DB}
ASSESSOR_POSTGRES_USER: ${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER}
ASSESSOR_POSTGRES_PASSWORD: ${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD}
ASSESSOR_DATABASE_SSL: disable
ports:
- "${ASSESSOR_BIND:?set ASSESSOR_BIND}:${ASSESSOR_PUBLISH_PORT:?set ASSESSOR_PUBLISH_PORT}:4173"
volumes:
- assessor-artifacts:/var/lib/assessor/artifacts
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4173/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 6
start_period: 40s
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${ASSESSOR_POSTGRES_DB:?set ASSESSOR_POSTGRES_DB}
POSTGRES_USER: ${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER}
POSTGRES_PASSWORD: ${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 3s
retries: 12
volumes:
- assessor-postgres-data:/var/lib/postgresql/data
volumes:
assessor-artifacts:
assessor-postgres-data:

108
docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,108 @@
# Parallel deployment
## Environments
The Gitea workflow deploys two permanent, isolated environments on the target
server. The old HTTPS installation is outside this workflow and remains
unchanged until a separately approved cutover.
| Branch | Compose project | VPN address | Secret |
|---|---|---|---|
| `main` | `assessor_prod` | `http://10.0.0.154:4173` | `PROD_ENV_FILE` |
| `experimental` | `assessor_experimental` | `http://10.0.0.154:4174` | `STAGING_ENV_FILE` |
`assessor_prod` already exists on the server. Keep its current `PROD_ENV_FILE`
database name, user and password unchanged so the existing PostgreSQL volume
remains accessible. Do not rotate only the secret; password rotation requires a
coordinated PostgreSQL role change.
`STAGING_ENV_FILE` contains a complete environment file with credentials that
are distinct from stable:
```text
ASSESSOR_POSTGRES_DB=assessor_experimental
ASSESSOR_POSTGRES_USER=assessor_experimental
ASSESSOR_POSTGRES_PASSWORD=replace-with-a-long-random-experimental-password
```
Do not reuse the stable database name, user, or password for experimental.
Secret values must never be printed into an Actions log.
## Mandatory server inventory
Before the first push-triggered deployment, connect to the target server over
SSH and perform a read-only inventory. Record only technical identifiers; do
not inspect database rows, uploaded files, application pages, or report text.
```sh
docker version
docker compose version
docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Label "com.docker.compose.project"}}'
docker volume ls
docker network ls
docker ps --filter publish=4173 --format '{{.Names}}\t{{.Ports}}'
docker ps --filter publish=4174 --format '{{.Names}}\t{{.Ports}}'
```
Also identify the existing reverse proxy and firewall policy without changing
them. Confirm that `10.0.0.154` is a host address, port 4174 is unused, the
runner uses this Docker daemon, and `assessor_experimental` does not belong to
an unrelated stack. Port 4173 and project `assessor_prod` are intentionally
adopted as stable; only its expected `app` and `postgres` services may exist.
Any other conflict blocks deployment and is never resolved by stopping or
deleting the existing resource.
## Deployment behavior
Pushes to a branch are serialized per environment with an atomic lock container
on the shared Docker host. This is necessary because Gitea currently ignores the
GitHub-compatible workflow `concurrency` key. A normal job removes its lock; a
crashed job's `--rm` lock expires after two hours. The workflow installs
dependencies, runs the complete test suite and production build, validates the
resolved Compose configuration, checks port/project isolation, backs up an
existing target environment, then builds and starts only that environment.
Compose project scoping produces independent PostgreSQL and artifact volumes.
PostgreSQL has no host-published port. `restart: unless-stopped` preserves both
environments across Docker daemon and server restarts.
Feature promotion is a merge from `experimental` to `main`. Stable is rebuilt
from the merged commit; databases and artifacts are never copied between the
environments.
## Backups and restore checks
Before every update of an existing environment, the workflow writes a custom
PostgreSQL dump, an artifact archive and SHA-256 checksums to the external
Docker volume `assessor_deployment_backups`:
```text
<compose-project>/<UTC timestamp>/postgres.dump
<compose-project>/<UTC timestamp>/artifacts.tar.gz
<compose-project>/<UTC timestamp>/SHA256SUMS
```
The workflow never deletes backups. Retention and off-host copying require a
separate approved operations policy. The backup volume must be included in the
server's protected backup system; a volume on the same Docker host is not a
complete disaster-recovery copy.
Restore tests must use an isolated temporary Compose project and synthetic data.
Verify `SHA256SUMS`, restore `postgres.dump` with `pg_restore --exit-on-error`
into a fresh database, extract the artifact archive into a fresh artifact
volume, and confirm only content-free counts and health state. Never restore an
experimental backup into stable or a stable backup into experimental. Never
run `docker compose down --volumes` for either permanent environment.
## Acceptance and later cutover
After deployment, verify both in-container health checks and VPN access. Import
different synthetic fixtures into stable and experimental, redeploy each branch,
and confirm persistence and isolation. Confirm the old HTTPS site remains
available without reading its domain content.
Changing `assessor.soc-ops.de` is not part of the deployment workflow. After
explicit production acceptance, validate the existing proxy configuration,
preserve its old upstream, point it to stable, reload it, and verify TLS and
health. Rollback restores the saved upstream; the archived old stack and its
data must remain recoverable until a later retention decision.

View File

@ -1160,6 +1160,7 @@ Umgesetzter Stand vom 2026-08-23:
- Typisierte Beobachtungen enthalten Scanner-/Berichtsmetadaten, Risiko-Scores, stabile zentrale AD-Schluessel fuer Domaene, Forest, Function Level, Schema, DC-Anzahl und Recycle Bin sowie generische, quellentreue Werte fuer jede weitere Tabellenzelle. LAPS, Backup, WEF, krbtgt und ausdruecklich fehlende Entra-Konfiguration werden als eigene Fakten abgebildet.
- `ScannerReportSection` und `EnvironmentObservation` erweitern das kanonische Modell additiv. Migration 018, Local-/PostgreSQL-Repositories, Projekt-Export und eine read-only Environment-Snapshot-API speichern und liefern denselben Vertrag.
- Die Unified Result DB projiziert jede typisierte EnvironmentObservation als eigenen Result-Typ `environment-observation` neben Scanner-Findings und Policy-Evaluationen. Original- und Normalwert, Property, Subject, Erfassungszustand, Abschnitt, Locator sowie Import-, Profil- und Mappingprovenienz bleiben direkt ueber Listen-, Facetten- und Einzelresultat-API abrufbar. Environment-spezifische Facetten filtern Zustand, Property und Subject; Finding-Achsen wie Severity, IOE, Abweichung und Review werden fuer diese Zeilen nicht fachlich umgedeutet.
- PingCastle-Profil 1.4.0 klassifiziert bekannte Tabellen mit den normalisierten sichtbaren PingCastle-Bezeichnungen `domain-information`, `computer-information`, `user-information`, `admin-groups`, `control-paths-analysis`, `infrastructure`, `anomalies`, `password-policies`, `gpo`, `trusts-details` und `pki`. Jeder sichtbare Environment-Unterpunkt wird im flachen Subject-Feld als Punktpfad an seinen Hauptbereich gebunden, beispielsweise `domain-information.entra-id-configuration`, `computer-information.operating-systems`, `infrastructure.wsus-settings`, `anomalies.windows-event-forwarding-wef` oder `gpo.firewall-configuration`. Tabellen mit null Zeilen tragen den Unterpunkt ueber ihre Row-Count-/Presence-Beobachtung; Unterpunkte ohne Tabelle erhalten eine quellentreue Heading-Presence-Beobachtung. Backup, LAPS, WEF und krbtgt bleiben entsprechend dem Bericht unter Anomalies. Finding-Regeldetailabschnitte werden nicht als Environment-Unterpunkte umgedeutet, und unbekannte Tabellen bleiben verlustfrei `pingcastle-table-row`. Bestehende 1.3.0-Importe benoetigen fuer die neuen Subject-Typen einen Reimport.
- PingCastle-Profil 1.3.0 ist die erste eindeutig versionierte Verarbeitungsrevision mit verbindlicher Environment-Context-Erfassung. Historische 1.2.0-Importe bleiben unveraendert gespeichert, gelten aber auch bei gleichem Finding-Kompatibilitaetsschluessel als `reimport-recommended`; nur ein neuer 1.3.0-Import darf als `current` erscheinen.
- Das Dashboard zeigt den aktiven Timeslot als Directory Overview mit zentralen Fakten, Betriebssystemen, hervorgehobenen Infrastruktur-/PKI-/Trust-/LAPS-Bereichen und einer Coverage-Zusammenfassung der Erfassungszustaende.
- Der Environment-Result-Vertikalschnitt ist mit 36 fokussierten synthetischen Domain-, Local-Repository-, PostgreSQL-Vertrags-, API-, HTTP- und UI-Tests verifiziert. Der Gesamtlauf besteht mit 703 Tests und ueberspringt 8 optionale PostgreSQL-Integrationstests; Local-only und der vollstaendige Client-/Server-Produktionsbuild sind erfolgreich. Nur der bereits dokumentierte globale Designcheck fuer zwei Schattenregeln in `datahub-redesign.css` Zeilen 39/42 bleibt rot. Die reale PostgreSQL-Laufzeitintegration fuer Migration 018 bleibt offen; Phase 13C bleibt deshalb in Arbeit.
@ -1348,10 +1349,18 @@ Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04
| Datum | Entscheidung | Begründung |
|---|---|---|
| 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 | 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 | 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 | 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 | HTML-Berichte oberhalb von 1 MiB werden in Browser- und Backendlaufzeit direkt mit dem begrenzten lexikalischen Extraktor verarbeitet; kleinere Dokumente duerfen weiterhin den DOM-Adapter verwenden. Die isolierte Worker-Zeitgrenze von 30 Sekunden und alle bestehenden Ressourcenlimits bleiben unveraendert. | Scanner-HTML kann schon deutlich unter der bisherigen 4-MiB-Schwelle in eine DOM-Struktur expandieren, die das Worker-Zeitbudget ueberschreitet. Der vorhandene lineare Extraktor bewahrt Tabellen-, Locator-, Sicherheits- und Source-Extractor-Vertraege, ohne einen vollstaendigen DOM-Baum aufzubauen oder die Schutzgrenze aufzuweichen. |
| 2026-08-23 | Im Pathfinder gerenderte Knoteninformationen sind das eigenstaendige Designelement `Pathfinder Micro Card` und keine allgemeine Matrix-/Finding-TileCard. Der Vertrag besitzt die Varianten `finding` und `relation`; Category bleibt eine groessere Relation-Auspragung. Bestaetigte Geometrie, Typografie und Radien sind eigene Tokens beziehungsweise Konstanten, und die gesamte Komposition skaliert unveraendert mit Faktor 0,4 im Graphkoordinatensystem. | Graphkarten beschriften Knoten und muessen Beziehungen sichtbar lassen. Eine Uebernahme allgemeiner Kartenmasse oder nachtraegliche kamerainverse Typografie wuerde die bestaetigte Darstellung verdecken beziehungsweise ihre inneren Proportionen veraendern. Ein eigener Vertrag erlaubt gezielte Regressionen, ohne regulaere TileCards ausserhalb des Pathfinders zu beeinflussen. |
| 2026-08-23 | Scanner-Kontext wird zweistufig gespeichert: quellengetreue Reportabschnitte mit Prosa und Tabellen sowie typisierte EnvironmentObservations mit stabilen Schluesseln und explizitem Erfassungszustand. Findings bleiben ein getrennter Vertrag. | Nur die Rohstruktur verhindert Informationsverlust bei neuen PingCastle-Versionen; nur typisierte Fakten erlauben zugleich belastbare Dashboard-, Result-DB-, LLM- und Toolabfragen. Ein Zustandsfeld trennt echte Abwesenheit von leerer Ausgabe, fehlender Berechtigung, Lizenzgrenze und Parserluecke. |
| 2026-08-23 | Typisierte Scanner-EnvironmentObservations werden als eigener Unified-Result-Quelltyp `environment-observation` projiziert und niemals als Finding umgedeutet. Die Projektion ist ein abgeleitetes Readmodell ueber die bestehende Environment-Persistenz; sie bewahrt Original-/Normalwert und vollstaendige Provenienz, bietet eigene Environment-Facetten und nimmt diese Zeilen aus Finding-spezifischen Facetten heraus. | Result DB, Exporte, LLMs und weitere Tools benoetigen einen gemeinsamen timeslotgebundenen Zugriff, ohne aus DFL, OS, PKI, Trusts oder Coverage kuenstliche Schweregrade, IOEs, Abweichungen oder Reviews abzuleiten. Ein Readmodell vermeidet Datenkopien und eine weitere Migration, waehrend der getrennte SourceKind den Vertrag eindeutig haelt. |
| 2026-08-23 | PingCastle Environment Context erhaelt einen eigenen Profilversionsschnitt 1.3.0 bei unveraendertem kanonischem Finding-Kompatibilitaetsschluessel `pingcastle-canonical-v1.1`. Profil 1.2.0 bleibt append-only historisch erhalten und wird als reimportbeduerftig bewertet; 1.3.0 ist der einzige aktuelle Stand. | Die Context-Erfassung war zuvor unter derselben sichtbaren Profilnummer wie contextlose Importe ausgerollt. Versionsgleichheit konnte deshalb einen technisch alten Backendimport faelschlich als `Current` markieren. Der Profilversionsschnitt macht die Datenfaehigkeit deterministisch, ohne Findings umzudeuten oder Altdaten umzuschreiben. |
| 2026-08-24 | PingCastle-Profil 1.4.0 und Extractor 2.1.0 verwenden fuer bekannte Environment-Subjects die normalisierten sichtbaren PingCastle-Bezeichnungen und bilden jeden sichtbaren Environment-Unterpunkt als Punktpfad unter seinem Hauptbereich ab. Der Extractor bindet Tabellen ueber den unmittelbar vorangehenden H2-Berichtstitel, ordnet leere Tabellen ueber Row Count/Presence ein und erzeugt fuer Heading-only-Unterpunkte ausschliesslich eine quellentreue Presence-Beobachtung. Prosa-Fakten fuer Entra ID Configuration, Backup, LAPS, WEF und krbtgt erhalten explizite Pfade; Finding-Regeldetailbereiche bleiben ausgeschlossen. Profil 1.3.0 bleibt append-only historisch und wird reimportbeduerftig; der Finding-Kompatibilitaetsschluessel bleibt `pingcastle-canonical-v1.1`. | Die vorherige ASSESSOR-Taxonomie `active-directory-*` und reine Section-Key-Klassifikation entsprachen weder der sichtbaren Berichtshierarchie noch Unterpunkten ohne gefuellte Tabelle. Punktpfade erhalten diese Hierarchie im flachen Subject-Feld, waehrend Originalwerte, Tabellen-/Heading-Provenienz und der generische Fallback erhalten bleiben und keine Finding-Semantik in Environment Context gelangt. |
| 2026-08-23 | Geoeffnete Pathfinder-Kacheln werden in einer eigenen, nach Kanten und Knoten gerenderten SVG-Kartenebene dargestellt. Die Platzierung waehlt anhand des sichtbaren Raums links oder rechts des Bezugspunkts, haelt einen festen Abstand, bleibt innerhalb der Graphflaeche und verschiebt mehrere Karten kollisionsfrei vertikal. Finding-Kacheln reservieren feste Bereiche fuer maximal fuenf Titelzeilen, zwei vollstaendige Badgezeilen und die rechte Aktionsspalte; mehr als vier Badges werden fachlich priorisiert und mit `+N` zusammengefasst. | SVG-Malreihenfolge und DOM-Nachbarschaft hatten spaeter gezeichnete Punkte vor Karten gelegt. Eine eigene letzte Ebene beseitigt diesen Fehler deterministisch. Feste Inhaltsbereiche verhindern teilweise abgeschnittene Badges; ein sichtbarer Ueberlaufzaehler erhaelt die Information, ohne die bestaetigte Kachelgroesse oder das gemeinsame Zoomverhalten zu veraendern. |
| 2026-08-23 | Die bildlich bestaetigte Pathfinder-Kachel verwendet eine feste, vollstaendige Vorlage statt der zuvor verdichteten 148-mal-72-px-Findingstruktur: Findings werden intern auf 240 mal 176 px, Rollen auf 148 mal 72 px gesetzt und beide einmalig mit dem konstanten Graphfaktor 0,4 eingebettet. Danach skaliert die komplette Komposition ausschliesslich gemeinsam mit der SVG-Kamera. CIS-Hierarchie-Badges gehoeren wieder zur Finding-Kachel. | Das Negativbeispiel zeigte, dass die kleine logische Findingflaeche bei starker Graphvergroesserung Titel und Metadaten uebergross und abgeschnitten darstellte. Eine feste ausreichend grosse Vorlage bewahrt Titel, Status, Gewicht, CIS-Kategorien und Aktionen; der konstante Einbettungsfaktor veraendert sich beim Zoomen nicht und laesst die Kachel wie ein Bild skalieren. Diese Bildreferenz ersetzt die unmittelbar vorherige Entscheidung, CIS-Badges in der Pathfinder-Kachel auszulassen. |
| 2026-08-23 | Der Backend-Verbindungszustand steht in jedem Bereichsheader unmittelbar vor dem Clientnamen und wird ohne Text oder Pillenrahmen als Paar aus einem roten und einem gruenen 9-mal-9-px-Quadrat dargestellt. Gruen ist bei bestaetigter Verbindung voll sichtbar; Rot ist bei fehlender oder noch ungepruefter Verbindung voll sichtbar, der jeweils andere Zustand bleibt gedimmt. | Die kompakte binaere Anzeige bewahrt den technischen Zustand, ohne den ohnehin knappen rechten Headerbereich mit `Server connected` zu belegen. Ein dauerhaftes Rot-/Gruen-Paar macht beide Bedeutungen wiedererkennbar; ARIA-Label und Tooltip erhalten die textliche Aussage fuer assistive Technik. |
@ -1601,6 +1610,14 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
| Risiko | Gegenmaßnahme |
|---|---|
| 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. |
| 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. |
| 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. |
| 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. |
| Lokale Systemschriften, konkurrierende Font-Stacks oder frei gesetzte Schriftgroessen koennen zwischen Betriebssystemen und Ansichten unterschiedliche Laufweiten, Zeilenumbrueche und Hierarchien erzeugen. | Inter als lokale WOFF2-Abhaengigkeit ohne CDN laden, den Font-Stack und alle semantischen Schriftrollen zentral tokenisieren und Import, Hierarchie, Mindestgroesse, erlaubte Grossschreibung sowie das Fehlen abweichender Display-Schriften regressionspruefen. |
| Mittelgrosse, strukturell komplexe Scanner-HTML-Berichte koennen vor der groessenbasierten DOM-Umschaltung die isolierte 30-Sekunden-Parsergrenze erreichen; ein pauschal hoeheres Timeout wuerde problematische Eingaben nur laenger binden. | Berichte oberhalb von 1 MiB frueh in den bestehenden begrenzten lexikalischen Extraktor routen, Workerzeit-, Speicher-, Tabellen- und Zeilengrenzen beibehalten und den 1,6-MiB-PingCastle-Pfad mit synthetischer Tabelle sowie `html-bounded-extraction`-Diagnose regressionspruefen. |
| Eine Anzeige der technischen Paketversion koennte faelschlich als aktueller fachlicher Verarbeitungsstand verstanden werden; eine lokal im React-Code hartcodierte Zahl koennte ausserdem vom Processingvertrag abweichen. | Die zentrale Konstante `CURRENT_ASSESSOR_PROCESSING_VERSION` aus dem Processingvertrag im Markenblock rendern, weder `v` noch `Version` voranstellen und Quelle, Wert, Position sowie kompakte Metadaten-Typografie regressionspruefen. |
| Ein Scanner-Reimport koennte den alten Lauf vor erfolgreicher Validierung loeschen, den falschen Timeslot beziehungsweise ein anderes Werkzeug ersetzen oder alte und neue Ergebnisse gleichzeitig in Work und Result DB anzeigen. | Vorschau und Abschluss kryptografisch an dasselbe Projekt-, Assessment- und ScanRun-Ziel binden, das erkannte Werkzeug gegen den aktiven Vorgaenger pruefen und Supersession samt neuem kanonischem Lauf in einer Repositorymutation committen. Den alten Lauf nicht loeschen; aktive Read-Modelle filtern `supersededByScanRunId`, waehrend der Projektexport beide Laeufe und ihre Beziehung erhaelt. |
| Ein Bericht desselben Werkzeugs mit gleichem Dateinamen oder Datum koennte faelschlich als Reimport akzeptiert werden; bei Mehrdatei-Reports koennte eine Teilmenge oder eine zusaetzliche Datei den falschen Bestand ersetzen. Ein Client koennte ausserdem eine reine Vorschaupruefung umgehen. | Anzahl und vollstaendiges SHA-256-Multiset der Originaldateien in Preview und Complete vergleichen und dieselbe Invariante in Local- und PostgreSQL-Repository sowie beim Uebernehmen bereits importierter Ersatzlaeufe erzwingen. Fehlende Originalhashes, Offline-Fallback und jede Abweichung fail closed ablehnen; der bestehende Scan bleibt aktiv. |
@ -1725,6 +1742,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
| UI-Tabellenprojektion und Candidate-Mapping erzeugen unterschiedliche Findinganzahlen oder semantische Werte. | Candidate-Mapping als einzige Persistenzquelle für neue Importe, identische Vorschauzählung, Diagnose nicht ausgewählter Tabellen, validierte Bestätigungs-IDs, Legacy-Fallback nur ohne Mapping sowie format- und scannerübergreifende Paritätsregressionen. |
| Persistente Ingestion Jobs werden doppelt ausgeführt, bleiben nach Worker-Abbruch hängen oder erzeugen durch Wiederholungen falsche Änderungsstände. | Projektweit eindeutige Idempotenzschlüssel, atomare `SKIP LOCKED`-Claims, zeitlich begrenzte Leases, begrenzte Versuche, kontrollierte Lease-Recovery, Dead Letter und revisionsneutrale Wiederholungen beziehungsweise Leerlauf-Polls. |
| Unbekannte Berichtslayouts werden falsch erkannt. | Konfidenzen, generischer Fallback, Importvorschau und bestätigbare Mappings. |
| PingCastle benennt oder strukturiert bekannte Environment-Abschnitte, H2-Unterpunkte oder Tabellen kuenftig anders und eine Subject-Klassifikation greift nicht mehr oder zu breit. | Section-Key, unmittelbar vorangehenden sichtbaren H2-Titel, Tabellenrolle und Header kombinieren; Row Count/Presence fuer leere Tabellen und quellentreue Heading-Presence fuer tabellenlose Unterpunkte bewahren. Finding-Regeldetailabschnitte ausschliessen, unbekannte Strukturen als `pingcastle-table-row` erhalten und die vollstaendige sichtbare Environment-Hierarchie sowie negative Fallback-/Rule-Detail-Faelle synthetisch regressionspruefen. |
| Quelltools ändern ihre Exportformate. | Versionierte Profile, Fixture-Tests und Erkennung über mehrere Signale. |
| Ähnliche oder gleich betitelte Findings werden falsch zusammengeführt. | Importlokale unresolved Identitäten, automatische Zuordnung nur über stabile technische IDs, Eindeutigkeitsprüfung pro Stufe und Titelähnlichkeit ausschließlich als bestätigungspflichtiger Reviewvorschlag. |
| Große oder manipulierte Dateien blockieren die Anwendung. | Ressourcenlimits, sichere Parserkonfiguration, Belastungstests, gecachte Result-Verknüpfungen, memoierte Datensatznavigation, lazy gerenderte Result-Tabellen und verzögerte lokale Review-Persistenz. |
@ -1777,6 +1795,26 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
## 12. Änderungsprotokoll
| Datum | Änderung |
| 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-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-Labels nach Nutzerkorrektur verdichtet: Outcome (`Failed`/`Passed`), Weight, Severity, Category, Score, CIS- und MITRE-Zuordnungen verwenden in Reader und regulaeren Matrixkacheln nun einen zentralen 12-px-Badgetoken statt der bisherigen 13-px-Labelstufe. Farb-, Glyph-, Rahmen-, Reihenfolge- und Statussemantik bleiben unveraendert; inhaltstragender Text wird nicht verkleinert. Fuenf fokussierte Typografieregressionen, 736 Standardtests, Local-only-Pruefung, Designpruefer und vollstaendiger Client-/Serverbuild erfolgreich; 8 optionale PostgreSQL-Tests uebersprungen. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Finding-Typografie nach Nutzerkorrektur verdichtet: Der Reader-Titel und die Finding-Abschnittsueberschrift verwenden jetzt die bestehende H3-Rolle mit 18 px statt H2 mit 32 px; Titel in Trefferlisten und regulaeren Matrixkacheln verwenden die H4-Rolle mit 16 px statt der bisherigen 18-px-Heading-Rolle. Lange Scannerbezeichnungen beanspruchen damit weniger Zeilen, waehrend inhaltstragender Text die verbindliche 16-px-Untergrenze behaelt. Die Aenderung betrifft ausschliesslich bestehende Work-Selektoren; globale Seitentitel, Pathfinder-Micro-Cards, Daten-, Import- und Persistenzlogik bleiben unveraendert. Zwei neue Typografieregressionen, 735 Standardtests, Local-only-Pruefung, Designpruefer und vollstaendiger Client-/Serverbuild erfolgreich; 8 optionale PostgreSQL-Tests uebersprungen. Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Donut-Innenraum nach visueller Nutzerpruefung korrigiert: Zahl, Prozentzeichen und vorhandener Unterwert bleiben unveraendert im Ring, duerfen seine Kontur aber nicht beruehren. Die Zahl ist unabhaengig von der Einheit geometrisch zentriert; dreistellige Prozentwerte erhalten eine automatische Kompaktstufe. Fuer die kleineren Sidebar-Ringe sind Wert, Einheit und Einheitenabstand ueber eigene gemeinsame Tokens reduziert. 13 fokussierte Donut-, Sidebar- und Datahub-Regressionen, Local-only-Sicherheitspruefung, Designcheck mit 0 Rohfarben sowie Client-/Server-Produktionsbuild erfolgreich. Keine Daten-, Filter-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Donutstandard nach Nutzerreferenz vereinheitlicht und verbindlich dokumentiert: Datahub-Statuskarten und Sidebar-Severity-Karten verwenden statt offener 270-Grad-Gauges nun vollstaendige neutrale Ringspuren mit farbigem Uhrzeigersinnbogen ab 12 Uhr, flachen Enden und mittigem Prozentwert samt kleiner Einheit; der absolute Wert bleibt als dezente Sekundaerinformation erhalten. Dashboard-Statusringe verwenden dieselbe Spur und Strichstaerke. Die mehrteilige Risk-Severity-Verteilung behaelt ihre fachlichen Segmente, uebernimmt aber Geometrie, Spur, Strichstaerke, Startposition und zentrierte Prozentdarstellung. 730 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-Sicherheitspruefung, Designcheck mit 0 Rohfarben sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, Filter-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Typografiesystem vereinheitlicht und als verbindliche Projektvorgabe verankert: Inter Variable wird ausschliesslich lokal gebuendelt und ersetzt abweichende System- und Display-Schriften. Zentrale semantische Rollen definieren H1 bis H4, Fliesstext, UI-Labels, Metadaten, Tabellenkoepfe und Buttons; Uppercase ist auf Tabellenkoepfe und kurze technische Kennzeichnungen begrenzt. Datahub, Bereichsheader, Mandantendetail, Tabellen, Controls, Raw Data und Kennzahlen verwenden die gemeinsame Hierarchie. 730 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-Sicherheitspruefung, Designcheck mit 0 Rohfarben sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Designkonflikte bereinigt: Mandantenkarten und die gemeinsam verwendeten Data-/Pathfinder-Tiles tragen weder regulaere noch Hover-Schatten und verwenden keinen Hover-Lift; Interaktion bleibt ueber Haarlinie und Hintergrund sichtbar. Redundante Pathfinder-Null-Ueberschreibungen wurden entfernt, Radius-/Control-Verweise auf vorhandene Tokens vereinheitlicht, die dynamische Dashboard-Segmentbreite erhielt einen CSS-Fallback und berechnete Result-Spaltenbreiten bleiben als erlaubte Geometrie gekapselt. Die Schattenobergrenze fuer `legacy-views.css` wurde von sechs auf drei abgesenkt. 728 Tests bestanden, 8 optionale PostgreSQL-Tests uebersprungen; Local-only-Sicherheitspruefung, vollstaendiger Designcheck mit 0 Rohfarben sowie Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-13A-HTML-Import gegen den reproduzierten 30-Sekunden-Abbruch mittelgrosser Scannerberichte nachgehaertet: Die gemeinsame Browser-/Node-Umschaltung verwendet den bereits begrenzten lexikalischen HTML-Extraktor nun ab mehr als 1 MiB statt erst oberhalb von 4 MiB. Worker-Timeout und Ressourcenlimits bleiben unveraendert. Ein synthetischer 1,6-MiB-PingCastle-Bericht bestaetigt Format, Profil, Tabelleninhalt und `html-bounded-extraction`; 30 fokussierte Parser-/Worker-/PingCastle-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Keine Realdaten gelesen, keine API-, Persistenz-, Schema- oder Profilversionsaenderung; Phase 13A bleibt abgeschlossen. |
| 2026-08-24 | Phase-7-Dashboard-IOE-Kennzahl als Filtereinstieg umgesetzt: `IOEs found` ist bei vorhandenem Trefferbestand anklickbar, setzt bestehende Dashboardfilter zurueck, uebergibt exakt die als explizite `IOE Found`/`IOEs found` gezaehlten Datensatz-IDs an die vorhandene Work-Teilmenge und oeffnet Work. Bei null Treffern bleibt das Steuerelement deaktiviert, damit kein ungefilterter Bestand erscheint. Zehn fokussierte Dashboard- und Filtertests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Ringspur nach Nutzerreferenz sichtbar gemacht: Der bislang mit dem Panel verschmelzende ungefuellte Anteil der oberen Statusringe verwendet nun den vorhandenen neutralgrauen Donut-Rahmentoken; Farbbogen, Geometrie, Werte und Interaktionen bleiben unveraendert. Sieben fokussierte Dashboardtests und vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Statusleiste nach Nutzerreferenz korrigiert: Die sechs gleichartigen Donutkarten sind durch eine fuenfteilige Leiste ersetzt. Compliance Score erhaelt den grossen Ring mit Formelhinweis; Deviation, Passed und Not Run verwenden kompakte Ringe mit realer Anzahl und Prozentwert. Not Applicable und Error stehen gemeinsam in einer Zusammenfassung neben `IOEs found`. Der IOE-Wert zaehlt ausschliesslich explizite `IOE Found`-/`IOEs found`-Quellenfelder und wird nicht aus Deviations abgeleitet; Statuskarten bleiben direkte Filtereinstiege in Work. 14 fokussierte Dashboard-, Donut- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Risk-Profile-Selektor um `Full Risk Profile` erweitert: Dieser Modus umfasst alle aktiven Findings, zeigt links statt Kategorien die real vorhandenen Werkzeuge mit ueber mehrere Zeitstaende aggregierten Findingzahlen und berechnet Severity-Ring, Marked-Zahl und Deviation-Nenner ueber den Gesamtbestand. Ein Werkzeugklick oeffnet Work mit genau diesem Quellenfilter; Werkzeugprofile behalten links ihre eigenen Kategorien. PingCastle bleibt beim ersten Oeffnen der Standard, sofern vorhanden. 18 fokussierte Dashboard-, Facetten- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-PingCastle-Risk-Profile um explizite Nullkategorien korrigiert: Die vier feste Scanner-Skala Anomalies, Stale Objects, Privileged Accounts und Trusts wird aus den strukturierten `risk.*`-Environment-Beobachtungen aufgebaut und filtert Nullwerte nicht mehr weg; `Trusts 0` bleibt damit sichtbar. Aeltere Importe ohne Risk-Observations fallen auf die werkzeugbezogenen Facettenzaehler zurueck, behalten aber alle vier Zeilen. Environment Snapshot wird fuer Directory Overview und Risk Profile gemeinsam einmal geladen. 15 fokussierte Dashboard- und Facettentests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Risk-Profile werkzeugbezogen korrigiert: Die bisherige statische Panelueberschrift ist ein klickbarer Tool-Selektor fuer PingCastle, PurpleKnight, CIS-CAT, Maester und alle weiteren real vorhandenen Scanquellen. Mehrere Zeitstaende desselben Werkzeugs werden zu genau einer Auswahl gruppiert; PingCastle ist der Standard, wenn vorhanden. Kategorien, Severity-Ring, Legendenzahlen, Marked-Zahl und Deviation-Nenner werden ausschliesslich aus den Datensaetzen des gewaehlten Werkzeugs neu gezaehlt. Kategorie- und Severity-Klicks uebergeben Werkzeug plus Auswahl gemeinsam an Work. 17 fokussierte Dashboard-, Facetten- und Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Unterzeile nach Nutzerreferenz korrigiert: Directory Overview zeigt vier getrennte Coverage-Kennzahlen oberhalb eines festen vierspaltigen Faktenrasters; die bisherigen Coverage-Chips und die wiederholte Summenzeile entfallen. Das rechte Panel ist nun ein Risk Profile mit `Marked for report` im Kopf, den vier staerksten realen Kategorien links sowie einem segmentierten Severity-Ring und Legende rechts. Kategorien und Severity bleiben direkte Filtereinstiege in Work; bei fehlenden Facetten erscheinen ehrliche Leerzustaende. Sieben fokussierte Dashboard-/Inset-Tests und vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Der bekannte Designcheck bleibt ausschliesslich an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard-Scanquellen nach Nutzerkorrektur gekuerzt: Die Legende der Scan Source Distribution entfernt ausschliesslich einen abschliessenden gueltigen ISO-Tag beziehungsweise ISO-Zeitstempel aus `Werkzeug · Zeitstempel`; Werkzeugname, interner Facettenwert, Provenienz und Filter-Sprung nach Work bleiben unveraendert. Drei fokussierte Tests und Client-Produktionsbuild erfolgreich; keine Import-, API-, Persistenz- oder Schemaaenderung. |
| 2026-08-24 | Phase-7-Dashboard nach Nutzerreferenz als kompaktes Analyseboard neu geordnet: Der bestehende Donut-Standard zeigt Compliance Score sowie reale Statuszahlen fuer Deviation, Passed, Not Applicable, Not Run und Error; Statuskarten bleiben direkte Filtereinstiege in Work. Severity und Scanquellen erscheinen als zweispaltige segmentierte Verteilungen mit anklickbarer Legende, Directory Overview und Reportmarkierung bilden die untere Informationszeile. Der verbindliche 20-px-Panelinset und 16-px-Inhaltstext bleiben erhalten; keine erfundenen Kennzahlen, keine Import-, API-, Persistenz- oder Schemaaenderung. Neun fokussierte Layout-/Donut-/Inset-Tests sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 721 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; ein fachfremder CRLF-sensitiver Quelltexttest in `results-browser-timeslot-scope.test.ts` bleibt rot. Der bekannte Designcheck bleibt an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Phase 7 bleibt abgeschlossen. |
| 2026-08-24 | Phase-13C-PingCastle-Environment-Subjects nachgehaertet: Profil 1.4.0, Mapping 1.2.0 und Extractor 2.1.0 verwenden normalisierte PingCastle-Berichtsnamen und Punktpfade fuer jeden sichtbaren Environment-Unterpunkt. Dazu gehoeren unter anderem `domain-information.entra-id-configuration`, die User-/Computer-Analysen, Admin-Groups-/Control-Paths-Unterpunkte, Trusts, PKI, alle Infrastructure-, Anomalies-, Password-Policies- und GPO-Unterpunkte. Leere Tabellen werden ueber Row Count/Presence, tabellenlose H2-Bereiche ueber eine quellentreue Presence-Beobachtung sichtbar; Backup, LAPS, WEF und krbtgt sind entsprechend dem Bericht unter Anomalies korrigiert. Finding-Regeldetailbereiche bleiben ausgeschlossen und unbekannte Tabellen behalten `pingcastle-table-row`. Ein ausschliesslich temporaerer, danach entfernter Read-only-Test gegen den ausdruecklich freigegebenen Dummy-Bericht bestaetigte 48 hierarchische Unterpunkt-Typen; persistierte Inhalte wurden nicht veraendert. 47 fokussierte synthetische Tests, Local-only-Sicherheitspruefung und Client-/Server-Produktionsbuild erfolgreich. Gesamtlauf: 718 bestanden, 8 optionale PostgreSQL-Tests uebersprungen; ein fachfremder CRLF-sensitiver Quelltexttest in `results-browser-timeslot-scope.test.ts` bleibt rot. Der Designcheck bleibt an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Keine API-, SQL- oder Migrationsaenderung; bestehende PingCastle-1.3.0-Importe benoetigen einen Reimport. Phase 13C bleibt in Arbeit. |
| 2026-08-24 | Phase-7/12/13A-Reimportidentitaet fail closed nachgehaertet: Ein zielgebundener Scanner-Reimport akzeptiert nur noch das exakte SHA-256-Multiset aller Dateien des zu ersetzenden ScanRuns. Preview prueft die hochgeladenen Bytes vor Parsing und Persistenz; Complete prueft die final ausgewaehlten Vorschauobjekte erneut; Local- und PostgreSQL-Repository erzwingen die Invariante bei kanonischem Ersatz und bei der Uebernahme eines bereits importierten Ersatzlaufs. Fehlende Originalhashes, abweichende Bytes, fehlende oder zusaetzliche Dateien sowie der nicht verifizierbare Offline-Fallback werden mit eindeutiger Fehlermeldung abgelehnt; der alte Scan bleibt aktiv. Datahub bietet einen vorhandenen Ersatzlauf nur bei Hashgleichheit an, und der Reimportdialog akzeptiert keine bloss wahrscheinliche Dublette mehr. 31 fokussierte synthetische Tests und der vollstaendige Client-/Server-Produktionsbuild erfolgreich; Gesamtlauf 718 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der Gesamtskriptstatus bleibt ausschliesslich am bekannten Designcheck fuer die unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; die Reimportaenderung erzeugt keinen neuen Designbefund. Keine Migration, keine Realdaten gelesen oder mutiert; Phasen 7, 12 und 13A bleiben abgeschlossen. |
| 2026-08-24 | Phase-7/12-Datahub-Readiness auf mandantenweite Vergleichsbereitschaft korrigiert: Die Kachel fasst nicht mehr nur den neuesten `Open`-Timeslot zusammen, sondern alle aktiven scannergebundenen Timeslots. Sobald irgendein aktiver Altstand einen Reimport auf den aktuellen ASSESSOR-Verarbeitungsvertrag benoetigt, erscheint der Mandant gelb und `Manage` dauerhaft; inkompatible oder unbekannte Vertraege bleiben rot. `Manage` klappt saemtliche betroffenen Timeslots automatisch auf, auch wenn sie weder aktuell noch ausgewaehlt sind; `Open` oeffnet weiterhin den neuesten Timeslot. Damit kann ein aktueller neuer Stand einen fuer spaetere Lifecycle-Vergleiche relevanten Altstand nicht mehr gruen ueberdecken. Zwoelf fokussierte Datahub-Tests sowie Client-Bundle und Server-Produktionsbuild erfolgreich; Gesamtlauf 715 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der vollstaendige Client-Typecheck ist durch zwei parallele Pathfinder-Typfehler in `ObsidianGraphView.tsx` blockiert; der bekannte Designcheck bleibt nur an den unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot. Keine API-, Persistenz-, Schema- oder Realdatenaenderung; Phase 7 und 12 bleiben abgeschlossen. |
| 2026-08-24 | Phase-7-Kopfbereich um den dauerhaft sichtbaren ASSESSOR-Verarbeitungsstand erweitert: Unter dem Markennamen erscheint ausschliesslich die zentrale Processing-Version, aktuell `1.3.0`, ohne `Version`, `v` oder weiteren Begleittext. Die Anzeige verwendet `CURRENT_ASSESSOR_PROCESSING_VERSION` aus dem Processingvertrag und ist bewusst von der technischen npm-Paketversion getrennt. Kompakte Metadaten-Typografie und tabellarische Ziffern veraendern Logo, Navigation und Kopfgeometrie nicht. Fuenf fokussierte Header-/Versionsregressionen sowie vollstaendiger Client-/Server-Produktionsbuild erfolgreich; Gesamtlauf 712 bestanden, 8 optionale PostgreSQL-Tests uebersprungen und Local-only gruen. Der Gesamtskriptstatus bleibt ausschliesslich am bekannten Designcheck fuer die unveraenderten Schattenregeln in `datahub-redesign.css` Zeilen 39/42 rot; die Versionsanzeige erzeugt keinen neuen Befund. Keine API-, Persistenz-, Schema- oder Realdatenaenderung; Phase 7 bleibt abgeschlossen. |
@ -2041,5 +2079,3 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
| 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. |

10
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "assessor",
"version": "0.1.0",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",
"pg": "^8.22.0",
"react": "19.2.4",
@ -961,6 +962,15 @@
}
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",

View File

@ -21,6 +21,7 @@
"test:watch": "vitest --config vitest.config.ts"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"jsdom": "^29.1.1",
"pg": "^8.22.0",
"react": "19.2.4",

View File

@ -17,6 +17,7 @@ export interface RepositoryEnvironment {
ASSESSOR_DATABASE_SSL?: string;
ASSESSOR_DATABASE_POOL_SIZE?: string;
ASSESSOR_POSTGRES_DB?: string;
ASSESSOR_POSTGRES_HOST?: string;
ASSESSOR_POSTGRES_USER?: string;
ASSESSOR_POSTGRES_PASSWORD?: string;
ASSESSOR_POSTGRES_PORT?: string;
@ -38,20 +39,26 @@ function sslConfiguration(value: string | undefined): PoolConfig['ssl'] {
throw new Error('ASSESSOR_DATABASE_SSL muss disable oder require sein.');
}
export function repositoryConnectionString(environment: RepositoryEnvironment): string | undefined {
const explicitConnectionString = environment.ASSESSOR_DATABASE_URL?.trim();
if (explicitConnectionString) return explicitConnectionString;
const password = environment.ASSESSOR_POSTGRES_PASSWORD?.trim();
if (!password) return undefined;
const host = environment.ASSESSOR_POSTGRES_HOST?.trim() || '127.0.0.1';
return `postgresql://${encodeURIComponent(environment.ASSESSOR_POSTGRES_USER?.trim() || 'assessor')}:${encodeURIComponent(password)}@${host}:${environment.ASSESSOR_POSTGRES_PORT?.trim() || '5432'}/${encodeURIComponent(environment.ASSESSOR_POSTGRES_DB?.trim() || 'assessor')}`;
}
export async function createRepositoryRuntime(environment: RepositoryEnvironment = {
ASSESSOR_DATABASE_URL: process.env.ASSESSOR_DATABASE_URL,
ASSESSOR_DATABASE_SSL: process.env.ASSESSOR_DATABASE_SSL,
ASSESSOR_DATABASE_POOL_SIZE: process.env.ASSESSOR_DATABASE_POOL_SIZE,
ASSESSOR_POSTGRES_DB: process.env.ASSESSOR_POSTGRES_DB,
ASSESSOR_POSTGRES_HOST: process.env.ASSESSOR_POSTGRES_HOST,
ASSESSOR_POSTGRES_USER: process.env.ASSESSOR_POSTGRES_USER,
ASSESSOR_POSTGRES_PASSWORD: process.env.ASSESSOR_POSTGRES_PASSWORD,
ASSESSOR_POSTGRES_PORT: process.env.ASSESSOR_POSTGRES_PORT
}): Promise<RepositoryRuntime> {
const explicitConnectionString = environment.ASSESSOR_DATABASE_URL?.trim();
const localPassword = environment.ASSESSOR_POSTGRES_PASSWORD?.trim();
const connectionString = explicitConnectionString || (localPassword
? `postgresql://${encodeURIComponent(environment.ASSESSOR_POSTGRES_USER?.trim() || 'assessor')}:${encodeURIComponent(localPassword)}@127.0.0.1:${environment.ASSESSOR_POSTGRES_PORT?.trim() || '5432'}/${encodeURIComponent(environment.ASSESSOR_POSTGRES_DB?.trim() || 'assessor')}`
: undefined);
const connectionString = repositoryConnectionString(environment);
if (!connectionString) {
return {
repository: new LocalAssessorRepository(),

View File

@ -39,7 +39,7 @@ function AreaIcon({ area }: { area: AppArea }) {
const AREA_TOPBAR_TITLE: Record<AppArea, string> = {
'db-structure': 'DB Structure',
'data-hub': 'Data Hub',
dashboard: 'Dashboard',
dashboard: 'Client Info',
graph: 'Pathfinder',
work: 'Work',
results: 'Result DB'
@ -52,7 +52,7 @@ const AREA_TOPBAR_TITLE: Record<AppArea, string> = {
const NAV_AREAS = [
['db-structure', 'DB Structure'],
['data-hub', 'Datahub'],
['dashboard', 'Dashboard'],
['dashboard', 'Client Info'],
['work', 'Work'],
['results', 'Result DB'],
['graph', 'Pathfinder']

View File

@ -18,6 +18,8 @@ interface DonutMetricCardProps {
value: number;
percentage: number;
tone: DonutMetricTone;
valueSuffix?: string;
showPercentage?: boolean;
className?: string;
active?: boolean;
onClick?(): void;
@ -28,26 +30,30 @@ export function DonutMetricCard({
value,
percentage,
tone,
valueSuffix = '',
showPercentage = true,
className,
active,
onClick
}: DonutMetricCardProps) {
const boundedPercentage = Math.min(100, Math.max(0, Math.round(percentage)));
const arcLength = boundedPercentage * 0.75;
const dashArray = String(arcLength) + ' ' + String(100 - arcLength);
const dashArray = String(boundedPercentage) + ' ' + String(100 - boundedPercentage);
const progressStyle = { '--donut-progress-dash': dashArray } as CSSProperties;
const classes = ['donut-metric-card', 'donut-metric-card--' + tone, className].filter(Boolean).join(' ');
const ariaLabel = label + ': ' + value.toLocaleString('en-US') + ', ' + boundedPercentage + '%';
const content = <>
<h3 className="donut-metric-card__title">{label}</h3>
<div className="donut-metric-card__plot">
<svg viewBox="0 0 200 200" aria-hidden="true">
<circle className="donut-metric-card__track" cx="100" cy="100" r="78" pathLength="100" strokeDasharray="75 25" />
<circle key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" cx="100" cy="100" r="78" pathLength="100" strokeDasharray={dashArray} style={progressStyle}/>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100" />
<circle key={label + '-' + value + '-' + boundedPercentage} className="donut-metric-card__progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={dashArray} style={progressStyle}/>
</svg>
<div className="donut-metric-card__values">
<strong>{value.toLocaleString('en-US')}</strong>
<span>{boundedPercentage}%</span>
<div className={`donut-metric-card__primary${boundedPercentage >= 100 ? ' is-compact' : ''}`}>
<strong>{showPercentage ? boundedPercentage : value.toLocaleString('en-US')}{showPercentage ? '' : valueSuffix}</strong>
{showPercentage && <span>%</span>}
</div>
{showPercentage && <small>{value.toLocaleString('en-US')}{valueSuffix}</small>}
</div>
</div>
</>;

View File

@ -21,6 +21,10 @@ function clampColumnWidth(width: number): number {
return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Math.round(width)));
}
function columnWidthStyle(width: number): CSSProperties {
return { width: `${width}px` };
}
function measuredColumnWidths(table: HTMLTableElement, columnCount: number): number[] {
const headers = Array.from(table.tHead?.rows[0]?.cells ?? []);
return Array.from({ length: columnCount }, (_, index) => clampColumnWidth(headers[index]?.getBoundingClientRect().width || MIN_COLUMN_WIDTH));
@ -101,7 +105,7 @@ export function ResizableResultTable({ headers, children, enabled = false, class
return (
<table ref={tableRef} className={`${className}${enabled ? ' is-column-resizable' : ''}${appliedWidths.length ? ' has-custom-column-widths' : ''}`} style={tableStyle}>
{enabled && <caption className="result-column-instructions">Column widths can be changed by dragging a header separator or fitted by double-clicking it.</caption>}
{appliedWidths.length > 0 && <colgroup>{appliedWidths.map((width, index) => <col key={index} style={{ width: `${width}px` }} />)}</colgroup>}
{appliedWidths.length > 0 && <colgroup>{appliedWidths.map((width, index) => <col key={index} style={columnWidthStyle(width)} />)}</colgroup>}
<thead><tr>{headers.map((header, index) => <th key={index}>{header}{enabled && index < columnCount - 1 && <span
className="result-column-resizer"
role="separator"

View File

@ -29,7 +29,8 @@ export const PROCESSING_VERSION_MANIFESTS: readonly ProcessingVersionManifest[]
{ profileId: 'pingcastle', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'pingcastle-canonical-v1', changeClass: 'semantic-breaking', reimportRequired: true, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.1.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.1.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'semantic-breaking', reimportRequired: false, affectedDimensions: ['outcome', 'isIoe'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'presentation-only', reimportRequired: true, affectedDimensions: ['evidence', 'presentation'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation'], current: true },
{ profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation'], current: false },
{ profileId: 'pingcastle', profileVersion: '1.4.0', mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['environment-context'], current: true },
{ profileId: 'cis-cat', profileVersion: '1.2.0', mappingVersion: '1.2.0', normalizationVersion: '1.2.0', presentationVersion: '1.2.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: true, affectedDimensions: ['evidence'], current: false },
{ profileId: 'cis-cat', profileVersion: '1.3.0', mappingVersion: '1.3.0', normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: ['evidence', 'presentation'], current: true },
{ profileId: 'maester', profileVersion: '1.0.0', mappingVersion: '1.0.0', normalizationVersion: '1.0.0', presentationVersion: '1.0.0', compatibilityKey: 'maester-canonical-v1', changeClass: 'compatible', reimportRequired: false, affectedDimensions: [], current: true }

View File

@ -3,72 +3,142 @@ import { AssessorApiClient } from '../../api/assessor-api-client';
import { useApp } from '../../app/AppProvider';
import { EmptyWorkspace } from '../../components/EmptyWorkspace/EmptyWorkspace';
import type { EnvironmentObservation, EnvironmentSnapshot } from '../../domain/environment-context';
import { deriveDynamicFacets, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
import type { FindingRecord } from '../../domain/finding';
import type { ImportProfile } from '../../domain/import-profile';
import type { Review } from '../../domain/review';
import { deriveDynamicFacets, matchesDynamicFacets, recountFacetsAgainstActiveFilters, type DynamicFacet, type DynamicFacetValue } from '../search-filter/dynamic-facets';
const assessorApi = new AssessorApiClient();
interface DashboardPanelProps {
/** Wechselt in die Arbeitsflaeche, nachdem der Filter gesetzt wurde. */
onOpenWork(): void;
}
/**
* Die Balkenbreite ist ein Messwert und kann deshalb nicht in src/styles
* stehen. Uebergeben wird nur der Anteil, die Darstellung bleibt im CSS.
*/
function shareStyle(share: number): CSSProperties {
return { '--share': `${share}%` } as unknown as CSSProperties;
function shareStyle(value: number): CSSProperties {
return { '--share': `${value}%` } as unknown as CSSProperties;
}
function share(count: number, total: number): number {
return total > 0 ? (count / total) * 100 : 0;
}
interface DistributionProps {
eyebrow: string;
export function dashboardExplicitIoeCount(records: FindingRecord[]): number {
return dashboardExplicitIoeRecordIds(records).length;
}
export function dashboardExplicitIoeRecordIds(records: FindingRecord[]): string[] {
return records
.filter(record => record.fields.some(field => /^ioes?\s+found$/i.test(field.value.trim())))
.map(record => record.id);
}
interface DashboardStatusMetricProps {
label: string;
value: number;
percentage: number;
tone: 'all' | 'deviation' | 'passed' | 'not-run';
large?: boolean;
helper?: string;
active?: boolean;
onClick?(): void;
}
function DashboardStatusMetric({ label, value, percentage, tone, large = false, helper, active, onClick }: DashboardStatusMetricProps) {
const roundedPercentage = Math.min(100, Math.max(0, Math.round(percentage)));
const content = <>
<div className={`dashboard-status-ring dashboard-status-ring-${tone}`}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="dashboard-status-ring-track" cx="60" cy="60" r="48" pathLength="100" />
<circle className="dashboard-status-ring-progress" cx="60" cy="60" r="48" pathLength="100" strokeDasharray={`${roundedPercentage} ${100 - roundedPercentage}`} />
</svg>
<span><span className={`dashboard-donut-primary${roundedPercentage >= 100 ? ' is-compact' : ''}`}><strong>{roundedPercentage}</strong><small>%</small></span></span>
</div>
<div className="dashboard-status-copy">
<span>{label}</span>
<strong>{large ? `${roundedPercentage} %` : <>{value.toLocaleString('en-US')}<small>{roundedPercentage}%</small></>}</strong>
{helper && <small>{helper}</small>}
</div>
</>;
const className = `dashboard-status-metric${large ? ' dashboard-status-metric-large' : ''}`;
return onClick
? <button className={className} type="button" aria-label={`${label}: ${value.toLocaleString('en-US')}, ${roundedPercentage}%`} aria-pressed={Boolean(active)} onClick={onClick}>{content}</button>
: <article className={className}>{content}</article>;
}
interface SegmentedDistributionProps {
heading: string;
question: string;
total: number;
values: DynamicFacetValue[];
severity?: boolean;
formatLabel?(label: string): string;
onSelect(value: string): void;
}
/**
* Eine Verteilung als Anteilsbalken. Jede Zeile ist zugleich der Sprung in die
* Arbeitsflaeche mit genau diesem Filter - eine Zahl, die man nicht verfolgen
* kann, beantwortet keine Frage.
*/
function Distribution({ eyebrow, heading, question, total, values, severity = false, onSelect }: DistributionProps) {
export function dashboardScanSourceLabel(label: string): string {
const parts = label.split(/\s+·\s+/);
const timestamp = parts.at(-1) ?? '';
if (parts.length < 2 || !/^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(timestamp) || Number.isNaN(Date.parse(timestamp))) return label;
return parts.slice(0, -1).join(' · ');
}
export interface DashboardRiskTool {
id: string;
label: string;
sourceValues: string[];
count: number;
}
export function dashboardRiskTools(values: DynamicFacetValue[]): DashboardRiskTool[] {
const tools = new Map<string, DashboardRiskTool>();
values.forEach(value => {
const label = dashboardScanSourceLabel(value.label);
const id = label.toLocaleLowerCase('en-US');
const existing = tools.get(id);
if (existing) {
existing.sourceValues.push(value.value);
existing.count += value.count;
} else tools.set(id, { id, label, sourceValues: [value.value], count: value.count });
});
return [...tools.values()];
}
/** Compact distribution based on the supplied dashboard reference. */
function SegmentedDistribution({ heading, total, values, severity = false, formatLabel = label => label, onSelect }: SegmentedDistributionProps) {
return (
<section className="dashboard-block">
<div className="datahub-section-heading">
<div><span>{eyebrow}</span><h3>{heading}</h3></div>
<strong>{total.toLocaleString('en-US')}</strong>
</div>
<p className="dashboard-question">{question}</p>
<section className="dashboard-block dashboard-distribution">
<h2>{heading}</h2>
{values.length === 0 ? (
<p className="dashboard-empty">This scan report carries no such classification.</p>
) : (
<ul className="dashboard-bars">
{values.map(value => (
<>
<div className="dashboard-segmented-bar" aria-hidden="true">
{values.map((value, index) => (
<span
className={`dashboard-segment ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`}
key={value.value}
style={shareStyle(share(value.count, total))}
/>
))}
</div>
<ul className="dashboard-distribution-legend">
{values.map((value, index) => (
<li key={value.value}>
<button
className={`dashboard-bar${severity ? ` severity-${value.value}` : ''}`}
className="dashboard-legend-button"
type="button"
onClick={() => onSelect(value.value)}
title={`Open in Work, filtered by ${value.label}`}
>
<span className="dashboard-bar-label">{value.label}</span>
<span className="dashboard-bar-track" aria-hidden="true">
<span className="dashboard-bar-fill" style={shareStyle(share(value.count, total))} />
<span className={`dashboard-legend-swatch ${severity ? `severity-${value.value}` : `dashboard-viz-${index % 6 + 1}`}`} aria-hidden="true" />
<span className="dashboard-legend-copy">
<span>{formatLabel(value.label)}</span>
<strong>{value.count.toLocaleString('en-US')} <small>· {share(value.count, total).toFixed(0)}%</small></strong>
</span>
<span className="dashboard-bar-count">{value.count.toLocaleString('en-US')}</span>
<span className="dashboard-bar-share">{share(value.count, total).toFixed(0)}%</span>
</button>
</li>
))}
</ul>
</>
)}
</section>
);
@ -97,7 +167,7 @@ function displayValue(observation: EnvironmentObservation): string {
return String(value);
}
function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; scanRunIds?: string[] }) {
function useEnvironmentSnapshot(projectId?: string, scanRunIds?: string[]) {
const [snapshot, setSnapshot] = useState<EnvironmentSnapshot>();
const [failed, setFailed] = useState(false);
@ -112,6 +182,11 @@ function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; sc
return () => { active = false; };
}, [projectId, scanRunIds?.join('|')]);
return { snapshot, failed };
}
function EnvironmentOverview({ projectId, snapshot, failed }: { projectId?: string; snapshot?: EnvironmentSnapshot; failed: boolean }) {
const observations = useMemo(() => {
const byProperty = new Map<string, EnvironmentObservation>();
snapshot?.observations.forEach(observation => byProperty.set(observation.property, observation));
@ -122,78 +197,206 @@ function EnvironmentOverview({ projectId, scanRunIds }: { projectId?: string; sc
const operatingSystems = useMemo(() => [...new Set(snapshot?.observations
.filter(item => item.property === 'operating_system.name')
.map(displayValue) ?? [])], [snapshot]);
const highlightedSections = useMemo(() => snapshot?.sections.filter(section =>
/pki|certificate|trust|infrastructure|entra|laps|password|domain information|computer information/i.test(`${section.sectionKey} ${section.label}`)
) ?? [], [snapshot]);
if (!projectId) return null;
return (
<section className="dashboard-environment" aria-labelledby="environment-overview-heading">
<div className="datahub-section-heading">
<div><span>Environment</span><h3 id="environment-overview-heading">Directory overview</h3></div>
<div><h2 id="environment-overview-heading">Directory overview</h2></div>
{snapshot && <strong>{snapshot.sections.length.toLocaleString('en-US')} sections</strong>}
</div>
<p className="dashboard-question">What did the scanner observe about the environment beyond individual findings?</p>
{!snapshot && !failed && <p className="dashboard-empty">Loading scanner context…</p>}
{failed && <p className="dashboard-empty">Scanner context could not be loaded.</p>}
{snapshot && snapshot.sections.length === 0 && <p className="dashboard-empty">This import predates structured scanner-context capture.</p>}
{snapshot && (
<div className="dashboard-coverage-summary" aria-label="Directory coverage summary">
<span><strong>{snapshot.observations.length.toLocaleString('en-US')}</strong><small>typed observations</small></span>
<span><strong>{(snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent).toLocaleString('en-US')}</strong><small>empty or absent</small></span>
<span><strong>{snapshot.coverage.restricted.toLocaleString('en-US')}</strong><small>restricted</small></span>
<span><strong>{snapshot.coverage.incomplete.toLocaleString('en-US')}</strong><small>incomplete</small></span>
</div>
)}
{snapshot && (observations.length > 0 || operatingSystems.length > 0) && (
<dl className="dashboard-facts">
{observations.map(({ label, observation }) => (
<div key={observation.property}><dt>{label}</dt><dd>{displayValue(observation)}</dd></div>
))}
{operatingSystems.length > 0 && <div><dt>Operating systems</dt><dd>{operatingSystems.join(', ')}</dd></div>}
{operatingSystems.length > 0 && <div><dt>OS</dt><dd>{operatingSystems.join(', ')}</dd></div>}
</dl>
)}
{snapshot && highlightedSections.length > 0 && (
<div className="dashboard-coverage" aria-label="Captured environment areas">
{highlightedSections.map(section => (
<span key={section.id} className={`dashboard-state dashboard-state-${section.state}`} title={section.label}>
{section.label} · {section.state.replace('-', ' ')}
</span>
</section>
);
}
interface RiskProfileProps {
sourceFacet?: DynamicFacet;
categoryFacet?: DynamicFacet;
severityFacet?: DynamicFacet;
records: FindingRecord[];
profile: ImportProfile;
reviews: Record<string, Review>;
environmentSnapshot?: EnvironmentSnapshot;
onSelectTool(sourceValues: string[]): void;
onSelectCategory(value: string, sourceValues: string[]): void;
onSelectSeverity(value: string, sourceValues: string[]): void;
}
type RiskScoreObservation = Pick<EnvironmentObservation, 'property' | 'normalizedValue' | 'originalValue'>;
const pingCastleRiskCategoryCatalog = [
['risk.anomalies', 'Anomalies'],
['risk.stale_objects', 'Stale Objects'],
['risk.privileged_accounts', 'Privileged Accounts'],
['risk.trusts', 'Trusts']
] as const;
export function dashboardPingCastleRiskCategories(observations: RiskScoreObservation[], fallback: DynamicFacetValue[]): DynamicFacetValue[] {
return pingCastleRiskCategoryCatalog.map(([property, label]) => {
const observation = observations.find(item => item.property === property);
const sourceValue = observation?.normalizedValue ?? observation?.originalValue;
const numericValue = typeof sourceValue === 'number' ? sourceValue : Number(sourceValue);
const fallbackValue = fallback.find(value => value.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'));
return {
value: fallbackValue?.value ?? label.toLocaleLowerCase('en-US'),
label,
count: Number.isFinite(numericValue) ? numericValue : fallbackValue?.count ?? 0
};
});
}
const fullRiskProfileId = 'full-risk-profile';
function RiskProfile({ sourceFacet, categoryFacet, severityFacet, records, profile, reviews, environmentSnapshot, onSelectTool, onSelectCategory, onSelectSeverity }: RiskProfileProps) {
const tools = useMemo(() => dashboardRiskTools(sourceFacet?.values ?? []), [sourceFacet]);
const [selectedToolId, setSelectedToolId] = useState('');
const selectedTool = tools.find(tool => tool.id === selectedToolId)
?? tools.find(tool => tool.id.includes('pingcastle'))
?? tools[0];
const fullProfile = selectedToolId === fullRiskProfileId;
const effectiveProfileId = fullProfile ? fullRiskProfileId : selectedTool?.id ?? fullRiskProfileId;
const scopedRecords = useMemo(() => fullProfile
? records
: selectedTool && sourceFacet
? records.filter(record => matchesDynamicFacets(record, [sourceFacet], { [sourceFacet.id]: { values: selectedTool.sourceValues } }, reviews))
: [], [fullProfile, records, reviews, selectedTool, sourceFacet]);
const scopedFacets = useMemo(() => recountFacetsAgainstActiveFilters({
facets: [categoryFacet, severityFacet].filter((facet): facet is DynamicFacet => Boolean(facet)),
records: scopedRecords,
filters: {},
reviews,
statusFilter: 'all',
matchesStatus: (record, status) => profile.matchesStatus(record, status)
}), [categoryFacet, profile, reviews, scopedRecords, severityFacet]);
const scopedCategories = scopedFacets.find(facet => facet.id === categoryFacet?.id)?.values ?? [];
const categories = fullProfile
? tools.map(tool => ({ value: tool.id, label: tool.label, count: tool.count }))
: selectedTool?.id.includes('pingcastle')
? dashboardPingCastleRiskCategories(
environmentSnapshot?.observations.filter(observation => observation.tool.id === 'pingcastle') ?? [],
scopedCategories
)
: [...scopedCategories].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
const severities = scopedFacets.find(facet => facet.id === severityFacet?.id)?.values ?? [];
const deviations = scopedRecords.filter(record => profile.matchesStatus(record, 'deviation')).length;
const marked = scopedRecords.filter(record => reviews[record.id]?.include).length;
const severityTotal = severities.reduce((sum, value) => sum + value.count, 0);
let severityOffset = 0;
return (
<section className="dashboard-block dashboard-risk-profile">
<header className="dashboard-risk-header">
{tools.length > 0 ? <select className="dashboard-risk-tool" aria-label="Risk profile tool" value={effectiveProfileId} onChange={event => setSelectedToolId(event.target.value)}>
<option value={fullRiskProfileId}>Full Risk Profile</option>
{tools.map(tool => <option value={tool.id} key={tool.id}>{tool.label} Risk Profile</option>)}
</select> : <h2>Risk profile</h2>}
<div className="dashboard-marked-summary"><span>Marked for report</span><strong>{marked.toLocaleString('en-US')} / {deviations.toLocaleString('en-US')}</strong></div>
</header>
<div className="dashboard-risk-body">
{categories.length > 0 ? (
<ul className="dashboard-risk-categories">
{categories.slice(0, 4).map(category => (
<li key={category.value}>
<button type="button" onClick={() => {
if (fullProfile) {
const tool = tools.find(item => item.id === category.value);
if (tool) onSelectTool(tool.sourceValues);
} else if (selectedTool) onSelectCategory(category.value, selectedTool.sourceValues);
}} title={`Open in Work, filtered by ${category.label}`}>
<span>{category.label}</span><strong>{category.count.toLocaleString('en-US')}</strong>
</button>
</li>
))}
</ul>
) : <p className="dashboard-empty">No risk categories available.</p>}
<div className="dashboard-risk-severity">
{severities.length > 0 ? <>
<div className="dashboard-risk-donut" role="img" aria-label="Risk severity distribution: 100%">
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className="dashboard-risk-donut-track" cx="60" cy="60" r="48" pathLength="100" />
{severities.map(severity => {
const percentage = share(severity.count, severityTotal);
const offset = severityOffset;
severityOffset += percentage;
return <circle
className={`dashboard-risk-donut-segment severity-${severity.value}`}
cx="60"
cy="60"
r="48"
key={severity.value}
pathLength="100"
strokeDasharray={`${percentage} ${100 - percentage}`}
strokeDashoffset={-offset}
/>;
})}
</svg>
<span><span className="dashboard-donut-primary is-compact"><strong>100</strong><small>%</small></span></span>
</div>
<ul className="dashboard-risk-legend">
{severities.map(severity => (
<li key={severity.value}>
<button type="button" onClick={() => fullProfile
? onSelectSeverity(severity.value, [])
: selectedTool && onSelectSeverity(severity.value, selectedTool.sourceValues)} title={`Open in Work, filtered by ${severity.label}`}>
<i className={`severity-${severity.value}`} aria-hidden="true" />
<span>{severity.label}</span>
<strong>{severity.count.toLocaleString('en-US')}</strong>
</button>
</li>
))}
</ul>
</> : <p className="dashboard-empty">No severity distribution available.</p>}
</div>
</div>
)}
{snapshot && (
<p className="dashboard-context-coverage">
{snapshot.observations.length.toLocaleString('en-US')} typed observations · {snapshot.coverage.empty + snapshot.coverage.explicitlyAbsent} empty or absent · {snapshot.coverage.restricted} restricted · {snapshot.coverage.incomplete} incomplete
</p>
)}
</section>
);
}
export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
const app = useApp();
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, resetFilters, setView } = app;
const { dataset, activeSheet, activeProfile, reviews, setStatusFilter, setFacetFilter, setPathFindingIds, resetFilters, setView } = app;
const facets = useMemo(
() => (activeSheet ? deriveDynamicFacets(activeSheet, dataset, activeProfile, reviews) : []),
[activeProfile, activeSheet, dataset, reviews]
);
const environment = useEnvironmentSnapshot(app.targetProjectId, app.activeScanRunIds);
if (!dataset || !activeSheet) return <EmptyWorkspace />;
const total = activeSheet.analysis.records.length;
const statusFacet: DynamicFacet | undefined = facets.find(facet => facet.source === 'profile-status');
const severityFacet: DynamicFacet | undefined = facets.find(facet => facet.severityScale);
/*
* Herkunft der Datensaetze. In der Projektaggregation traegt sie das Feld
* "Quelle: Scan" ("<Werkzeug> · <Zeitpunkt>"), bei einem einzelnen Import die
* Ersatzfacette ueber den Dateinamen. `dataset.sheets.length` waere hier
* falsch: darin stecken auch die verlinkten Ergebnistabellen, nicht nur die
* Scanberichte.
*/
const categoryFacet: DynamicFacet | undefined = facets.find(facet => facet.role === 'category' || /^(?:category|kategorie)$/i.test(facet.label.trim()));
const sourceFacet: DynamicFacet | undefined = facets.find(facet =>
/^(?:quelle|source):\s*scan$/i.test(facet.label.trim()) || facet.source === 'provenance');
const reports = sourceFacet?.values.length ?? 0;
// "Abweichung" ist der Wert, den das Profil selbst als Befund fuehrt; er
// heisst nicht in jedem Werkzeug gleich, traegt aber immer diesen Schluessel.
const deviations = statusFacet?.values.find(value => value.value === 'deviation')?.count ?? 0;
const marked = Object.values(reviews).filter(review => review.include).length;
const statusCount = (value: string) => statusFacet?.values.find(item => item.value === value)?.count ?? 0;
const deviations = statusCount('deviation');
const passed = statusCount('passed');
const notApplicable = statusCount('not-applicable');
const notRun = statusCount('not-run');
const errors = statusCount('error');
const explicitIoeRecordIds = dashboardExplicitIoeRecordIds(activeSheet.analysis.records);
const explicitIoes = explicitIoeRecordIds.length;
const complianceScore = passed + deviations > 0 ? Math.round((passed / (passed + deviations)) * 100) : 0;
/* Der Statusfilter ueberlebt Navigationsspruenge. Nur ein geklicktes
Statusdiagramm darf ihn in `apply` bewusst ersetzen. */
const jump = (apply: () => void) => {
resetFilters();
setStatusFilter(app.statusFilter);
@ -204,62 +407,74 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
return (
<div className="dashboard-page">
{/* Kontext statt Kennzahlkachel: die Zahlen darunter gelten fuer genau
diesen Bestand, und das muss ohne Rueckfrage ablesbar sein. */}
<p className="dashboard-context">
<strong>{dataset.fileName}</strong>
<span>{reports.toLocaleString('en-US')} scan {reports === 1 ? 'report' : 'reports'}</span>
<span>{total.toLocaleString('en-US')} records</span>
</p>
<EnvironmentOverview projectId={app.targetProjectId} scanRunIds={app.activeScanRunIds} />
<div className="dashboard-columns">
<Distribution
eyebrow="Coverage"
heading="Result status"
question="How much of this scan actually produced a usable result?"
total={total}
values={statusFacet?.values ?? []}
onSelect={value => jump(() => setStatusFilter(value))}
<section className="dashboard-status-rack" aria-label="Result status overview">
<DashboardStatusMetric
label="Compliance Score"
value={complianceScore}
percentage={complianceScore}
tone="all"
large
helper="Passed / (Passed + Deviation)"
/>
<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>
<Distribution
eyebrow="Priority"
heading="Severity"
question="Which findings warrant the first look?"
<div className="dashboard-distribution-grid">
<SegmentedDistribution
heading="Severity distribution"
total={total}
values={severityFacet?.values ?? []}
severity
onSelect={value => severityFacet && jump(() => setFacetFilter(severityFacet.id, { values: [value] }))}
/>
<Distribution
eyebrow="Origin"
heading="Scan reports"
question="Which report contributed which share of the records?"
<SegmentedDistribution
heading="Scan source distribution"
total={total}
values={sourceFacet?.values ?? []}
formatLabel={dashboardScanSourceLabel}
onSelect={value => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: [value] }))}
/>
</div>
{/* Bearbeitungsstand: die einzige Zahl, die nicht aus dem Bericht,
sondern aus der eigenen Arbeit stammt. */}
<section className="dashboard-block">
<div className="datahub-section-heading">
<div><span>Progress</span><h3>Marked for the report</h3></div>
<strong>{marked.toLocaleString('en-US')}</strong>
</div>
<p className="dashboard-question">How much of the deviation backlog has been reviewed?</p>
<div className="dashboard-progress">
<span className="dashboard-bar-track" aria-hidden="true">
<span className="dashboard-bar-fill" style={shareStyle(share(marked, deviations))} />
</span>
<span className="dashboard-progress-legend">
{marked.toLocaleString('en-US')} of {deviations.toLocaleString('en-US')} deviations
</span>
</div>
</section>
<div className="dashboard-lower-grid">
<EnvironmentOverview projectId={app.targetProjectId} snapshot={environment.snapshot} failed={environment.failed} />
<RiskProfile
sourceFacet={sourceFacet}
categoryFacet={categoryFacet}
severityFacet={severityFacet}
records={activeSheet.analysis.records}
profile={activeProfile}
reviews={reviews}
environmentSnapshot={environment.snapshot}
onSelectTool={sourceValues => sourceFacet && jump(() => setFacetFilter(sourceFacet.id, { values: sourceValues }))}
onSelectCategory={(value, sourceValues) => categoryFacet && sourceFacet && jump(() => {
setFacetFilter(sourceFacet.id, { values: sourceValues });
setFacetFilter(categoryFacet.id, { values: [value] });
})}
onSelectSeverity={(value, sourceValues) => severityFacet && sourceFacet && jump(() => {
if (sourceValues.length) setFacetFilter(sourceFacet.id, { values: sourceValues });
setFacetFilter(severityFacet.id, { values: [value] });
})}
/>
</div>
</div>
);

View File

@ -34,10 +34,10 @@ import { toolIdForRecord } from '../../importers/status-normalization';
* zweizeilige Titel bleibt durch den Overscan unbemerkbar.
*/
const RECORD_ROW_HEIGHT = 78;
/** Mindestbreite einer Kachel, siehe `.tile-grid` in legacy-views.css. */
const TILE_MIN_WIDTH = 280;
/** `gap` von `.tile-grid` (--panel-inset-Token, 20px). */
const TILE_GAP = 20;
/** Verbindliche Inhaltskachelbreite, siehe `--tile-content-width`. */
const TILE_MIN_WIDTH = 320;
/** Einheitlicher Segmentabstand, siehe `--segment-gap`. */
const TILE_GAP = 8;
/** `padding` von `.tile-grid` (--panel-inset-Token, 20px je Seite). */
const TILE_GRID_INSET = 20;
/** Geschaetzte Kachelzeilenhoehe inklusive Zeilenabstand, aus demselben Grund
@ -316,7 +316,7 @@ export function DatasetViewer() {
// literalen Inline-Style-Objekts, damit die Darstellung sonst in
// src/styles bleibt (siehe FloatingResultsWindow.tsx fuer denselben
// Kompromiss bei einer anderen laufzeitberechneten Position).
const tileGridStyle = { gridTemplateColumns: `repeat(${tileColumns}, 1fr)` };
const tileGridStyle = { gridTemplateColumns: `repeat(${tileColumns}, minmax(var(--tile-content-width), 1fr))` };
const tileTopSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingTop };
const tileBottomSpacerStyle = { gridColumn: '1 / -1', height: tileWindow.paddingBottom };
const recordTopSpacerStyle = { height: recordWindow.paddingTop };
@ -332,7 +332,7 @@ export function DatasetViewer() {
{!records.length ? (
<EmptyWorkspace showOpenData={false} />
) : view === 'columns' ? (
<div className="viewer-scroll" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
<div className="viewer-scroll tile-view-scroll" ref={setTileScrollNode}><div className="tile-grid" style={tileGridStyle}>
{tileWindow.paddingTop > 0 && <div aria-hidden="true" style={tileTopSpacerStyle} />}
{records.slice(tileWindow.startIndex * tileColumns, tileWindow.endIndex * tileColumns).map(record => (
<TileCard key={record.id} record={record} title={recordTitle(record)} bookmarked={Boolean(reviews[record.id]?.include)} resultColor={resultColor(record.id)} toolId={activeSheet?.toolId ?? dataset?.profileId} search={search} onOpen={() => {

View File

@ -1,6 +1,10 @@
import type { DecodedHtmlDocument, DecodedHtmlTable } from './runtime-adapters';
const MAXIMUM_DOM_HTML_CHARACTERS = 4 * 1024 * 1024;
// Real-world scanner reports can exceed the isolated worker's time budget well
// before four MiB when their markup expands into a large DOM. Route reports
// above one MiB through the bounded lexical parser, which preserves the table
// and source-extractor contract without constructing the complete DOM tree.
const MAXIMUM_DOM_HTML_CHARACTERS = 1 * 1024 * 1024;
const MAXIMUM_EXTRACTED_TABLES = 10_001;
const MAXIMUM_EXTRACTED_ROWS_PER_TABLE = 250_001;
const MAXIMUM_EXTRACTED_ROWS_TOTAL = 500_001;

View File

@ -838,12 +838,75 @@ function semanticProperty(sectionKey: string, tableName: string, label: string):
return properties[normalized];
}
function semanticSubject(sectionKey: string): string {
if (/domaininformation/i.test(sectionKey)) return 'active-directory-domain';
if (/computerinformation|domaincontroller/i.test(sectionKey)) return 'active-directory-computer';
if (/trust/i.test(sectionKey)) return 'active-directory-trust';
if (/pki|certificate/i.test(sectionKey)) return 'public-key-infrastructure';
return 'pingcastle-table-row';
function subjectKey(value: string): string {
return value.toLocaleLowerCase('en-US').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'value';
}
function subsectionContext(document: DecodedSourceDocument): {
tableLabels: Map<number, string>;
headings: Array<{ sectionKey: string; label: string }>;
} {
const markup = sanitizeHtmlText(document.rawText).text;
const sections = sectionSpans(markup, true);
const headings: Array<{ start: number; label: string }> = [];
const pattern = /<h2\b[^>]*>([\s\S]*?)<\/h2\s*>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(markup))) {
const label = markupText(match[1] ?? '').trim();
if (label) headings.push({ start: match.index, label });
}
const tableLabels = new Map<number, string>();
findHtmlTableSpans(markup).forEach((table, tableOrdinal) => {
const containingSections = sections
.filter(section => table.start >= section.bodyStart && table.end <= section.bodyEnd)
.sort((left, right) => (right.end - right.start) - (left.end - left.start));
const rootSection = containingSections[0];
if (!rootSection) return;
const heading = headings
.filter(candidate => candidate.start >= rootSection.bodyStart && candidate.start < table.start)
.at(-1);
const sourceTableIndex = document.tables[tableOrdinal]?.index;
if (heading && sourceTableIndex !== undefined) tableLabels.set(sourceTableIndex, heading.label);
});
return {
tableLabels,
headings: headings.flatMap(heading => {
const section = sections
.filter(candidate => heading.start >= candidate.bodyStart && heading.start < candidate.bodyEnd)
.sort((left, right) => (left.end - left.start) - (right.end - right.start))[0];
return section ? [{ sectionKey: section.sectionKey, label: heading.label }] : [];
})
};
}
function semanticSubjectBase(sectionKey: string): string | undefined {
return /domaininformation/i.test(sectionKey) ? 'domain-information'
: /computerinformation|computer/i.test(sectionKey) ? 'computer-information'
: /user/i.test(sectionKey) ? 'user-information'
: /admingroups/i.test(sectionKey) ? 'admin-groups'
: /controlpathsanalysis/i.test(sectionKey) ? 'control-paths-analysis'
: /infrastructure/i.test(sectionKey) ? 'infrastructure'
: /anomalies/i.test(sectionKey) ? 'anomalies'
: /passwordpolicies/i.test(sectionKey) ? 'password-policies'
: /^sectiongpo$/i.test(sectionKey) ? 'gpo'
: /trust/i.test(sectionKey) ? 'trusts-details'
: /pki|certificate/i.test(sectionKey) ? 'pki'
: undefined;
}
function semanticSubject(sectionKey: string, subsectionLabel: string | undefined, tableName: string, headers: string[]): string {
const normalizedHeaders = new Set(headers.map(normalizedLabel));
if (/neverexpiresuser/i.test(sectionKey)) return 'user-information.password-age-distribution';
if (/enabledtrusteddelegationcomputer/i.test(sectionKey)) return 'computer-information.account-analysis';
if (/userinformation/i.test(sectionKey) && normalizedHeaders.has('nb user accounts')) {
return 'user-information.account-analysis';
}
if (/userinformation/i.test(sectionKey) && /^account\s+list$/i.test(tableName)) {
return 'user-information.honey-pot';
}
const base = semanticSubjectBase(sectionKey);
if (!base) return 'pingcastle-table-row';
return subsectionLabel ? `${base}.${subjectKey(subsectionLabel)}` : base;
}
function typedValue(value: string): { normalizedValue: string | number | boolean; valueType: EnvironmentObservationCandidate['valueType'] } {
@ -869,6 +932,7 @@ function contextObservations(
): EnvironmentObservationCandidate[] {
const observations: EnvironmentObservationCandidate[] = [];
const reportText = markupText(sanitizeHtmlText(document.rawText).text);
const subsections = subsectionContext(document);
const reportMetadata = /Date:\s*(\d{4}-\d{2}-\d{2})\s*-\s*Engine version:\s*([^\s<]+)/i.exec(reportText);
if (reportMetadata) {
addObservation(observations, {
@ -898,8 +962,13 @@ function contextObservations(
sections.forEach(section => {
section.tables.forEach((table, tableOrdinal) => {
const subsectionLabel = table.sourceTableIndex === undefined ? undefined : subsections.tableLabels.get(table.sourceTableIndex);
const tableSubjectType = semanticSubject(section.sectionKey, subsectionLabel, table.name, table.headers);
const tableSubject = tableSubjectType === 'pingcastle-table-row'
? { type: 'report-section', identity: section.sectionKey, label: section.label }
: { type: tableSubjectType, identity: `${section.sectionKey}:${subjectKey(subsectionLabel ?? table.name)}`, label: subsectionLabel ?? table.name };
addObservation(observations, {
subject: { type: 'report-section', identity: section.sectionKey, label: section.label },
subject: tableSubject,
property: `inventory.${propertyKey(section.sectionKey)}.${propertyKey(table.name)}.row_count`,
label: `${table.name} row count`, originalValue: table.rows.length, normalizedValue: table.rows.length,
valueType: 'number', state: table.rows.length ? 'observed' : (section.state === 'observed' ? 'empty' : section.state),
@ -908,7 +977,7 @@ function contextObservations(
});
if (!table.rows.length) {
addObservation(observations, {
subject: { type: 'report-section', identity: section.sectionKey, label: section.label },
subject: tableSubject,
property: `section.${propertyKey(section.sectionKey)}.table.${tableOrdinal}.presence`, label: table.name,
originalValue: null, valueType: 'object', state: section.state === 'observed' ? 'empty' : section.state,
sectionKey: section.sectionKey,
@ -925,17 +994,44 @@ function contextObservations(
const typed = typedValue(value);
const stableProperty = semanticProperty(section.sectionKey, table.name, label);
addObservation(observations, {
subject: { type: semanticSubject(section.sectionKey), identity: subjectIdentity, label: subjectIdentity },
subject: { type: tableSubjectType, identity: subjectIdentity, label: subjectIdentity },
property: stableProperty ?? `pingcastle.${propertyKey(section.sectionKey)}.${propertyKey(table.name)}.${propertyKey(label)}`,
label, originalValue: value, ...typed, state: 'observed', sectionKey: section.sectionKey,
source: { fileName: document.file.name, tableIndex: table.sourceTableIndex, rowIndex: rowIndex + 1, columnIndex },
attributes: { tableName: table.name }
attributes: { tableName: table.name, ...(subsectionLabel ? { subsectionLabel } : {}) }
});
});
});
});
});
const tableSubsections = new Set([...subsections.tableLabels.entries()].map(([sourceTableIndex, label]) => {
const section = sections.find(candidate => candidate.tables.some(table => table.sourceTableIndex === sourceTableIndex));
return section ? `${section.sectionKey}\u0000${label}` : '';
}));
const factSubsections = new Set([
'sectionDomainInformation\u0000Entra ID Configuration',
'sectionAnomalies\u0000Backup',
'sectionAnomalies\u0000LAPS',
'sectionAnomalies\u0000Windows Event Forwarding (WEF)',
'sectionAnomalies\u0000krbtgt (Used for Golden ticket attacks)'
]);
subsections.headings.forEach(({ sectionKey, label }) => {
const key = `${sectionKey}\u0000${label}`;
if (tableSubsections.has(key) || factSubsections.has(key)) return;
if (!sections.some(section => section.sectionKey === sectionKey)) return;
const base = semanticSubjectBase(sectionKey);
if (!base) return;
const type = `${base}.${subjectKey(label)}`;
addObservation(observations, {
subject: { type, identity: `${sectionKey}:${subjectKey(label)}`, label },
property: `section.${propertyKey(sectionKey)}.subsection.${propertyKey(label)}.presence`, label,
originalValue: null, valueType: 'object', state: 'observed', sectionKey,
source: { fileName: document.file.name, objectPath: `html/sections/${sectionKey}/subsections/${subjectKey(label)}` },
attributes: { subsectionLabel: label }
});
});
const proseFacts: Array<[RegExp, string, string, EnvironmentObservationCandidate['valueType']]> = [
[/Last backup date:\s*([^\s]+(?:\s+[^\s]+)?)/i, 'backup.last_detected_at', 'Last detected AD backup', 'datetime'],
[/Legacy LAPS installation date:\s*(Never|\d{4}-\d{2}-\d{2}(?:[ T][0-9:.+-]+Z?)?)/i, 'laps.legacy.installation_date', 'Legacy LAPS installation date', 'datetime'],
@ -949,14 +1045,20 @@ function contextObservations(
if (!value) return;
const absent = /^never$/i.test(value);
const typed = declaredType === 'number' ? { normalizedValue: Number(value), valueType: declaredType } : { normalizedValue: value, valueType: declaredType };
const factSubject: [string, string?] = property === 'backup.last_detected_at' ? ['anomalies.backup', 'sectionAnomalies']
: property.startsWith('laps.') ? ['anomalies.laps', 'sectionAnomalies']
: property === 'logging.wef.configuration_count' ? ['anomalies.windows-event-forwarding-wef', 'sectionAnomalies']
: property.startsWith('kerberos.krbtgt.') ? ['anomalies.krbtgt-used-for-golden-ticket-attacks', 'sectionAnomalies']
: ['active-directory-environment', undefined];
addObservation(observations, {
subject: { type: 'active-directory-environment' }, property, label, originalValue: value,
...typed, state: absent ? 'explicitly-absent' : 'observed', source: { fileName: document.file.name, objectPath: `html/facts/${property}` }
subject: { type: factSubject[0] }, property, label, originalValue: value,
...typed, state: absent ? 'explicitly-absent' : 'observed', sectionKey: factSubject[1],
source: { fileName: document.file.name, objectPath: `html/facts/${property}` }
});
});
if (/No Entra ID configuration has been found in this domain/i.test(reportText)) {
addObservation(observations, {
subject: { type: 'active-directory-domain' }, property: 'entra.configuration_detected', label: 'Entra ID configuration detected',
subject: { type: 'domain-information.entra-id-configuration' }, property: 'entra.configuration_detected', label: 'Entra ID configuration detected',
originalValue: false, normalizedValue: false, valueType: 'boolean', state: 'explicitly-absent', sectionKey: 'sectionDomainInformation',
source: { fileName: document.file.name, objectPath: 'html/facts/entra-configuration' }
});
@ -1168,7 +1270,7 @@ function isHealthcheckXml(document: DecodedSourceDocument): boolean {
export const pingCastleSourceExtractor: SourceExtractor = {
id: 'pingcastle-healthcheck-source-extractor',
version: '2.0.0',
version: '2.1.0',
detect(document) {
if (isHealthcheckXml(document)) {
const riskRuleCount = xmlRuleGroups(document).length;

View File

@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { AppProvider } from './app/AppProvider';
import '@fontsource-variable/inter/wght.css';
import './styles/layers.css';
import './styles/ui-tokens.css';
/* styles/ui-components.css wird absichtlich noch nicht global geladen.
@ -25,6 +26,7 @@ import './styles/import-workflow.css';
import './styles/legacy-views.css';
import './styles/obsidian-graph.css';
import './styles/functional-icons.css';
import './styles/tile-contract.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@ -21,11 +21,11 @@ export const pingCastleDefinition: ToolProfileDefinition = {
schemaVersion: 1,
id: 'pingcastle',
name: 'PingCastle',
version: '1.3.0',
version: '1.4.0',
processing: {
mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'compatible',
reimportRequired: true, affectedDimensions: ['environment-context', 'evidence', 'presentation']
reimportRequired: true, affectedDimensions: ['environment-context']
},
sourceSignals: [
{ scope: 'file-name', pattern: /ping[\s_.-]*castle|^ad[_-]hc[_-]/i, contribution: 0.5, message: 'Der Dateiname entspricht einem PingCastle-Healthcheck.' },

View File

@ -1,14 +1,19 @@
* { box-sizing: border-box; }
html, body, #root { width: 100%; min-width: 0; min-height: 100%; margin: 0; }
body { min-height: 100vh; overflow: hidden; color: var(--text); background: var(--bg); font: var(--ui-body-weight) var(--ui-body-size)/1.45 var(--font); }
body { min-height: 100vh; overflow: hidden; color: var(--text); background: var(--bg); font: var(--fw-regular) var(--type-body-size)/1.5 var(--font-ui); }
button, input, select, textarea { color: inherit; font: inherit; }
button, select { cursor: pointer; }
button:disabled, select:disabled { cursor: not-allowed; opacity: .42; }
h1, h2, h3, h4, p { margin-top: 0; }
h1 { font-size: var(--type-h1-size); font-weight: var(--fw-semibold); line-height: 1.1; letter-spacing: -.04em; }
h2 { font-size: var(--type-h2-size); font-weight: var(--fw-semibold); line-height: 1.15; letter-spacing: -.03em; }
h3 { font-size: var(--type-h3-size); font-weight: var(--fw-semibold); line-height: 1.25; letter-spacing: 0; }
h4 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.3; letter-spacing: 0; }
svg { display: block; width: 17px; height: 17px; }
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 { position: sticky; top: 0; z-index: 1; color: var(--muted); background: var(--panel-soft); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
td { font-size: var(--type-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; }
tbody tr:hover td { background: var(--panel-hover); }
pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
mark { padding: 1px 2px; color: var(--state-active-text); background: var(--state-active); }
@ -19,13 +24,13 @@ button, .btn, .icon-btn, .tab-btn, .bookmark-btn, .results-btn {
border: 1px solid var(--button-border);
color: var(--button-text);
background: var(--button-bg);
font-size: var(--ui-control-size);
font-weight: 500;
font-size: var(--type-button-size);
font-weight: var(--fw-semibold);
line-height: 1.15;
}
.select, .input {
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(--ui-control-size); font-weight: 500;
color: var(--text); background-color: var(--panel-raised); font-size: var(--type-label-size); font-weight: var(--fw-regular);
}
/* Verbindliche Buttonform: eine Hoehe, ein Radius, ein waagerechtes Polster.
Senkrecht traegt der Knopf kein eigenes Polster - die Hoehe kommt aus
@ -42,7 +47,7 @@ button, .btn, .icon-btn, .tab-btn, .bookmark-btn, .results-btn {
border: 1px solid var(--button-border);
color: var(--button-marked-text);
background: var(--button-marked-bg);
font-weight: 600;
font-weight: var(--fw-semibold);
}
.btn.primary:hover, .btn--primary:hover, .btn.active:hover, .tab-btn.active:hover, .bookmark-btn[aria-pressed="true"]:hover, .icon-btn[aria-pressed="true"]:hover, .results-btn.is-open:hover {
border-color: var(--button-border);

View File

@ -13,13 +13,13 @@ mark, .search-highlight {
.empty-brand-logo { position: relative; display: block; width: 82px; height: 82px; }
.empty-brand-logo img { position: absolute; inset: 0; width: 82px; height: 82px; object-fit: contain; }
.empty-brand-logo-light { opacity: 0; }
.empty-brand-mark strong { color: var(--heading); font-size: clamp(34px, 4vw, 58px); font-weight: 750; line-height: 1; letter-spacing: .08em; }
.empty-open-data { position: relative; display: flex; align-items: center; gap: 13px; width: min(310px, 100%); min-height: 50px; padding: 10px 20px; border: 0; border-radius: 0 var(--legacy-radius-md) var(--legacy-radius-md) 0; color: var(--text); background: var(--panel-raised); box-shadow: none; font-size: var(--ui-heading-size); font-weight: 750; text-align: left; }
.empty-brand-mark strong { color: var(--heading); font-size: clamp(34px, 4vw, 58px); font-weight: var(--fw-bold); line-height: 1; letter-spacing: .08em; }
.empty-open-data { position: relative; display: flex; align-items: center; gap: 13px; width: min(310px, 100%); min-height: 50px; padding: 10px 20px; border: 0; border-radius: 0 var(--legacy-radius-md) var(--legacy-radius-md) 0; color: var(--text); background: var(--panel-raised); box-shadow: none; font-size: var(--ui-heading-size); font-weight: var(--fw-semibold); text-align: left; }
.empty-open-data::before { content: ''; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--legacy-accent); }
.empty-open-data svg { flex: 0 0 25px; width: 25px; height: 25px; }
.empty-open-data:hover { color: var(--heading); background: var(--panel-hover); }
.empty-open-data:active { background: var(--panel-soft); }
.empty-supported-formats { color: var(--muted); font-size: var(--ui-body-size); font-weight: 500; line-height: 1.8; letter-spacing: .025em; }
.empty-supported-formats { color: var(--muted); font-size: var(--ui-body-size); font-weight: var(--fw-regular); line-height: 1.8; letter-spacing: .025em; }
html[data-theme="light"] .empty-brand-logo-dark { opacity: 0; }
html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
/* Die drei Light-Sonderregeln fuer .empty-open-data sind entfallen: die
@ -39,15 +39,15 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
.record-list .finding-card.active, .record-list .finding-card.active:hover, .record-list .finding-card.active:focus-visible { z-index: 1; outline: 0; border-color: transparent; border-left: 0; color: var(--state-selected-flat-text); background: var(--state-selected-flat); box-shadow: none; }
.record-list .finding-card.active h3, .record-list .finding-card.active .finding-card-status { color: var(--state-selected-flat-text); }
.record-list .finding-card-actions, .record-list .finding-badges { display: none; }
.record-list .finding-card h3 { margin-top: 1px; font-size: var(--ui-heading-size); font-weight: 700; line-height: 1.35; }
.record-list .finding-card h3 { margin-top: 1px; font-size: var(--type-h4-size); font-weight: var(--fw-content-medium); line-height: 1.35; }
.finding-card-status { display: none; }
.record-list .finding-card-status { display: block; margin: 5px 0 0; color: var(--muted); font-size: var(--ui-control-size); line-height: 1.2; }
.finding-hex { display: grid; place-items: center; width: 29px; height: 34px; margin-top: 1px; color: var(--sev-on); background: var(--severity, var(--muted)); clip-path: polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%); font-size: var(--ui-control-size); font-weight: 900; }
.finding-hex { display: grid; place-items: center; width: 29px; height: 34px; margin-top: 1px; color: var(--sev-on); background: var(--severity, var(--muted)); clip-path: polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%); font-size: var(--ui-control-size); font-weight: var(--fw-bold); }
.finding-hex > span { transform: translate(-3px, 1px); }
.reader-title-hex { position: relative; display: block; width: 30px; height: 34px; margin-top: 1px; background: var(--severity, var(--muted)); clip-path: polygon(50% 0,94% 24%,94% 76%,50% 100%,6% 76%,6% 24%); }
.reader-title-hex::after { content: ''; position: absolute; left: 2px; top: 14px; width: 8px; height: 4px; border-left: 2px solid var(--sev-on); border-bottom: 2px solid var(--sev-on); transform: rotate(-45deg); pointer-events: none; }
.finding-card h3 { margin: 0; color: var(--heading); font-size: var(--ui-heading-size); font-weight: 650; line-height: 1.42; overflow-wrap: anywhere; }
.workspace-label { display: inline-flex; align-items: center; justify-content: center; margin-left: 6px; padding: 1px 6px; border: 1px solid var(--border-default); border-radius: 0; color: var(--text-primary); background: var(--surface-input); font-size: var(--ui-label-size); font-weight: 700; font-variant-numeric: tabular-nums; vertical-align: middle; }
.finding-card h3 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); line-height: 1.42; overflow-wrap: normal; word-break: normal; hyphens: manual; }
.workspace-label { display: inline-flex; align-items: center; justify-content: center; margin-left: 6px; padding: 1px 6px; border: 1px solid var(--border-default); border-radius: 0; color: var(--text-primary); background: var(--surface-input); font-size: var(--ui-label-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; vertical-align: middle; }
.finding-badges, .mitre-line { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.finding-card-actions { display: grid; align-content: start; gap: 8px; }
.bookmark-btn { color: var(--button-text); border: 1px solid var(--button-border); border-radius: var(--btn-radius); background: var(--button-bg); }
@ -89,18 +89,18 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
/* Der Fundname nutzt die volle Breite der Lesespalte; die Schaltflaechen
ruecken in eine eigene Zeile darunter, statt den Titel zu beschneiden. */
.reader-heading { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--legacy-space-3) 16px; align-items: start; padding-bottom: 28px; border-bottom: 1px solid var(--line); }
.reader-heading h2 { margin: 0; color: var(--heading); font-size: var(--ui-reading-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.3; letter-spacing: 0; }
.reader-heading h2 { margin: 0; color: var(--heading); font-size: var(--type-detail-title-size); font-weight: var(--ui-strong-weight); line-height: 1.3; letter-spacing: 0; overflow-wrap: normal; word-break: normal; hyphens: manual; }
.reader-heading-actions { display: flex; grid-column: 1 / -1; align-items: center; justify-content: flex-end; gap: 10px; align-self: center; }
/* Der PingCastle-Score-Erlaeuterungsabsatz wurde samt Markup entfernt. */
.bookmark-label { color: var(--text); font-size: var(--ui-control-size); font-weight: var(--ui-label-weight); }
.results-detail-btn { min-width: 136px; }
.dashboard-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 28px auto 24px; }
.dashboard-grid label, .review-grid > label { display: grid; gap: 7px; color: var(--heading); font-size: var(--ui-value-size); font-weight: 750; letter-spacing: .035em; text-transform: uppercase; }
.dashboard-grid label, .review-grid > label { display: grid; gap: 7px; color: var(--heading); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); letter-spacing: 0; text-transform: none; }
.dashboard-grid input, .dashboard-grid select, .review-grid textarea { width: 100%; min-height: 39px; padding: 8px 11px; border: var(--border-hair) solid var(--line-strong); border-radius: var(--legacy-radius-sm); color: var(--text); background-color: var(--surface-inset); text-transform: none; }
.reader-summary, .review-grid, .all-fields { margin-top: 14px; padding: 20px 0; border: 0; border-bottom: 1px solid var(--line); background: transparent; }
/* Traegt seit der Zusammenfuehrung mit "Check title" die Pruefbeschreibung der
Regel, also einen Satz. Versalien waeren dafuer unlesbar. */
.reader-summary h3 { margin-bottom: var(--legacy-space-5); color: var(--heading); font-size: var(--ui-reading-heading-size); text-transform: none; }
.reader-summary h3 { margin-bottom: var(--legacy-space-5); color: var(--heading); font-size: var(--type-h3-size); text-transform: none; }
.reader-summary h4 { margin: 0 0 7px; color: var(--heading); font-size: var(--ui-reading-size); text-transform: uppercase; }
.reader-summary > div { padding: 0 0 15px; margin: 0 0 15px; border-bottom: 1px solid var(--line); }
.reader-summary > div:last-child { margin-bottom: 0; }
@ -424,12 +424,12 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
.donut-metric-rack {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: var(--space-2);
padding: var(--space-3);
border: var(--border-hair) solid var(--donut-border);
border-radius: var(--donut-rack-radius);
background: var(--donut-rack-surface);
box-shadow: var(--donut-rack-shadow);
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);
box-shadow: none;
}
.donut-metric-card {
position: relative;
@ -439,11 +439,11 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
min-height: var(--donut-card-min-h);
padding: var(--space-5) var(--space-3) var(--space-2);
overflow: hidden;
border: var(--border-hair) solid var(--donut-border);
border-radius: var(--donut-card-radius);
border: var(--border-hair) solid var(--tile-border);
border-radius: var(--tile-radius);
color: var(--donut-all);
background: linear-gradient(145deg, var(--donut-card-surface), var(--donut-card-surface-end));
box-shadow: var(--donut-card-shadow);
background: var(--tile-surface);
box-shadow: var(--tile-card-shadow);
}
.donut-metric-card::before {
position: absolute;
@ -478,7 +478,7 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
width: 100%;
height: 100%;
overflow: visible;
transform: rotate(135deg);
transform: rotate(-90deg);
}
.donut-metric-card__plot circle {
fill: none;
@ -487,7 +487,6 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
.donut-metric-card__track { stroke: var(--donut-track); }
.donut-metric-card__progress {
stroke: currentColor;
stroke-linecap: round;
animation: donut-progress-build var(--dur-data-build) var(--ease-data-build) both;
}
@keyframes donut-progress-build {
@ -505,20 +504,37 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: var(--space-4);
font-family: var(--font-donut-display);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.donut-metric-card__values strong {
.donut-metric-card__primary {
position: relative;
display: block;
}
.donut-metric-card__primary strong {
color: var(--donut-value);
font-size: var(--donut-value-size);
font-weight: var(--fw-semibold);
}
.donut-metric-card__values span {
margin-top: var(--space-2);
color: currentColor;
.donut-metric-card__primary span {
position: absolute;
left: calc(100% + var(--space-1));
bottom: 0;
color: var(--donut-value);
font-size: var(--donut-percent-size);
font-weight: var(--fw-semibold);
}
.donut-metric-card__primary.is-compact strong {
font-size: calc(var(--donut-value-size) * .85);
}
.donut-metric-card__primary.is-compact span {
font-size: var(--type-meta-size);
}
.donut-metric-card__values > small {
margin-top: var(--space-2);
color: var(--donut-label);
font-size: var(--type-meta-size);
font-weight: var(--fw-regular);
}
.donut-metric-card--all { color: var(--donut-all); }
@ -539,17 +555,17 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
.sidebar-severity-rack {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-2);
gap: var(--segment-gap);
flex: 1 1 auto;
min-height: 0;
margin: 0 var(--space-3) var(--space-2);
padding: var(--space-2);
padding: var(--segment-inset);
overflow-y: auto;
border: var(--border-hair) solid var(--donut-border);
border-radius: var(--donut-rack-radius);
background: var(--donut-rack-surface);
box-shadow: var(--donut-rack-shadow);
scrollbar-color: var(--border-strong) var(--donut-rack-surface);
border: var(--border-hair) solid var(--segment-border);
border-radius: var(--segment-radius);
background: var(--segment-surface);
box-shadow: none;
scrollbar-color: var(--border-strong) var(--segment-surface);
scrollbar-width: thin;
}
.sidebar-severity-card {
@ -563,15 +579,12 @@ html[data-theme="light"] .empty-brand-logo-light { opacity: 1; }
.sidebar-severity-card .donut-metric-card__plot {
width: calc(var(--donut-ring-size) - var(--space-8));
}
.sidebar-severity-card .donut-metric-card__values {
padding-top: var(--space-2);
.sidebar-severity-card .donut-metric-card__primary strong {
font-size: var(--donut-compact-value-size);
}
.sidebar-severity-card .donut-metric-card__values strong {
font-size: var(--donut-value-size);
}
.sidebar-severity-card .donut-metric-card__values span {
margin-top: var(--space-1);
font-size: var(--donut-percent-size);
.sidebar-severity-card .donut-metric-card__primary span {
left: calc(100% + var(--donut-compact-unit-gap));
font-size: var(--donut-compact-percent-size);
}
.sidebar-severity-card:hover,
.sidebar-severity-card[aria-pressed="true"] {

View File

@ -35,12 +35,12 @@
.datahub-reference-knob { font-style: normal; }
.datahub-comparison-error { display: block; margin: 0 var(--legacy-space-3) var(--legacy-space-2); color: var(--danger-text); font-size: var(--ui-control-size); font-style: normal; }
.datahub-tenants-module { min-height: 0; padding: 0; border: 0; background: transparent; }
.datahub-tenant-card-rack { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--space-2); padding: var(--space-3); border: var(--border-hair) solid var(--donut-border); border-radius: var(--donut-rack-radius); background: var(--donut-rack-surface); box-shadow: var(--donut-rack-shadow); }
.datahub-tenant-card { position: relative; min-height: var(--donut-card-min-h); padding: 0; transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, transform .12s ease; }
.datahub-tenant-card-rack { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-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); box-shadow: none; }
.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:hover,
.datahub-tenant-card:focus-within { border-color: var(--line-strong); background: var(--panel-hover); box-shadow: var(--shadow-raised-hover); transform: translateY(-2px); }
.datahub-tenant-card.is-selected { outline: var(--bw-emphasis) solid var(--button-marked-bg); outline-offset: calc(-1 * var(--bw-emphasis)); }
.datahub-tenant-card:focus-within { border-color: var(--line-strong); 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-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="error"] { background: var(--state-offline); }
@ -70,14 +70,13 @@
@media (prefers-reduced-motion: reduce) {
.datahub-tenant-card { transition: none; }
.datahub-tenant-card:hover, .datahub-tenant-card:focus-within { transform: none; }
}
@media (max-width: 1100px) {
.datahub-tenant-card-rack { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.datahub-tenant-card-rack { grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); }
}
@media (max-width: 760px) {
.datahub-tenant-card-rack { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.datahub-tenant-card-rack { grid-template-columns: minmax(0, var(--tile-compact-width)); }
}
.datahub-reference-module { overflow: hidden; }

View File

@ -8,10 +8,10 @@
.datahub-projects, .datahub-create { margin-top: var(--legacy-space-6); }
.datahub-section-heading { display: flex; align-items: end; justify-content: space-between; gap: var(--legacy-space-5); margin-bottom: var(--legacy-space-3); }
.datahub-section-heading h3 { margin: 0; color: var(--heading); font-size: var(--ui-body-size); }
.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: 700; font-variant-numeric: tabular-nums; }
.datahub-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: var(--legacy-space-2); }
.datahub-card { display: grid; grid-template-columns: 40px minmax(0,1fr) auto; align-items: center; gap: var(--legacy-space-3); min-height: 96px; padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); color: var(--text); background: var(--panel); text-align: left; transition: border-color .12s ease, background .12s ease; }
.datahub-card:hover { border-color: var(--line-strong); background: var(--panel-hover); }
.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-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-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 strong { color: var(--heading); font-size: var(--ui-body-size); overflow: hidden; text-overflow: ellipsis; }
@ -24,7 +24,7 @@
.datahub-create label small { color: var(--muted); font-weight: 400; }
.datahub-offline, .datahub-alert, .datahub-empty, .datahub-loading { margin-top: var(--legacy-space-6); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.datahub-offline { display: flex; gap: var(--legacy-space-5); padding: var(--legacy-space-5); border: var(--border-hair) solid var(--line); }
.datahub-offline-icon { display: grid; place-items: center; flex: 0 0 32px; width: 32px; height: 32px; border-radius: var(--legacy-radius-sm); color: var(--state-active-text); background: var(--state-active); font-size: var(--ui-heading-size); font-weight: 900; }
.datahub-offline-icon { display: grid; place-items: center; flex: 0 0 32px; width: 32px; height: 32px; border-radius: var(--legacy-radius-sm); color: var(--state-active-text); background: var(--state-active); font-size: var(--ui-heading-size); font-weight: var(--fw-bold); }
.datahub-offline h3, .datahub-empty h3 { margin: 0 0 var(--legacy-space-2); color: var(--heading); }
.datahub-offline p, .datahub-empty p { max-width: 650px; color: var(--muted); line-height: 1.5; }
.datahub-alert { display: flex; align-items: center; justify-content: space-between; gap: var(--legacy-space-3); padding: var(--legacy-space-3); border-color: var(--danger); color: var(--danger-text); background: var(--danger-surface); }
@ -62,18 +62,18 @@
.tenant-duplicate-badge { width: fit-content; padding: 1px var(--legacy-space-1); border: var(--border-hair) solid var(--warning); border-radius: var(--legacy-radius-sm); color: var(--warning) !important; background: transparent; font-size: var(--ui-small-size); font-weight: var(--ui-label-weight); letter-spacing: .03em; text-transform: uppercase; }
.tenant-scan-delete { min-width: 64px; padding-inline: 8px; }
.datahub-dialog-form { display: grid; gap: 16px; }
.datahub-dialog-form label { display: grid; gap: 7px; color: var(--heading); font-size: var(--ui-label-size); font-weight: 750; }
.datahub-dialog-form label { display: grid; gap: 7px; color: var(--heading); font-size: var(--ui-label-size); font-weight: var(--fw-semibold); }
.datahub-dialog-form label small { color: var(--muted); font-weight: 400; }
.datahub-dialog-form textarea { min-height: 100px; resize: vertical; }
.datahub-dialog-form footer, .datahub-delete-warning footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; padding-top: 16px; border-top: 1px solid var(--line); }
.datahub-delete-warning { display: grid; gap: 10px; }
.datahub-delete-warning > strong { color: var(--heading); font-size: var(--ui-heading-size); }
.datahub-delete-warning p { color: var(--muted); line-height: 1.6; }
@media (max-width: 1050px) { .tenant-file-head { display: none; } .tenant-file-row { grid-template-columns: 1fr 1fr; gap: 8px 18px; padding: 16px; } .tenant-file-row > span::before { content: attr(data-label); display: block; margin-bottom: 3px; color: var(--muted); font-size: var(--ui-label-size); font-weight: 800; letter-spacing: .05em; text-transform: uppercase; } }
@media (max-width: 1050px) { .tenant-file-head { display: none; } .tenant-file-row { grid-template-columns: 1fr 1fr; gap: 8px 18px; padding: 16px; } .tenant-file-row > span::before { content: attr(data-label); display: block; margin-bottom: 3px; color: var(--muted); font-size: var(--ui-label-size); font-weight: var(--fw-semibold); letter-spacing: var(--ls-caps); text-transform: uppercase; } }
@media (max-width: 800px) { .tenant-header { grid-template-columns: 1fr; } .tenant-actions { flex-wrap: wrap; } .tenant-file-row { grid-template-columns: 1fr; } .datahub-card-actions { flex-wrap: wrap; } }
/* Full-page tenant editor */
.datahub-card.active { border-color: var(--line-strong); background: var(--panel-hover); }
.datahub-card.active { border-color: var(--tile-selected-border); outline: var(--bw-emphasis) solid var(--tile-selected-border); outline-offset: calc(-1 * var(--bw-emphasis)); background: var(--tile-surface-hover); }
.tenant-editor-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: var(--legacy-space-5); padding-bottom: var(--legacy-space-6); border-bottom: var(--border-hair) solid var(--line); }
.tenant-editor-header .tenant-back { grid-column: 1 / -1; }
.tenant-editor-header h2 { margin: var(--legacy-space-1) 0; color: var(--heading); font-size: clamp(17px, 2vw, 22px); line-height: 1.15; letter-spacing: -.01em; }
@ -81,7 +81,7 @@
.tenant-editor { margin-top: var(--legacy-space-6); padding: var(--legacy-space-5); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.tenant-editor-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--legacy-space-4); }
.tenant-editor-form label { display: grid; grid-template-columns: auto 1fr; gap: var(--legacy-space-2); color: var(--heading); font-size: var(--ui-control-size); font-weight: var(--ui-label-weight); }
.tenant-editor-form label > span { justify-self: end; color: var(--muted); font-size: var(--ui-small-size); font-weight: 500; }
.tenant-editor-form label > span { justify-self: end; color: var(--muted); font-size: var(--ui-small-size); font-weight: var(--fw-semibold); }
.tenant-editor-form .input { grid-column: 1 / -1; }
.tenant-editor-description { grid-column: 1 / -1; }
.tenant-editor-description textarea { min-height: 118px; resize: vertical; }
@ -101,7 +101,8 @@
.viewer.app-area-data-hub, .viewer.app-area-dashboard, .viewer.app-area-db-structure { overflow-x: hidden; overflow-y: auto; background: var(--surface-app); scrollbar-color: var(--line-strong) var(--surface-app); scrollbar-width: thin; }
.viewer.app-area-data-hub { scrollbar-gutter: stable; }
.datahub-page { box-sizing: border-box; min-height: 100%; background: var(--surface-app); }
.datahub-card, .tenant-editor, .tenant-file-table, .tenant-import-pending { border-radius: var(--legacy-radius-sm); box-shadow: var(--shadow); }
.datahub-card { border-radius: var(--tile-radius); box-shadow: var(--tile-card-shadow); }
.tenant-editor, .tenant-file-table, .tenant-import-pending { border-radius: var(--legacy-radius-sm); box-shadow: var(--shadow); }
.tenant-editor-header { align-items: center; }
.tenant-editor-header h2 { font-weight: var(--ui-strong-weight); letter-spacing: -.01em; }
.tenant-editor { padding: var(--legacy-space-5); }
@ -159,48 +160,128 @@
min(1180px, 100%) begrenzt zu sein. */
.db-structure-page > *, .results-browser-page > * { width: 100% !important; max-width: none !important; margin-inline: 0 !important; }
/* Dashboard: Lagebild des geoeffneten Mandanten. Jeder Block beantwortet genau
eine Frage und ist zugleich der Einstieg in die Arbeitsflaeche mit dem
passenden Filter - eine Zahl ohne Weg dorthin waere Dekoration. */
.dashboard-page { box-sizing: border-box; width: 100%; min-height: 100%; padding: var(--panel-inset); color: var(--text); background: var(--surface-app); }
.dashboard-context { display: flex; align-items: center; flex-wrap: wrap; gap: var(--legacy-space-2) var(--legacy-space-4); margin: 0 0 var(--legacy-space-5); color: var(--muted); font-size: var(--ui-label-size); }
/* Dashboard: kompaktes Lagebild des geoeffneten Mandanten. Statuskarten und
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-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-environment { margin: 0 0 var(--legacy-space-5); padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-left: 3px solid var(--legacy-accent); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--legacy-space-3) var(--legacy-space-5); margin: 0; }
.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; }
button.dashboard-status-metric { cursor: pointer; }
button.dashboard-status-metric:hover { border-color: currentColor; 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[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-ring { position: relative; width: 98px; height: 98px; color: var(--donut-all); }
.dashboard-status-metric-large .dashboard-status-ring { width: 154px; height: 154px; }
.dashboard-status-ring svg { display: block; width: 100%; height: 100%; transform: rotate(-90deg); }
.dashboard-status-ring circle { fill: none; stroke-width: var(--donut-ring-stroke); }
.dashboard-status-ring-track { stroke: var(--donut-track); }
.dashboard-status-ring-progress { stroke: currentColor; }
.dashboard-status-ring > span { position: absolute; inset: 0; display: flex; align-items: flex-start; justify-content: center; padding-top: 36px; color: var(--heading); font-variant-numeric: tabular-nums; }
.dashboard-status-metric-large .dashboard-status-ring > span { padding-top: 57px; }
.dashboard-donut-primary { position: relative; display: block; line-height: 1; }
.dashboard-status-ring > span strong { display: block; font: var(--fw-semibold) var(--donut-value-size) / 1 var(--font-donut-display); }
.dashboard-status-metric-large .dashboard-status-ring > span strong { font-size: calc(var(--donut-value-size) * 1.45); }
.dashboard-status-ring > span small { position: absolute; left: calc(100% + var(--space-1)); bottom: 0; color: var(--heading); font-size: var(--donut-percent-size); font-weight: var(--fw-semibold); }
.dashboard-status-ring .dashboard-donut-primary.is-compact strong, .dashboard-risk-donut .dashboard-donut-primary.is-compact strong { font-size: calc(var(--donut-value-size) * .85); }
.dashboard-status-ring .dashboard-donut-primary.is-compact small, .dashboard-risk-donut .dashboard-donut-primary.is-compact small { font-size: var(--type-meta-size); }
.dashboard-status-ring-deviation { color: var(--donut-error); }
.dashboard-status-ring-passed { color: var(--donut-passed); }
.dashboard-status-ring-not-run { color: var(--donut-deviation); }
.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 > 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-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 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 + button { border-top: var(--border-hair) solid var(--line); }
.dashboard-status-summary-stack button:hover { background: var(--panel-hover); }
.dashboard-status-summary-stack button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-status-summary-stack span, .dashboard-ioe-summary span { color: var(--heading); font-size: var(--ui-body-size); }
.dashboard-status-summary-stack strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-ioe-summary { display: grid; align-content: center; gap: var(--space-4); min-width: 0; padding: 0 0 0 var(--space-5); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.dashboard-ioe-summary:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-ioe-summary:disabled { cursor: default; }
.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-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 .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 > span { display: grid; gap: var(--space-1); min-width: 0; padding: 0 var(--space-3); text-align: center; }
.dashboard-coverage-summary > span + span { border-left: var(--border-hair) solid var(--line-strong); }
.dashboard-coverage-summary strong { color: var(--heading); font-size: var(--ui-heading-size); font-variant-numeric: tabular-nums; }
.dashboard-coverage-summary small { color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3) var(--space-5); margin: 0; }
.dashboard-facts div { min-width: 0; }
.dashboard-facts dt { margin-bottom: var(--legacy-space-1); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-facts dd { margin: 0; color: var(--heading); font-size: var(--ui-body-size); overflow-wrap: anywhere; }
.dashboard-coverage { display: flex; flex-wrap: wrap; gap: var(--legacy-space-2); margin-top: var(--legacy-space-4); }
.dashboard-state { padding: var(--legacy-space-1) var(--legacy-space-2); border: var(--border-hair) solid var(--line); color: var(--muted); background: var(--panel-raised); font-size: var(--ui-label-size); }
.dashboard-state-observed { border-color: var(--legacy-accent); color: var(--text); }
.dashboard-state-license-restricted, .dashboard-state-access-denied, .dashboard-state-parse-failed { border-color: var(--severity-high); }
.dashboard-context-coverage { margin: var(--legacy-space-4) 0 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); align-items: start; gap: var(--legacy-space-5); }
.dashboard-block { padding: var(--legacy-space-4); border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel); }
.dashboard-question { margin: 0 0 var(--legacy-space-4); color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-block { min-width: 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-distribution { display: grid; align-content: start; gap: var(--space-4); }
.dashboard-segmented-bar { display: flex; width: 100%; height: 28px; overflow: hidden; border-radius: var(--tile-radius); background: var(--surface-active); }
.dashboard-segment { --share: 0%; flex: 0 0 var(--share); min-width: 2px; height: 100%; background: var(--severity, var(--viz-1)); }
.dashboard-segment + .dashboard-segment { border-left: var(--border-hair) solid var(--surface-panel); }
.dashboard-distribution-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.dashboard-legend-button { display: flex; align-items: flex-start; gap: var(--space-2); width: 100%; min-height: 52px; padding: var(--space-2); border: var(--border-hair) solid transparent; border-radius: var(--btn-radius); color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-legend-button:hover { border-color: var(--line); background: var(--panel-hover); }
.dashboard-legend-button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-legend-swatch { flex: 0 0 10px; width: 10px; height: 10px; margin-top: 3px; background: var(--severity, var(--viz-1)); }
.dashboard-legend-copy { display: grid; gap: var(--space-1); min-width: 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-legend-copy > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-legend-copy strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-legend-copy small { color: var(--muted); font-size: var(--ui-control-size); font-weight: var(--fw-regular); }
.dashboard-viz-1 { --severity: var(--viz-1); }
.dashboard-viz-2 { --severity: var(--viz-2); }
.dashboard-viz-3 { --severity: var(--viz-3); }
.dashboard-viz-4 { --severity: var(--viz-4); }
.dashboard-viz-5 { --severity: var(--viz-5); }
.dashboard-viz-6 { --severity: var(--viz-6); }
.dashboard-empty { margin: 0; color: var(--muted); font-size: var(--ui-control-size); }
.dashboard-bars { display: grid; gap: var(--border-hair); margin: 0; padding: 0; list-style: none; }
/* Zeile als Raster: Bezeichnung, Anteilsbalken, Zahl, Anteil. Die beiden
rechten Spalten sind fest, damit Zahlen ueber alle Zeilen fluchten. */
.dashboard-bar { display: grid; grid-template-columns: 132px minmax(0, 1fr) 62px 44px; align-items: center; gap: var(--legacy-space-3); width: 100%; min-height: var(--row-height); padding: 0 var(--legacy-space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-bar:hover { background: var(--panel-hover); }
.dashboard-bar:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-bar-label { min-width: 0; overflow: hidden; color: var(--text); font-size: var(--ui-control-size); text-overflow: ellipsis; white-space: nowrap; }
/* --share setzt die Komponente zur Laufzeit je Zeile. Der Vorgabewert steht
hier und nicht in der Tokendatei: es ist ein Messwert dieser Darstellung,
keine Rolle der Designsprache. Ohne ihn bliebe ein Balken ohne Messwert
ungerendert statt leer. */
/* Die Spur nutzt --surface-active statt --surface-inset: letzteres ist im
hellen Skin dieselbe Flaeche wie das Panel, der ungefuellte Anteil des
Balkens waere dort unsichtbar gewesen. */
.dashboard-bar-track { --share: 0%; display: block; width: 100%; height: 10px; background: var(--surface-active); }
/* Ohne Severity-Klasse faellt der Balken auf den neutralen Ton zurueck - so
traegt der Ergebnisstatus keine Schwereaussage, die er nicht macht. */
.dashboard-bar-fill { display: block; width: var(--share); height: 100%; background: var(--severity, var(--legacy-accent)); }
.dashboard-bar-count { color: var(--heading); font-family: var(--font-mono); font-size: var(--ui-label-size); font-variant-numeric: tabular-nums; text-align: right; }
.dashboard-bar-share { color: var(--muted); font-family: var(--font-mono); font-size: var(--ui-label-size); font-variant-numeric: tabular-nums; text-align: right; }
.dashboard-progress { display: grid; gap: var(--legacy-space-2); }
.dashboard-progress-legend { color: var(--muted); font-size: var(--ui-control-size); }
.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-tool { min-height: 30px; max-width: min(100%, 320px); padding: var(--space-1) var(--space-2); border: var(--border-hair) solid transparent; border-radius: 0; color: var(--heading); background-color: transparent; font-size: var(--ui-body-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; cursor: pointer; }
.dashboard-risk-tool:hover { border-color: var(--line-strong); background-color: var(--panel-hover); }
.dashboard-risk-tool:focus-visible { border-color: var(--focus-ring); outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-risk-tool option { color: var(--text); background-color: var(--surface-input); text-transform: none; }
.dashboard-marked-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-5); min-width: 240px; padding: var(--space-2) var(--space-3); border: var(--border-hair) solid var(--line); color: var(--muted); font-size: var(--ui-label-size); }
.dashboard-marked-summary strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-risk-body { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(280px, 1.2fr); min-height: 190px; margin-top: var(--space-4); }
.dashboard-risk-categories, .dashboard-risk-legend { margin: 0; padding: 0; list-style: none; }
.dashboard-risk-categories { display: grid; align-content: center; padding-right: var(--space-5); border-right: var(--border-hair) solid var(--line-strong); }
.dashboard-risk-categories li + li { border-top: var(--border-hair) solid var(--line); }
.dashboard-risk-categories button, .dashboard-risk-legend button { display: grid; align-items: center; width: 100%; min-height: 42px; padding: var(--space-2); border: 0; border-radius: 0; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.dashboard-risk-categories button { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); }
.dashboard-risk-categories button:hover, .dashboard-risk-legend button:hover { background: var(--panel-hover); }
.dashboard-risk-categories button:focus-visible, .dashboard-risk-legend button:focus-visible { outline: var(--border-hair) solid var(--focus-ring); outline-offset: -1px; }
.dashboard-risk-categories span, .dashboard-risk-legend span { min-width: 0; overflow: hidden; font-size: var(--ui-body-size); text-overflow: ellipsis; white-space: nowrap; }
.dashboard-risk-categories strong, .dashboard-risk-legend strong { color: var(--heading); font-size: var(--ui-body-size); font-variant-numeric: tabular-nums; }
.dashboard-risk-severity { display: flex; align-items: center; justify-content: center; gap: var(--space-5); min-width: 0; padding-left: var(--space-5); }
.dashboard-risk-donut { position: relative; flex: 0 0 150px; width: 150px; height: 150px; overflow: visible; }
.dashboard-risk-donut svg { display: block; width: 100%; height: 100%; overflow: visible; transform: rotate(-90deg); }
.dashboard-risk-donut circle { fill: none; stroke-width: var(--donut-ring-stroke); }
.dashboard-risk-donut-track { stroke: var(--donut-track); }
.dashboard-risk-donut-segment { stroke: var(--severity, var(--info)); }
.dashboard-risk-donut > span { position: absolute; inset: 0; display: flex; align-items: flex-start; justify-content: center; padding-top: 57px; color: var(--heading); font-variant-numeric: tabular-nums; }
.dashboard-risk-donut > span strong { display: block; font: var(--fw-semibold) calc(var(--donut-value-size) * 1.45) / 1 var(--font-donut-display); }
.dashboard-risk-donut > span small { position: absolute; left: calc(100% + var(--space-1)); bottom: 0; font-size: var(--donut-percent-size); font-weight: var(--fw-semibold); }
.dashboard-risk-legend { flex: 1 1 auto; min-width: 120px; }
.dashboard-risk-legend button { grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); }
.dashboard-risk-legend i { width: 12px; height: 12px; background: var(--severity, var(--info)); }
@media (max-width: 1400px) {
.dashboard-status-rack { grid-template-columns: repeat(2, minmax(0, 290px)); }
.dashboard-status-summary { grid-column: 1 / -1; }
.dashboard-distribution-grid, .dashboard-lower-grid { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.dashboard-status-rack { grid-template-columns: minmax(0, 290px); }
.dashboard-status-summary { grid-column: auto; }
.dashboard-coverage-summary, .dashboard-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-risk-body { grid-template-columns: 1fr; }
.dashboard-risk-categories { padding-right: 0; padding-bottom: var(--space-4); border-right: 0; border-bottom: var(--border-hair) solid var(--line-strong); }
.dashboard-risk-severity { padding: var(--space-4) 0 0; }
}
/* DB-Struktur panel */
.db-structure-page { width: 100%; max-width: none; }
@ -209,10 +290,10 @@
eigenen Aussenabstand. */
.db-structure-meta { margin: 0; color: var(--muted); font-size: var(--ui-body-size); }
.page-actions-row { display: flex; align-items: center; justify-content: space-between; gap: var(--legacy-space-4); margin-top: var(--legacy-space-4); }
.db-structure-layers { display: grid; gap: var(--legacy-space-5); margin-top: var(--legacy-space-5); width: 100%; }
.db-structure-layer { border: var(--border-hair) solid var(--line); border-left: 3px solid var(--legacy-accent); border-radius: var(--legacy-radius-sm); padding: var(--legacy-space-4); background: var(--panel); }
.db-structure-layers { display: grid; gap: var(--segment-gap); margin-top: var(--legacy-space-5); width: 100%; }
.db-structure-layer { padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-left: 3px solid var(--legacy-accent); border-radius: var(--segment-radius); background: var(--segment-surface); }
.db-structure-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--legacy-space-2); }
.db-structure-card { border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-sm); background: var(--panel-raised); overflow: hidden; }
.db-structure-card { 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); }
.db-structure-card-header { display: flex; align-items: center; justify-content: space-between; gap: var(--legacy-space-2); width: 100%; padding: var(--legacy-space-3); border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.db-structure-card-header:hover { background: var(--panel-hover); }
.db-structure-card-title { display: grid; gap: var(--legacy-space-1); min-width: 0; }
@ -634,8 +715,9 @@
}
.tenant-detail-page .tenant-identity h2 {
padding: 0;
font-size: calc(var(--fs-xl) * 2);
line-height: 1.1;
font-size: var(--type-h2-size);
font-weight: var(--fw-semibold);
line-height: 1.15;
letter-spacing: -.03em;
}
.tenant-detail-page .tenant-identity p {
@ -662,11 +744,11 @@
}
.tenant-timeslots-heading {
margin: 0;
color: var(--text-secondary);
font-size: var(--fs-md);
color: var(--text-primary);
font-size: var(--type-h3-size);
font-weight: var(--fw-semibold);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
letter-spacing: 0;
text-transform: none;
}
.tenant-timeslot-add {
flex: 0 0 auto;
@ -777,7 +859,7 @@
min-height: calc(var(--row-h) + var(--space-2));
color: var(--text-secondary);
background: var(--surface-panel);
font-size: var(--fs-sm);
font-size: var(--type-table-head-size);
font-weight: var(--fw-semibold);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
@ -798,7 +880,7 @@
.tenant-report-copy small {
overflow: hidden;
color: var(--text-secondary);
font-size: var(--fs-sm);
font-size: var(--type-meta-size);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
@ -806,7 +888,7 @@
.tenant-report-imported {
overflow: hidden;
color: var(--text-primary);
font-size: var(--fs-md);
font-size: var(--type-body-size);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
@ -817,7 +899,7 @@
gap: var(--space-2);
min-width: 0;
color: var(--text-primary);
font-size: var(--fs-md);
font-size: var(--type-body-size);
font-weight: var(--fw-regular);
white-space: nowrap;
}
@ -899,11 +981,11 @@
justify-content: flex-end;
}
.tenant-information-title > strong {
color: var(--text-secondary);
font-size: var(--fs-md);
color: var(--text-primary);
font-size: var(--type-h3-size);
font-weight: var(--fw-semibold);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
letter-spacing: 0;
text-transform: none;
}
.tenant-edit-client,
.tenant-save-client {
@ -931,10 +1013,10 @@
padding: 0;
color: var(--text-primary);
background: transparent;
font-size: var(--fs-md);
font-size: var(--type-label-size);
font-weight: var(--fw-semibold);
letter-spacing: var(--ls-caps);
text-transform: uppercase;
letter-spacing: 0;
text-transform: none;
}
.tenant-detail-page .tenant-editor-form .tenant-field-label i {
color: var(--text-secondary);
@ -946,7 +1028,7 @@
display: block;
justify-self: start;
color: var(--text-primary);
font-size: var(--fs-lg);
font-size: var(--type-body-size);
font-weight: var(--fw-regular);
letter-spacing: normal;
line-height: var(--lh-prose);

View File

@ -30,7 +30,7 @@
.import-preview-heading { display: flex; align-items: center; gap: 9px; min-width: 0; }
.import-preview-main { min-width: 0; }
.import-preview-heading strong { min-width: 0; overflow-wrap: anywhere; }
.import-status { flex: 0 0 auto; font-size: var(--ui-label-size); font-weight: 700; }
.import-status { flex: 0 0 auto; font-size: var(--ui-label-size); font-weight: var(--fw-semibold); }
.import-status-ready { color: var(--low); }
.import-status-replacement { color: var(--low); }
.import-status-warning, .import-status-duplicate { color: var(--medium); }

View File

@ -1,8 +1,7 @@
.app-shell { display: grid; grid-template-rows: 124px minmax(0, 1fr); height: 100vh; min-height: 0; }
.topbar { display: flex; align-items: flex-start; gap: 10px; min-width: 0; height: 124px; padding: 6px 12px; background: var(--header); overflow: hidden; }
.brand { display: flex; align-items: center; gap: 12px; flex: 0 0 204px; min-width: 204px; padding: 10px 14px 10px 16px; margin-top: 2px; white-space: nowrap; }
.app-shell:not(.app-shell-work) .brand { min-height: 112px; padding-block: 6px; margin-top: 0; }
.brand h1 { margin: 0; color: var(--heading); font-size: var(--ui-heading-size); line-height: 1; letter-spacing: .045em; }
.brand { display: flex; align-items: center; gap: 12px; flex: 0 0 204px; min-width: 204px; min-height: 112px; padding: 6px 14px 6px 16px; margin-top: 0; white-space: nowrap; }
.brand h1 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--fw-bold); line-height: 1; letter-spacing: .045em; }
.brand-copy { display: grid; gap: var(--space-1); }
.brand-version { color: var(--muted); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; line-height: 1; letter-spacing: var(--ls-caps); }
.brand-logo { position: relative; display: block; flex: 0 0 48px; width: 48px; height: 48px; }
@ -23,9 +22,9 @@ html[data-theme="light"] .brand-logo-light { opacity: 1; }
.toolbar, .header-export-row, .live-summary, .search-tools, .view-tabs { display: flex; align-items: center; gap: 6px; min-width: 0; }
.toolbar { flex: 0 0 auto; justify-content: flex-end; margin: 0; padding: 0; border: 0; min-inline-size: 0; }
.live-summary { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; height: 30px; margin-left: 0; padding: 3px 8px; gap: 10px; border: 1px solid var(--border-default); border-radius: 0; background: var(--surface-panel-raised); white-space: nowrap; }
.live-summary span { display: inline-flex; align-items: center; gap: 8px; color: var(--text-secondary); font-weight: 500; font-size: var(--ui-label-size); }
.live-summary span { display: inline-flex; align-items: center; gap: 8px; color: var(--text-secondary); font-weight: var(--fw-regular); font-size: var(--ui-label-size); }
.live-summary span + span { border-left: 1px solid var(--border-subtle); padding-left: 10px; }
.live-summary b { 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-label-size); font-variant-numeric: tabular-nums; font-weight: 700; line-height: 1; text-align: center; }
.live-summary b { 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-label-size); font-variant-numeric: tabular-nums; font-weight: var(--fw-semibold); line-height: 1; text-align: center; }
.delimiter-select { width: 220px; }
.view-tabs { flex: 0 0 auto; gap: 0; border: 1px solid var(--button-border); border-radius: 0; overflow: visible; background: var(--surface-input); }
.tab-btn { flex: 0 0 auto; min-width: 72px; padding: 2px 8px; border: 0; border-right: 1px solid var(--button-border); border-radius: 0; color: var(--button-text); background: var(--button-bg); font-size: var(--ui-label-size); font-weight: var(--fw-medium); line-height: 1.15; white-space: nowrap; transition: background-color .12s ease, color .12s ease; }
@ -49,12 +48,12 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
.theme-switcher { display: flex; align-items: center; justify-content: space-between; gap: 4px; padding: 4px; border: 1px solid var(--button-border); border-radius: 10px; background: var(--surface-input); }
.theme-switcher-btn { display: grid; place-items: center; flex: 1 1 0; height: 28px; padding: 0; border: 1px solid transparent; border-radius: 7px; color: var(--button-text); background: transparent; box-shadow: none; font-size: var(--ui-body-size); line-height: 1; transition: background-color .12s ease, color .12s ease, border-color .12s ease; cursor: pointer; }
.theme-switcher-btn:hover:not(.active) { color: var(--button-text); background: var(--button-bg-hover); }
.theme-switcher-btn.active { border-color: var(--button-border); color: var(--button-marked-text); background: var(--button-marked-bg); box-shadow: none; font-weight: 700; }
.sidebar-toggle-inline { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid transparent; border-radius: 7px; color: var(--button-text); background: transparent; font-size: 16px; font-weight: bold; cursor: pointer; }
.theme-switcher-btn.active { border-color: var(--button-border); color: var(--button-marked-text); background: var(--button-marked-bg); box-shadow: none; font-weight: var(--fw-semibold); }
.sidebar-toggle-inline { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid transparent; border-radius: 7px; color: var(--button-text); background: transparent; font-size: 16px; font-weight: var(--fw-bold); cursor: pointer; }
.sidebar-toggle-inline:hover { background: var(--button-bg-hover); color: var(--text-primary); }
.sidebar-primary-actions { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0 0; padding: 0 0 12px 10px; border-top: 0; border-bottom: 0; }
.sidebar-tab-btn { position: relative; display: flex; align-items: center; gap: 13px; width: calc(100% - 10px); min-height: 42px; padding: 8px 14px; border: 1px solid transparent; border-radius: 8px; color: var(--text-secondary); background: transparent; box-shadow: none; font-size: var(--ui-heading-size); font-weight: 500; letter-spacing: 0; text-align: left; text-shadow: none; cursor: pointer; transition: color .15s ease, background .15s ease, border-color .15s ease; }
.sidebar-tab-btn { position: relative; display: flex; align-items: center; gap: 13px; width: calc(100% - 10px); min-height: 42px; padding: 8px 14px; border: 1px solid transparent; border-radius: 8px; color: var(--text-secondary); background: transparent; box-shadow: none; font-size: var(--ui-heading-size); font-weight: var(--fw-regular); letter-spacing: 0; text-align: left; text-shadow: none; cursor: pointer; transition: color .15s ease, background .15s ease, border-color .15s ease; }
.sidebar-tab-btn svg { flex: 0 0 22px; width: 22px; height: 22px; color: var(--text-muted); }
.sidebar-tab-btn span { min-width: 0; }
.sidebar-tab-btn:hover:not(.active):not(:disabled) { color: var(--text-primary); background: var(--surface-hover); border-color: transparent; box-shadow: none; transform: none; }
@ -74,7 +73,7 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
background: var(--surface-app);
box-shadow: none;
outline: 0;
font-weight: 700;
font-weight: var(--fw-semibold);
}
.sidebar-tab-btn.active svg {
color: var(--band-surface);
@ -89,7 +88,7 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
.toolbar:disabled, .workbar:disabled { opacity: .5; }
.toolbar:disabled *, .workbar:disabled * { pointer-events: none; }
.search-tools { grid-column: 1 / -1; display: flex; align-items: center; gap: 6px; flex: 1 1 auto; flex-wrap: wrap; margin: 0; padding: 0; }
.standard-filter-btn { flex: 0 0 auto; min-width: 96px; margin-left: 0; font-weight: 700; }
.standard-filter-btn { flex: 0 0 auto; min-width: 96px; margin-left: 0; font-weight: var(--fw-semibold); }
.title-select { flex: 0 0 215px; width: 215px; }
.sort-select { flex: 0 0 345px; width: 345px; }
.status-select { flex: 0 0 190px; width: 190px; }
@ -122,16 +121,17 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
.area-kicker { display: block; margin-bottom: 10px; color: var(--muted); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
.area-landing-card h2 { margin: 0 0 12px; color: var(--heading); font-size: var(--ui-glyph-size); }
.area-landing-card p { max-width: 510px; margin: 0 auto 24px; color: var(--muted); line-height: 1.6; }
.area-landing-card .hidden-input + .btn { min-width: 170px; font-weight: 800; }
.area-landing-card .hidden-input + .btn { min-width: 170px; font-weight: var(--fw-semibold); }
.records-layout { display: grid; grid-template-columns: clamp(420px, 27vw, 520px) minmax(0, 1fr); height: 100%; min-height: 0; background: var(--surface-app); }
.record-list { min-width: 0; min-height: 0; padding: 8px 6px; background: var(--surface-app); overflow-y: auto; scrollbar-color: var(--line-strong) var(--surface-inset); scrollbar-width: thin; }
.tile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); align-content: start; gap: 14px; min-height: 100%; padding: var(--panel-inset); background: var(--surface-app); }
.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); }
.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); }
@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: 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: 760px) { .app-shell > main { grid-template-columns: 0 minmax(0, 1fr); } .app-shell > main > aside { visibility: hidden; } .sidebar-toggle { left: 0; } .records-layout { grid-template-columns: 1fr; } .record-list { max-height: 280px; border-right: 0; border-bottom: 1px solid var(--line); } .tile-grid { grid-template-columns: 1fr; padding: var(--panel-inset); } }
@media (max-width: 760px) { .app-shell > main { grid-template-columns: 0 minmax(0, 1fr); } .app-shell > main > aside { visibility: hidden; } .sidebar-toggle { left: 0; } .records-layout { grid-template-columns: 1fr; } .record-list { max-height: 280px; border-right: 0; border-bottom: 1px solid var(--line); } .tile-grid { grid-template-columns: minmax(0, 1fr); } }
.app-sidebar { display: flex; flex-direction: column; min-height: 100%; padding: 0; }
.sidebar-primary-actions { order: 0; }
.sidebar-severity-rack { order: 1; }
@ -144,7 +144,7 @@ aside { position: relative; z-index: 10; min-width: 0; min-height: 0; background
.connection-unavailable .connection-state-square.is-offline,
.connection-checking .connection-state-square.is-offline { opacity: 1; }
.area-topbar { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; }
.area-topbar strong { margin-right: auto; color: var(--heading); font-size: calc(var(--fs-xl) * 4); font-weight: var(--fw-semibold); line-height: 1.15; 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 > strong { flex: 0 0 auto; margin-right: 0; }
.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; }

View File

@ -206,16 +206,16 @@ button, input, select, textarea { font-family: inherit; }
.reader-nav { margin-bottom: var(--legacy-space-6); color: var(--muted); font-size: var(--ui-control-size); }
.reader-nav button { color: var(--text); }
.reader-heading { padding-bottom: var(--legacy-space-5); border-color: var(--line); }
/* Der Fundname steht auf derselben Stufe wie die Finding-Ueberschrift. */
/* Der Fundname steht als kompakter Detailtitel eine Stufe ueber dem Inhalt. */
.reader-heading h2 {
color: var(--heading);
font-size: var(--ui-reading-heading-size);
font-size: var(--type-detail-title-size);
font-weight: var(--ui-strong-weight);
line-height: 1.3;
letter-spacing: 0;
}
.reader-title-hex { width: 26px; height: 30px; }
.finding-badges, .mitre-line { gap: var(--legacy-space-2); margin-top: var(--legacy-space-3); }
.finding-badges, .mitre-line { gap: var(--legacy-space-2); margin-top: var(--legacy-space-3); font-size: var(--ui-badge-size); }
.status-badge, .severity-badge, .mitre-badge, .reader-severity {
display: inline-flex;
align-items: center;
@ -226,7 +226,7 @@ button, input, select, textarea { font-family: inherit; }
border-radius: var(--legacy-radius-sm);
color: var(--text);
background: transparent;
font-size: var(--ui-label-size);
font-size: var(--ui-badge-size);
font-weight: var(--ui-label-weight);
line-height: 1.18;
letter-spacing: var(--ui-label-tracking);
@ -244,7 +244,7 @@ button, input, select, textarea { font-family: inherit; }
.reader-summary, .review-grid, .all-fields { border-color: var(--line); }
/* Die Ueberschrift des Finding-Abschnitts traegt die Pruefbeschreibung der
Regel und ist deshalb ein Satz, keine Bereichsmarke - entsprechend gesetzt. */
.reader-summary h3 { color: var(--heading); font-size: var(--ui-reading-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.3; letter-spacing: 0; }
.reader-summary h3 { color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--ui-strong-weight); line-height: 1.3; letter-spacing: 0; }
/* Der Finding-Abschnitt ist die Lesefläche des Berichts. Beschriftungen und
Fliesstext stehen deshalb auf der Lesestufe, nicht auf der dichten
Werkzeugtypografie der Leisten, Kacheln und Raster. */
@ -264,12 +264,16 @@ button, input, select, textarea { font-family: inherit; }
flach mit Haarlinien. */
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(0, var(--tile-content-width)));
align-items: stretch;
gap: var(--legacy-space-5);
align-content: start;
justify-content: start;
gap: var(--segment-gap);
min-height: 100%;
padding: var(--panel-inset);
background: var(--bg);
padding: var(--segment-inset);
border: var(--border-hair) solid var(--segment-border);
border-radius: var(--segment-radius);
background: var(--segment-surface);
}
.data-tile {
position: relative;
@ -280,23 +284,22 @@ button, input, select, textarea { font-family: inherit; }
min-width: 0;
min-height: 112px;
padding: var(--legacy-space-4);
border: var(--border-hair) solid var(--line);
border-radius: var(--radius-lg);
border: var(--border-hair) solid var(--tile-border);
border-radius: var(--tile-radius);
color: var(--text);
background: var(--panel-raised);
box-shadow: var(--shadow-raised);
background: var(--tile-surface);
box-shadow: var(--tile-card-shadow);
cursor: pointer;
overflow: hidden;
text-align: left;
transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, transform .12s ease;
transition: background .12s ease, border-color .12s ease;
}
.data-tile:hover, .data-tile:focus-visible {
border-color: var(--line-strong);
border-radius: var(--radius-lg);
background: var(--panel-hover);
border-radius: var(--tile-radius);
background: var(--tile-surface-hover);
box-shadow: var(--tile-card-shadow);
outline: 0;
transform: translateY(-2px);
box-shadow: var(--shadow-raised-hover);
}
.data-tile-hex {
position: relative;
@ -324,9 +327,9 @@ button, input, select, textarea { font-family: inherit; }
.data-tile.severity-low .data-tile-hex { background: var(--low); }
.data-tile.severity-info .data-tile-hex { background: var(--info); }
.data-tile-content { min-width: 0; }
/* Bleibt bewusst gleich gross wie `.record-title`: dieselbe Datenmenge darf in
zwei Ansichten nicht unterschiedlich gewichtet wirken. */
.data-tile-content h3 { margin: 0; color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--ui-strong-weight); line-height: 1.35; overflow-wrap: anywhere; }
/* Matrix- und Listentitel teilen die kompakte Inhaltsstufe, damit lange
Scannerbezeichnungen nicht die Kachelstruktur verdraengen. */
.data-tile-content h3 { margin: 0; color: var(--heading); font-size: var(--type-h4-size); font-weight: var(--ui-strong-weight); line-height: 1.35; overflow-wrap: normal; word-break: normal; hyphens: manual; }
/* Die Groesse gilt dem Outcome-Text ("IOE Found"), der sonst die 14 px des
Rumpfes erbte und damit groesser stand als dieselbe Aussage in der
Trefferliste. Die Plaketten daneben tragen ihre eigene Groesse. */
@ -395,9 +398,9 @@ button, input, select, textarea { font-family: inherit; }
padding: var(--panel-inset);
color: var(--text);
background: var(--panel);
font-family: "Cascadia Code", Consolas, monospace;
font-family: var(--font-ui);
font-size: var(--fs-lg);
font-weight: 400;
font-weight: var(--fw-regular);
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
@ -416,7 +419,7 @@ button, input, select, textarea { font-family: inherit; }
}
@media (prefers-reduced-motion: reduce) {
.data-tile, .record-card { transition: none; }
.data-tile:hover, .data-tile:focus-visible, .record-card:hover { transform: none; }
.record-card:hover { transform: none; }
}

View File

@ -4,9 +4,9 @@
/* Steht im gemeinsamen Kopfcontainer der Shell. */
.finding-path-controls { display: flex; align-items: end; gap: 10px; min-width: 0; height: 100%; padding: 8px 0 0; background: transparent; overflow-x: auto; }
.finding-path-title { display: grid; align-self: stretch; align-content: center; min-width: 240px; margin-right: auto; }
.finding-path-title span { color: var(--heading); font-size: var(--ui-heading-size); font-weight: 800; }
.finding-path-title strong { color: var(--muted); font-size: var(--ui-control-size); font-weight: 650; }
.finding-path-controls label { display: grid; gap: 6px; min-width: 180px; color: var(--muted); font-size: var(--ui-value-size); font-weight: 750; letter-spacing: .045em; text-transform: uppercase; }
.finding-path-title span { color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--fw-semibold); }
.finding-path-title strong { color: var(--muted); font-size: var(--ui-control-size); font-weight: var(--fw-semibold); }
.finding-path-controls label { display: grid; gap: 6px; min-width: 180px; color: var(--muted); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); letter-spacing: 0; text-transform: none; }
/* Dieselben Masse wie die Filter im Command Band von Work: dort sind Controls
`--control-height` hoch und tragen `--ui-control-size`. Mit 40 px und
`--ui-body-size` waren die Graph-Filter als einzige Ansicht groesser. */
@ -14,44 +14,44 @@
.finding-path-search { min-width: 260px !important; }
.finding-path-layout { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto; width: 100%; max-width: 100%; min-width: 0; min-height: 0; overflow: hidden; }
.finding-path-main { min-width: 0; min-height: 0; padding: 16px; overflow: auto; background-image: radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--line) 55%, transparent) 1px, transparent 0); background-size: 22px 22px; }
.active-finding-path { min-width: max-content; margin-bottom: 18px; padding: 14px; border: var(--border-hair) solid var(--line); border-radius: var(--legacy-radius-md); background: color-mix(in srgb, var(--panel) 94%, transparent); }
.active-finding-path { min-width: max-content; margin-bottom: 18px; padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.active-finding-path > header { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 13px; }
.active-finding-path > header div { display: grid; }
.active-finding-path > header span:first-child { color: var(--heading); font-size: var(--ui-heading-size); font-weight: 850; }
.active-finding-path > header span:first-child { color: var(--heading); font-size: var(--ui-heading-size); font-weight: var(--fw-semibold); }
.active-finding-path > header strong { color: var(--muted); font-size: var(--ui-control-size); font-weight: 600; }
.path-method { color: var(--muted); font-size: var(--ui-value-size); text-transform: uppercase; }
.active-path-track { display: flex; align-items: stretch; gap: 8px; }
.active-path-step-wrap { display: flex; align-items: center; gap: 8px; }
.active-path-arrow { color: var(--muted); font-size: var(--ui-glyph-size); font-weight: 300; }
.active-path-step { position: relative; display: grid; gap: 5px; width: 280px; min-height: 126px; padding: 12px 13px 11px 16px; border: 1px solid var(--line); border-radius: 4px; color: var(--text); background: var(--panel); text-align: left; }
.active-path-arrow { color: var(--muted); font-size: var(--ui-glyph-size); font-weight: var(--fw-regular); }
.active-path-step { position: relative; display: grid; gap: 5px; width: 280px; min-width: 0; min-height: 126px; padding: 12px 13px 11px 16px; 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; }
.active-path-step::before { content: ''; position: absolute; inset: 0 auto 0 0; width: var(--marker-width); background: var(--severity); }
.active-path-step:hover { border-color: var(--severity); background: var(--panel-hover); }
.active-path-step small { color: var(--severity); font-size: var(--ui-value-size); font-weight: 850; letter-spacing: .04em; text-transform: uppercase; }
.active-path-step small { color: var(--severity); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); letter-spacing: var(--ls-caps); text-transform: uppercase; }
.active-path-step strong { color: var(--heading); font-size: var(--ui-body-size); line-height: 1.35; }
.active-path-step span { align-self: end; color: var(--muted); font-size: var(--ui-value-size); }
.finding-tactic-board { display: flex; align-items: flex-start; gap: 12px; min-width: max-content; }
.finding-tactic-column { width: 310px; border: 1px solid var(--line); border-radius: 4px; background: color-mix(in srgb, var(--panel) 92%, transparent); }
.finding-tactic-column { width: 310px; border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.finding-tactic-column > header { display: flex; align-items: center; justify-content: space-between; min-height: 42px; padding: 10px 12px; border-bottom: 1px solid var(--line); background: var(--panel-soft); }
.finding-tactic-column > header strong { color: var(--heading); font-size: var(--ui-body-size); }
.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: 800; }
.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-path-card { position: relative; display: grid; gap: 7px; width: 100%; min-height: 126px; padding: 11px 11px 10px 14px; border: 1px solid var(--line); border-radius: 3px; color: var(--text); background: var(--panel); text-align: left; }
.finding-path-card:hover { border-color: var(--line-strong); background: var(--panel-hover); }
.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.is-path-step { border-left: var(--marker-width) solid var(--severity); padding-left: 7px; }
.finding-path-card.is-selected { outline: 2px solid var(--legacy-accent); outline-offset: -2px; }
.finding-path-card-head { display: flex; align-items: center; gap: 7px; color: var(--severity); font-size: var(--ui-label-size); font-weight: 850; letter-spacing: .04em; text-transform: uppercase; }
.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 i { width: 8px; height: 8px; border-radius: 50%; background: var(--severity); }
.finding-path-card-head b { margin-left: auto; color: var(--success); font-size: var(--ui-label-size); }
.finding-path-card > strong { color: var(--heading); font-size: var(--ui-body-size); line-height: 1.4; }
.finding-path-metrics, .finding-techniques { color: var(--muted); font-size: var(--ui-value-size); }
.finding-techniques { color: var(--text); }
.finding-path-detail { display: grid; grid-template-columns: minmax(260px, 1.35fr) minmax(320px, 1fr); column-gap: 40px; align-items: start; width: 100%; min-width: 0; min-height: 320px; box-sizing: border-box; padding: 28px 32px 40px; border-top: 1px solid var(--line); background: var(--panel); overflow: visible; }
.finding-detail-severity { justify-self: start; display: inline-block; padding: 5px 9px; color: var(--severity-ink); background: var(--severity); font-size: var(--ui-value-size); font-weight: 850; text-transform: uppercase; }
.finding-detail-severity { justify-self: start; display: inline-block; padding: 5px 9px; color: var(--severity-ink); background: var(--severity); font-size: var(--ui-value-size); font-weight: var(--fw-bold); text-transform: uppercase; }
.finding-path-detail h2 { grid-column: 1; margin: 14px 0 22px; color: var(--heading); font-size: var(--ui-glyph-size); line-height: 1.35; }
.finding-path-detail dl { grid-column: 1; display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px 18px; margin: 0; }
.finding-path-detail dt { color: var(--muted); font-size: var(--ui-value-size); font-weight: 800; text-transform: uppercase; }
.finding-path-detail dt { color: var(--muted); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); text-transform: none; }
.finding-path-detail dd { margin: 0; color: var(--text); font-size: var(--ui-body-size); overflow-wrap: anywhere; }
.finding-open-detail { grid-column: 2; grid-row: 1 / span 2; align-self: end; width: 100%; min-height: 42px; margin: 0; font-size: var(--ui-body-size); font-weight: 750; }
.finding-open-detail { grid-column: 2; grid-row: 1 / span 2; align-self: end; width: 100%; min-height: 42px; margin: 0; font-size: var(--ui-body-size); font-weight: var(--fw-semibold); }
.finding-path-detail p { grid-column: 2; margin: 18px 0 0; color: var(--muted); font-size: var(--ui-control-size); line-height: 1.65; }
.finding-path-empty { display: grid; place-content: center; gap: 8px; min-height: 100%; color: var(--muted); text-align: center; }
.finding-path-empty strong { color: var(--heading); font-size: var(--ui-heading-size); }
@ -79,10 +79,10 @@
.finding-detail-main { min-width: 0; }
.finding-detail-main h2 { margin: 14px 0 22px; color: var(--heading); font-size: var(--ui-glyph-size); line-height: 1.35; }
.finding-detail-main dl { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px 18px; margin: 0; }
.finding-detail-main dt { color: var(--muted); font-size: var(--ui-value-size); font-weight: 800; text-transform: uppercase; }
.finding-detail-main dt { color: var(--muted); font-size: var(--ui-value-size); font-weight: var(--fw-semibold); text-transform: none; }
.finding-detail-main dd { margin: 0; color: var(--text); font-size: var(--ui-body-size); overflow-wrap: anywhere; }
.finding-detail-actions { display: grid; align-content: start; gap: 18px; min-width: 0; padding-top: 34px; }
.finding-detail-actions .finding-open-detail { grid-column: auto; grid-row: auto; align-self: auto; width: 100%; min-height: 44px; margin: 0; font-size: var(--ui-body-size); font-weight: 750; }
.finding-detail-actions .finding-open-detail { grid-column: auto; grid-row: auto; align-self: auto; width: 100%; min-height: 44px; margin: 0; font-size: var(--ui-body-size); font-weight: var(--fw-semibold); }
.finding-detail-actions p { grid-column: auto; grid-row: auto; margin: 0; color: var(--muted); font-size: var(--ui-control-size); line-height: 1.65; }
@media (max-width: 1050px) {
.finding-path-detail { grid-template-columns: 1fr; }
@ -116,7 +116,7 @@
.finding-tactic-heading { display: grid; min-width: 0; }
.finding-tactic-heading strong { color: var(--heading); font-size: var(--ui-heading-size) !important; font-weight: 800; line-height: 1.25; }
.finding-tactic-heading strong { color: var(--heading); font-size: var(--ui-heading-size) !important; font-weight: var(--fw-semibold); line-height: 1.25; }
.finding-tactic-tools { display: flex; align-items: center; gap: 9px; margin-left: auto; padding-left: 10px; }
.finding-tactic-tools > span { min-width: 26px; height: 26px; margin: 0; }
.finding-tactic-workspace { display: grid; place-items: center; width: 26px; height: 26px; cursor: pointer; }
@ -172,7 +172,7 @@
margin: 0;
color: var(--heading);
font-size: var(--ui-heading-size);
font-weight: 850;
font-weight: var(--fw-semibold);
line-height: 1.2;
}
.finding-path-title span {
@ -196,12 +196,12 @@
.meter-label {
color: var(--muted);
font-size: var(--ui-value-size);
font-weight: 700;
font-weight: var(--fw-semibold);
}
.meter-val {
color: var(--heading);
font-size: var(--ui-body-size);
font-weight: 850;
font-weight: var(--fw-bold);
}
.meter-gauge {
width: 90px;
@ -225,7 +225,7 @@
border-color: var(--legacy-accent);
background: var(--legacy-accent-active);
color: var(--text-on-accent);
font-weight: 800;
font-weight: var(--fw-semibold);
}
/* ---------- SVG Canvas & Board ---------- */
@ -263,7 +263,7 @@
border: 1px solid var(--line);
border-radius: var(--legacy-radius-sm);
font-size: var(--ui-label-size);
font-weight: 750;
font-weight: var(--fw-semibold);
}
.origin-pill.native {
color: var(--text-on-accent);
@ -317,7 +317,7 @@
margin: 0;
color: var(--heading);
font-size: var(--ui-body-size);
font-weight: 800;
font-weight: var(--fw-semibold);
}
.box-body {
display: flex;
@ -337,7 +337,7 @@
.mitre-ref-link {
color: var(--legacy-accent);
font-size: var(--ui-body-size);
font-weight: 700;
font-weight: var(--fw-semibold);
text-decoration: none;
}
.mitre-ref-link:hover {

View File

@ -113,10 +113,7 @@
gap: var(--space-2);
padding: var(--space-3);
overflow: hidden;
box-shadow: none;
}
.obsidian-node-matrix-card .data-tile:hover,
.obsidian-node-matrix-card .data-tile:focus-visible { box-shadow: none; transform: none; }
.obsidian-node-matrix-card .data-tile-hex { width: 18px; height: 20px; }
.obsidian-node-matrix-card .data-tile-content h3 {
display: -webkit-box;
@ -161,14 +158,8 @@
gap: var(--legacy-space-3);
padding: var(--legacy-space-3);
overflow: hidden;
box-shadow: none;
cursor: pointer;
}
.obsidian-node-card.data-tile:hover,
.obsidian-node-card.data-tile:focus-visible {
box-shadow: none;
transform: none;
}
.obsidian-node-card-technique .data-tile-hex,
.obsidian-node-card-category .data-tile-hex,
.obsidian-node-card-defense .data-tile-hex,
@ -206,7 +197,6 @@
border-color: var(--graph-selected);
outline: 3px solid var(--graph-selected);
outline-offset: -3px;
box-shadow: none;
}
.obsidian-graph-toolbar,
@ -216,7 +206,7 @@
position: absolute;
z-index: 5;
border: var(--bw-hairline) solid var(--border-default);
border-radius: var(--radius-panel);
border-radius: var(--radius-md);
color: var(--text-primary);
background: color-mix(in srgb, var(--surface-panel-raised) 94%, transparent);
backdrop-filter: blur(var(--space-2));
@ -233,7 +223,7 @@
transform: translateX(-50%);
}
.obsidian-graph-search { display: grid; gap: var(--space-1); min-width: calc(var(--space-8) * 8); color: var(--text-secondary); font-size: var(--fs-sm); }
.obsidian-graph-search input { min-height: var(--control-h); padding: 0 var(--space-2); border: var(--bw-hairline) solid var(--border-strong); border-radius: var(--radius-control); color: var(--text-primary); background: var(--surface-input); font-size: var(--fs-base); }
.obsidian-graph-search input { min-height: var(--control-h); padding: 0 var(--space-2); border: var(--bw-hairline) solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text-primary); background: var(--surface-input); font-size: var(--fs-base); }
.obsidian-graph-count { align-self: center; min-width: max-content; padding-inline: var(--space-2); color: var(--text-secondary); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.obsidian-graph-branch-actions {
display: flex;
@ -280,7 +270,7 @@
transform: translateX(-50%);
}
.obsidian-graph-filters label { display: grid; gap: var(--space-1); color: var(--text-secondary); font-size: var(--fs-sm); }
.obsidian-graph-filters select { min-height: var(--control-h); padding-inline: var(--space-2); border: var(--bw-hairline) solid var(--border-strong); border-radius: var(--radius-control); color: var(--text-primary); background: var(--surface-input); font-size: var(--fs-base); }
.obsidian-graph-filters select { min-height: var(--control-h); padding-inline: var(--space-2); border: var(--bw-hairline) solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text-primary); background: var(--surface-input); font-size: var(--fs-base); }
.obsidian-graph-zoom { top: var(--space-4); right: var(--space-4); display: grid; overflow: hidden; }
.obsidian-graph-zoom button { display: grid; place-items: center; width: var(--space-8); height: var(--space-8); padding: 0; border: 0; border-bottom: var(--bw-hairline) solid var(--border-default); color: var(--text-primary); background: transparent; font-size: var(--fs-lg); cursor: pointer; }
@ -315,7 +305,7 @@
min-height: var(--control-h);
padding: 0 var(--space-4);
border: var(--bw-hairline) solid var(--border-strong);
border-radius: var(--radius-control);
border-radius: var(--radius-sm);
color: var(--text-primary);
background: color-mix(in srgb, var(--surface-panel-raised) 94%, transparent);
font-size: var(--fs-sm);
@ -424,7 +414,7 @@
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--control-height);
min-height: var(--control-h);
color: var(--text-primary);
font-size: var(--ui-label-size);
cursor: pointer;

View File

@ -8,8 +8,8 @@
.policy-profile-editor legend { padding-inline: var(--legacy-space-1); color: var(--muted); font-size: var(--ui-label-size); font-weight: var(--ui-label-weight); letter-spacing: var(--ui-label-tracking); text-transform: uppercase; }
.policy-profile-editor .policy-profile-product-toggle { display: flex; grid-column: 1 / -1; grid-template-columns: auto 1fr; align-items: center; }
.policy-profile-editor footer { display: flex; grid-column: 1 / -1; justify-content: flex-end; }
.policy-action-stages { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--legacy-space-3); margin-top: var(--legacy-space-4); }
.policy-action-stages > section { min-width: 0; padding: var(--legacy-space-3); border: var(--border-hair) solid var(--line); background: var(--panel); }
.policy-action-stages { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--segment-gap); margin-top: var(--legacy-space-4); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.policy-action-stages > section { min-width: 0; padding: var(--legacy-space-3); 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); }
.policy-action-stages h4 { margin: 0 0 var(--legacy-space-2); color: var(--heading); font-size: var(--ui-body-size); }
.policy-action-stages section > p { margin: 0; color: var(--muted); font-size: var(--ui-control-size); }
.policy-action-stages article { display: flex; align-items: center; justify-content: space-between; gap: var(--legacy-space-3); padding: var(--legacy-space-2) 0; border-top: var(--border-hair) solid var(--line); }

View File

@ -3,8 +3,8 @@
.policy-analysis-panel { margin-top: var(--legacy-space-6); padding-top: var(--legacy-space-5); border-top: var(--border-hair) solid var(--line); }
.policy-analysis-boundary { margin: 0 0 var(--legacy-space-4); padding: var(--legacy-space-3); border-left: 3px solid var(--warning); color: var(--heading); background: var(--panel-soft); font-size: var(--ui-control-size); line-height: 1.5; }
.policy-analysis-empty { padding-block: 28px; }
.policy-analysis-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--legacy-space-2); margin-bottom: var(--legacy-space-4); }
.policy-analysis-summary article { display: grid; gap: var(--legacy-space-1); padding: var(--legacy-space-3); border: var(--border-hair) solid var(--line); background: var(--panel-raised); }
.policy-analysis-summary { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start; gap: var(--segment-gap); margin-bottom: var(--legacy-space-4); padding: var(--segment-inset); border: var(--border-hair) solid var(--segment-border); border-radius: var(--segment-radius); background: var(--segment-surface); }
.policy-analysis-summary article { display: grid; gap: var(--legacy-space-1); min-width: 0; padding: var(--legacy-space-3); 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); }
.policy-analysis-summary span { color: var(--muted); font-size: var(--ui-control-size); }
.policy-analysis-summary strong { color: var(--heading); font-family: var(--font-mono); font-size: var(--ui-heading-size); }
.policy-analysis-columns { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--legacy-space-4); align-items: start; }

View File

@ -0,0 +1,13 @@
/* Letzte Kaskadenschicht fuer die verbindliche Segment-/Kachelgrammatik.
Ansichtsspezifische Breakpoints duerfen eine unvollstaendige Kachelzeile
nicht wieder auf die gesamte Segmentbreite strecken. */
.donut-metric-rack {
grid-template-columns: repeat(auto-fill, var(--tile-compact-width));
justify-content: start;
}
@media (min-width: 1101px) {
.datahub-reference-controls {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}

View File

@ -143,14 +143,15 @@
Alte rem-Skala auf die sechs Stufen der Designsprache abgebildet. Die
Groessen bleiben dadurch unveraendert, nur ihre Quelle wechselt. */
--font: var(--font-ui);
--ui-label-size: var(--fs-sm);
--ui-small-size: var(--fs-sm);
--ui-value-size: var(--fs-sm);
--ui-control-size: var(--fs-base);
--ui-body-size: var(--fs-md);
--ui-heading-size: var(--fs-lg);
--ui-reading-size: var(--fs-lg);
--ui-reading-heading-size: var(--fs-xl);
--ui-label-size: var(--type-table-head-size);
--ui-badge-size: var(--type-badge-size);
--ui-small-size: var(--type-meta-size);
--ui-value-size: var(--type-label-size);
--ui-control-size: var(--type-button-size);
--ui-body-size: var(--type-body-size);
--ui-heading-size: var(--type-h3-size);
--ui-reading-size: var(--type-body-size);
--ui-reading-heading-size: var(--type-h2-size);
/* Ausnahme: Glyphengroesse der Symbolschaltflaechen. Die Typografieskala
endet bei 18px, weil sie Text beschreibt; ein Symbol ist kein Text.
Faellt mit der Umstellung auf .icon-btn. */

View File

@ -421,26 +421,32 @@
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-w), 1fr));
gap: var(--space-2);
padding: var(--space-3);
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(--bw-hairline) solid var(--segment-border);
border-radius: var(--segment-radius);
background: var(--segment-surface);
}
.tile {
display: grid;
grid-template-rows: auto 1fr auto;
min-width: 0;
height: var(--tile-h);
padding: var(--space-2) var(--space-3);
background: var(--surface-panel);
border: var(--bw-hairline) solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--tile-surface);
border: var(--bw-hairline) solid var(--tile-border);
border-radius: var(--tile-radius);
box-shadow: var(--tile-card-shadow);
cursor: pointer;
overflow: hidden;
}
.tile:hover { background: var(--surface-hover); border-color: var(--border-strong); }
.tile:hover { background: var(--tile-surface-hover); border-color: var(--border-strong); }
.tile[aria-selected="true"] {
background: var(--surface-selected);
border-color: var(--selection-border);
background: var(--tile-surface-hover);
border-color: var(--tile-selected-border);
border-width: var(--bw-emphasis);
padding: calc(var(--space-2) - 1px) calc(var(--space-3) - 1px);
}

View File

@ -24,20 +24,53 @@
:root {
/* ---------- Typografie ---------- */
--font-ui: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Consolas, monospace;
--font-donut-display: "Bahnschrift Condensed", "Arial Narrow", "Aptos Narrow", "Segoe UI", sans-serif;
/* Inter wird lokal mit dem Client-Build ausgeliefert. Auch Datenwerte und
Rohansichten verwenden dieselbe Familie; Hierarchie entsteht nicht durch
wechselnde Schriften, sondern ausschliesslich durch Groesse und Gewicht. */
--font-ui: "Inter Variable", sans-serif;
--font-mono: var(--font-ui);
--font-donut-display: var(--font-ui);
/* Semantische Typografierollen. Diese Rollen sind die verbindliche Quelle
fuer neue Ansichten; die darunterliegende fs-Skala bleibt nur als
Kompatibilitaetsschicht fuer bereits migrierte Komponenten bestehen. */
--type-h1-size: 56px;
--type-h2-size: 32px;
--type-h3-size: 18px;
--type-detail-title-size: 20px;
--type-h4-size: 16px;
--type-body-size: 16px;
--type-label-size: 14px;
--type-meta-size: 13px;
--type-badge-size: 12px;
--type-table-head-size: 13px;
--type-button-size: 14px;
/* ---------- Verbindlicher Donut-Standard ---------- */
/* Der Donut-Rack ist ein Panel wie jedes andere: Rack = Hintergrund,
Karte = Element, Ringspur = eingesenkt. Geometrie und Datenfarben des
Donut-Standards bleiben unveraendert, nur die Grundtoene folgen jetzt
dem Panelstandard. */
--donut-rack-surface: #242426;
--donut-card-surface: #323238;
--donut-card-surface-end: #323238;
--donut-track: #242426;
--donut-border: #383840;
/* ---------- Verbindliche Segment-/Kachelgrammatik ---------- */
--segment-surface: #242426;
--segment-border: #383840;
--segment-radius: 10px;
--segment-inset: var(--space-3);
--segment-gap: var(--space-2);
--tile-surface: #323238;
--tile-surface-hover: #3A3A42;
--tile-border: #555B65;
--tile-selected-border: #A68AF9;
--tile-radius: 8px;
--tile-compact-width: 186px;
--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);
--donut-rack-surface: var(--segment-surface);
--donut-card-surface: var(--tile-surface);
--donut-card-surface-end: var(--tile-surface);
--donut-track: #555B65;
--donut-border: var(--segment-border);
--donut-value: #E2DFE6;
--donut-label: #B7BAC0;
--donut-all: #348CF2;
@ -46,26 +79,29 @@
--donut-not-applicable: #7B36F3;
--donut-not-run: #8A8E95;
--donut-error: #F23C47;
--donut-rack-shadow: 0 10px 28px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.04);
--donut-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 8px rgba(0, 0, 0, 0.24);
--donut-rack-radius: 10px;
--donut-card-radius: 8px;
--donut-rack-shadow: none;
--donut-card-shadow: var(--tile-card-shadow);
--donut-rack-radius: var(--segment-radius);
--donut-card-radius: var(--tile-radius);
--donut-card-min-h: 196px;
--donut-ring-size: 128px;
--donut-ring-stroke: 11px;
--donut-ring-stroke: 12px;
--donut-progress-dash: 0 100;
--donut-title-size: 16px;
--donut-value-size: 28px;
--donut-percent-size: 17px;
--donut-value-size: 26px;
--donut-percent-size: 14px;
--donut-compact-value-size: 22px;
--donut-compact-percent-size: 12px;
--donut-compact-unit-gap: 2px;
--datahub-readiness-dot-size: 14px;
--datahub-readiness-dot-inset: var(--space-1);
--fs-micro: 11px; /* Badges, Spaltenkoepfe, Zaehler */
--fs-sm: 12px; /* Tabellenzellen, Sekundaertext, Facetten */
--fs-base: 13px; /* Controls, Standardtext, Listenzeilen */
--fs-md: 14px; /* Objekttitel (Tile, ListRow, Inspector) */
--fs-lg: 16px; /* Panel- und Detailtitel */
--fs-xl: 18px; /* Seitentitel in der Topbar. Groesster Wert im System. */
--fs-micro: var(--type-meta-size);
--fs-sm: var(--type-meta-size);
--fs-base: var(--type-label-size);
--fs-md: var(--type-label-size);
--fs-lg: var(--type-body-size);
--fs-xl: var(--type-h3-size);
/* Pathfinder Micro Card: eigenstaendige, im Graphen begrenzt
bildschirmstabil skalierte Knotenkarte. Keine allgemeine
@ -80,8 +116,11 @@
--pathfinder-micro-action-highlight-mix: #FFFFFF;
--fw-regular: 400;
--fw-medium: 500;
--fw-content-medium: 500;
--fw-semibold: 600;
--fw-bold: 700;
/* Kompatibilitaetsname: visuell gibt es keine vierte Gewichtsstufe. */
--fw-medium: var(--fw-semibold);
--lh-ui: 1.35;
--lh-prose: 1.55;
@ -376,11 +415,18 @@
color-scheme: light;
/* Donut-Standard: identische Geometrie, helle Referenzpalette. */
--donut-rack-surface: #E7E9ED;
--donut-card-surface: #FFFFFF;
--donut-card-surface-end: #F3F4F6;
--donut-track: #D5D8DE;
--donut-border: #B9BEC7;
--segment-surface: #E7E9ED;
--segment-border: #B9BEC7;
--tile-surface: #FFFFFF;
--tile-surface-hover: #F3F4F6;
--tile-border: #B9BEC7;
--tile-selected-border: #D99A14;
--tile-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86), 0 2px 7px rgba(23, 25, 29, 0.12);
--donut-rack-surface: var(--segment-surface);
--donut-card-surface: var(--tile-surface);
--donut-card-surface-end: var(--tile-surface);
--donut-track: #B9BEC7;
--donut-border: var(--segment-border);
--donut-value: #20242A;
--donut-label: #555B65;
--donut-all: #1C70C9;
@ -389,8 +435,8 @@
--donut-not-applicable: #6D2CE8;
--donut-not-run: #626873;
--donut-error: #D42836;
--donut-rack-shadow: 0 8px 22px rgba(23, 25, 29, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.70);
--donut-card-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.86), 0 2px 7px rgba(23, 25, 29, 0.12);
--donut-rack-shadow: none;
--donut-card-shadow: var(--tile-card-shadow);
--surface-app: #F1F1F4;
--surface-shell: #E4E4EA;
--surface-panel: #FFFFFF;

View File

@ -14,5 +14,7 @@ describe('ASSESSOR version in the brand header', () => {
expect(app).not.toContain('>v{CURRENT_ASSESSOR_PROCESSING_VERSION}');
expect(layout).toMatch(/\.brand-copy\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-1\);/s);
expect(layout).toMatch(/\.brand-version\s*\{[^}]*font-size:\s*var\(--fs-micro\);[^}]*font-variant-numeric:\s*tabular-nums;/s);
expect(layout).toMatch(/\.brand\s*\{[^}]*min-height:\s*112px;[^}]*padding:\s*6px 14px 6px 16px;[^}]*margin-top:\s*0;/s);
expect(layout).not.toMatch(/\.app-shell[^,{]* \.brand\s*\{/s);
});
});

View File

@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const main = readFileSync(new URL('../../src/main.tsx', import.meta.url), 'utf8');
const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8');
const base = readFileSync(new URL('../../src/styles/base.css', import.meta.url), 'utf8');
const datahub = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
describe('binding ASSESSOR typography system', () => {
it('bundles one font family and exposes the approved semantic hierarchy', () => {
expect(main).toContain("import '@fontsource-variable/inter/wght.css';");
expect(tokens).toContain('--font-ui: "Inter Variable", sans-serif;');
expect(tokens).toContain('--font-mono: var(--font-ui);');
expect(tokens).toContain('--font-donut-display: var(--font-ui);');
expect(tokens).toContain('--type-h1-size: 56px;');
expect(tokens).toContain('--type-h2-size: 32px;');
expect(tokens).toContain('--type-h3-size: 18px;');
expect(tokens).toContain('--type-h4-size: 16px;');
expect(tokens).toContain('--type-body-size: 16px;');
expect(tokens).toContain('--type-label-size: 14px;');
expect(tokens).toContain('--type-meta-size: 13px;');
expect(tokens).toContain('--type-badge-size: 12px;');
expect(tokens).toContain('--type-table-head-size: 13px;');
expect(tokens).toContain('--type-button-size: 14px;');
expect(tokens).toContain('--fw-regular: 400;');
expect(tokens).toContain('--fw-semibold: 600;');
expect(tokens).toContain('--fw-bold: 700;');
});
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(/td\s*\{[^}]*font-size:\s*var\(--type-body-size\);/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-information-title > strong\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*text-transform:\s*none;/s);
expect(datahub).toMatch(/\.tenant-detail-page \.tenant-editor-form \.tenant-field-label\s*\{[^}]*font-size:\s*var\(--type-label-size\);[^}]*text-transform:\s*none;/s);
});
});

View File

@ -4,25 +4,39 @@ import { describe, expect, it } from 'vitest';
import { DonutMetricCard } from '../../src/components/DonutMetricCard/DonutMetricCard';
const styles = readFileSync(new URL('../../src/styles/components.css', import.meta.url), 'utf8');
const tileContract = readFileSync(new URL('../../src/styles/tile-contract.css', import.meta.url), 'utf8');
const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8');
describe('DonutMetricCard binding design contract', () => {
it('renders the reference card structure, 270-degree progress and visible values', () => {
it('renders the shared full-circle reference with centered percentage and supporting count', () => {
const html = renderToStaticMarkup(
<DonutMetricCard label="Deviation" value={453} percentage={51} tone="deviation" />
);
expect(html).toContain('class="donut-metric-card donut-metric-card--deviation"');
expect(html).toContain('class="donut-metric-card__title">Deviation</h3>');
expect(html).toContain('stroke-dasharray="75 25"');
expect(html).toContain('stroke-dasharray="38.25 61.75"');
expect(html).toContain('<strong>453</strong>');
expect(html).toContain('<span>51%</span>');
expect(html).toContain('--donut-progress-dash:38.25 61.75');
expect(html).toContain('class="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100"');
expect(html).toContain('stroke-dasharray="51 49"');
expect(html).toContain('<div class="donut-metric-card__primary"><strong>51</strong><span>%</span></div>');
expect(html).toContain('<small>453</small>');
expect(html).toContain('--donut-progress-dash:51 49');
expect(styles).toContain('transform: rotate(-90deg);');
expect(styles).toContain('left: calc(100% + var(--space-1));');
expect(styles).toMatch(/\.donut-metric-card__primary\s*\{[^}]*position:\s*relative;/s);
expect(styles).not.toContain('stroke-linecap: round;');
expect(styles).toContain('@keyframes donut-progress-build');
expect(styles).toContain('@keyframes donut-accent-build');
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(tileContract).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s);
expect(styles).toMatch(/@media \(max-width: 1100px\)\s*\{[^}]*repeat\(3, minmax\(0, 1fr\)\)/s);
expect(styles).toMatch(/@media \(max-width: 760px\)\s*\{[^}]*repeat\(2, minmax\(0, 1fr\)\)/s);
expect(tokens).toContain('--dur-data-build: 700ms;');
expect(tokens).toContain('--donut-value-size: 26px;');
expect(tokens).toContain('--donut-percent-size: 14px;');
expect(tokens).toContain('--donut-compact-value-size: 22px;');
expect(tokens).toContain('--donut-compact-percent-size: 12px;');
expect(styles).toMatch(/\.sidebar-severity-card \.donut-metric-card__primary span\s*\{[^}]*left:\s*calc\(100% \+ var\(--donut-compact-unit-gap\)\);/s);
});
it('renders an empty progress arc at zero and clamps percentages', () => {
@ -34,12 +48,22 @@ describe('DonutMetricCard binding design contract', () => {
);
expect(zero).toContain('stroke-dasharray="0 100"');
expect(zero).toContain('<span>0%</span>');
expect(clamped).toContain('stroke-dasharray="75 25"');
expect(clamped).toContain('<span>100%</span>');
expect(zero).toContain('<strong>0</strong><span>%</span>');
expect(clamped).toContain('stroke-dasharray="100 0"');
expect(clamped).toContain('donut-metric-card__primary is-compact');
expect(clamped).toContain('<strong>100</strong><span>%</span>');
expect(zero).not.toContain('<animate');
});
it('supports a single suffixed score without a duplicate percentage label', () => {
const html = renderToStaticMarkup(
<DonutMetricCard label="Compliance Score" value={36} valueSuffix="%" showPercentage={false} percentage={36} tone="all" />
);
expect(html).toContain('<strong>36%</strong>');
expect(html).not.toContain('<span>%</span>');
});
it('becomes an accessible active filter control when clickable', () => {
const html = renderToStaticMarkup(
<DonutMetricCard

View File

@ -37,7 +37,7 @@ describe('ResizableResultTable', () => {
Object.defineProperty(firstColumnCells[1], 'scrollWidth', { configurable: true, value: 238 });
act(() => container.querySelector('.result-column-resizer')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
expect((container.querySelector('col') as HTMLElement).style.width).toBe('240px');
expect((container.querySelector('col') as HTMLTableColElement | null)?.style.width).toBe('240px');
expect(container.querySelector('table')?.classList.contains('has-custom-column-widths')).toBe(true);
let handle = container.querySelector('.result-column-resizer') as HTMLElement;
@ -49,7 +49,7 @@ describe('ResizableResultTable', () => {
act(() => handle.dispatchEvent(pointerEvent('pointerdown', 7, 100)));
handle = container.querySelector('.result-column-resizer') as HTMLElement;
act(() => handle.dispatchEvent(pointerEvent('pointermove', 7, 148)));
expect((container.querySelector('col') as HTMLElement).style.width).toBe('168px');
expect((container.querySelector('col') as HTMLTableColElement | null)?.style.width).toBe('168px');
act(() => root.unmount());
});
@ -66,7 +66,7 @@ describe('ResizableResultTable', () => {
headers.forEach(cell => { cell.getBoundingClientRect = () => ({ width: 100 } as DOMRect); });
act(() => container.querySelector('.result-column-resizer')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })));
expect((container.querySelector('col') as HTMLElement).style.width).toBe('116px');
expect((container.querySelector('col') as HTMLTableColElement | null)?.style.width).toBe('116px');
act(() => root.unmount());
});

View File

@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const workflow = readFileSync(new URL('../../.gitea/workflows/deploy.yml', import.meta.url), 'utf8');
const compose = readFileSync(new URL('../../docker-compose.deploy.yml', import.meta.url), 'utf8');
describe('deployment workflow', () => {
it('maps main and experimental to isolated stacks and VPN ports', () => {
expect(workflow).toContain('ASSESSOR_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}');
expect(workflow).toContain('ASSESSOR_ENV_FILE: ${{ secrets.STAGING_ENV_FILE }}');
expect(workflow).toContain('ASSESSOR_STACK=assessor_prod');
expect(workflow).toContain('ASSESSOR_STACK=assessor_experimental');
expect(workflow).toContain('ASSESSOR_PUBLISH_PORT=4173');
expect(workflow).toContain('ASSESSOR_PUBLISH_PORT=4174');
expect(workflow.match(/ASSESSOR_BIND=10\.0\.0\.154/g)).toHaveLength(2);
expect(workflow).not.toContain('assessor.soc-ops.de');
});
it('validates all database settings without printing secret values', () => {
expect(workflow.match(/if \[ -z "\$ASSESSOR_ENV_FILE" \]/g)).toHaveLength(2);
expect(workflow.match(/for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD/g)).toHaveLength(2);
expect(workflow).toContain('secret values were not printed.');
expect(workflow).not.toMatch(/echo "\$ASSESSOR_ENV_FILE"/);
expect(workflow).not.toContain('printf \'%s\\n\' "${{ secrets.');
});
it('tests, builds, validates and serializes each branch deployment', () => {
expect(workflow).toContain('run: npm test');
expect(workflow).toContain('run: npm run build');
expect(workflow).toMatch(/docker compose[^\n]*config --quiet/);
expect(workflow).toContain('publish=$ASSESSOR_PUBLISH_PORT');
expect(workflow).toContain('project="$(docker inspect');
expect(workflow).toContain('if [ "$project" != "$ASSESSOR_STACK" ]');
expect(workflow).toContain('name: Acquire environment deployment lock');
expect(workflow).toContain('name: Release environment deployment lock');
expect(workflow).toContain('${ASSESSOR_STACK}_deployment_lock');
expect(workflow).toContain('ASSESSOR_LOCK_ACQUIRED=true');
expect(workflow).toContain('if: always()');
expect(workflow).not.toMatch(/^concurrency:/m);
});
it('creates separate pre-deployment database and artifact backups', () => {
expect(workflow).toContain('backup_volume=assessor_deployment_backups');
expect(workflow).toContain('pg_dump --format=custom --no-owner');
expect(workflow).toContain('artifacts.tar.gz');
expect(workflow).toContain('SHA256SUMS');
expect(workflow).not.toContain('down --volumes');
expect(workflow).not.toContain('docker image prune');
expect(workflow).not.toMatch(/docker compose[^\n]*logs/);
});
it('checks application health inside the app container', () => {
expect(workflow).toMatch(/docker compose[^\n]*exec -T app/);
expect(workflow).toContain("fetch('http://127.0.0.1:4173/api/v1/health')");
expect(workflow).not.toMatch(/curl[^\n]*127\.0\.0\.1:4173/);
});
});
describe('deployment Compose stack', () => {
it('requires an explicit bind, port and database configuration', () => {
expect(compose).toContain('${ASSESSOR_BIND:?set ASSESSOR_BIND}');
expect(compose).toContain('${ASSESSOR_PUBLISH_PORT:?set ASSESSOR_PUBLISH_PORT}');
expect(compose).toContain('${ASSESSOR_POSTGRES_DB:?set ASSESSOR_POSTGRES_DB}');
expect(compose).toContain('${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER}');
expect(compose).toContain('${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD}');
});
it('keeps PostgreSQL internal and scopes persistent volumes through Compose', () => {
const postgresSection = compose.slice(compose.indexOf(' postgres:'));
expect(postgresSection).not.toMatch(/\n ports:/);
expect(compose).toContain('assessor-artifacts:/var/lib/assessor/artifacts');
expect(compose).toContain('assessor-postgres-data:/var/lib/postgresql/data');
expect(compose.match(/restart: unless-stopped/g)).toHaveLength(2);
});
});

View File

@ -85,17 +85,16 @@ const prohibitions = [
label: 'einen Schatten ausserhalb der Overlay-Rollen',
/* `inset` bleibt ausgenommen: eingesenkte Wertfelder sind Flaechen-
* modellierung, kein schwebendes Element. */
/* Der bindende Donut-Vertrag vom 2026-08-05 traegt zwei eigene,
* tokenisierte Tiefenschatten. Sie sind Teil der Nutzerreferenz und
* ausschliesslich an DonutMetricCard/Rack gebunden. */
matches: line => line.includes('box-shadow') && !/shadow-overlay|shadow-popover|inset|var\(--donut-(?:rack|card)-shadow\)/.test(line),
remedy: 'Schatten tragen nur echte Overlays (--shadow-overlay, --shadow-popover). Panels grenzen ueber Haarlinien ab.',
/* Der bindende Segment-/Kachelvertrag erlaubt genau den gemeinsamen,
* dezenten Kachelschatten. Segmente selbst bleiben flach. */
matches: line => line.includes('box-shadow') && !/box-shadow:\s*none|shadow-overlay|shadow-popover|inset|var\(--(?:donut-(?:rack|card)-shadow|tile-card-shadow)\)/.test(line),
remedy: 'Nur echte Overlays und Kacheln verwenden ihre gemeinsamen Schattentokens; Segmente und Panels grenzen ueber Haarlinien ab.',
budgets: new Map([
['components.css', 6],
['components.css', 2],
['datahub.css', 2],
['import-workflow.css', 1],
['layout.css', 7],
['legacy-views.css', 6]
['layout.css', 1],
['legacy-views.css', 0]
])
},
{
@ -310,13 +309,18 @@ assert.match(searchFilterControls, /placeholder="Search"/, 'Das Freitextfeld mus
assert.doesNotMatch(searchFilterControls, /Filter text/, 'Die alte Bezeichnung Filter text darf nicht sichtbar bleiben.');
assert.match(
shellLayout,
/\.area-topbar strong\s*\{[^}]*font-size:\s*calc\(var\(--fs-xl\) \* 4\);[^}]*font-weight:\s*var\(--fw-semibold\);[^}]*line-height:\s*1\.15;[^}]*letter-spacing:\s*-\.04em;/,
/\.area-topbar strong\s*\{[^}]*font-size:\s*var\(--type-h1-size\);[^}]*font-weight:\s*var\(--fw-semibold\);[^}]*line-height:\s*1\.1;[^}]*letter-spacing:\s*-\.04em;/,
'layout.css muss die Bereichsbezeichnungen aller regulaeren Kopfpanels wie den Mandantentitel darstellen.'
);
assert.match(
shellLayout,
/\.app-shell:not\(\.app-shell-work\) \.brand\s*\{[^}]*min-height:\s*112px;[^}]*padding-block:\s*6px;[^}]*margin-top:\s*0;/,
'layout.css muss die Marke mittig zur horizontalen Achse der grossen Bereichsbezeichnung ausrichten.'
/\.brand\s*\{[^}]*min-height:\s*112px;[^}]*padding:\s*6px 14px 6px 16px;[^}]*margin-top:\s*0;/,
'layout.css muss die Marke in allen Ansichten fest auf der Datahub-Position halten.'
);
assert.doesNotMatch(
shellLayout,
/\.app-shell[^,{]* \.brand\s*\{/,
'Ansichtsspezifische Brand-Geometrie ist nicht erlaubt, damit Logo und Schrift beim Ansichtswechsel nicht springen.'
);
assert.doesNotMatch(
shellLayout,

View File

@ -28,7 +28,7 @@ describe('processing compatibility contracts', () => {
it('separates PingCastle presentation from canonical mapping semantics', () => {
const old = file('old', 'pingcastle', '1.1.0', '1.1.0');
const contextless = file('contextless', 'pingcastle', '1.2.0', '1.1.0');
const current = file('current', 'pingcastle', '1.3.0', '1.1.0');
const current = file('current', 'pingcastle', '1.4.0', '1.2.0');
expect(resolvedCompatibilityKey('pingcastle', old.processedWith)).toBe('pingcastle-canonical-v1.1');
expect(resolvedCompatibilityKey('pingcastle', contextless.processedWith)).toBe('pingcastle-canonical-v1.1');
@ -41,7 +41,7 @@ describe('processing compatibility contracts', () => {
it('blocks semantically incompatible versions in one scope', () => {
const legacy = file('legacy', 'pingcastle', '1.0.0', '1.0.0');
const current = file('current', 'pingcastle', '1.3.0', '1.1.0');
const current = file('current', 'pingcastle', '1.4.0', '1.2.0');
expect(processingReadiness('pingcastle', legacy.processedWith)).toBe('incompatible');
expect(processingScopeIssues([legacy, current])).toMatchObject([{
toolId: 'pingcastle', code: 'incompatible-processing-contracts'

View File

@ -0,0 +1,106 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import type { FindingRecord } from '../../src/domain/finding';
import { 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 styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
describe('dashboard reference layout', () => {
it('uses the reference metric strip for compliance, primary statuses and the compact summary', () => {
expect(component).toContain('className="dashboard-status-rack"');
expect(component).toContain('label="Compliance Score"');
expect(component).toContain('<DashboardStatusMetric label="Deviation"');
expect(component).toContain('<DashboardStatusMetric label="Passed"');
expect(component).toContain('<DashboardStatusMetric label="Not Run"');
expect(component).toContain('className="dashboard-status-summary"');
expect(component).toContain('<span>Not Applicable</span>');
expect(component).toContain('<span>Error</span>');
expect(component).toContain('className="dashboard-ioe-summary" type="button"');
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(/\.dashboard-status-copy > strong\s*\{[^}]*display:\s*grid;[^}]*white-space:\s*normal;/s);
expect(styles).toMatch(/\.dashboard-status-copy > strong small, \.dashboard-status-copy > small\s*\{[^}]*display:\s*block;/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', () => {
expect(component).toContain('heading="Severity distribution"');
expect(component).toContain('heading="Scan source distribution"');
expect(component).toContain('className="dashboard-lower-grid"');
expect(styles).toMatch(/\.dashboard-distribution-grid\s*\{[^}]*grid-template-columns:\s*repeat\(2,/s);
expect(styles).toContain('.dashboard-segmented-bar');
});
it('matches the reference structure for directory coverage and risk profile', () => {
expect(component).toContain('className="dashboard-coverage-summary"');
expect(component).toContain('<small>typed observations</small>');
expect(component).toContain('className="dashboard-block dashboard-risk-profile"');
expect(component).toContain('className="dashboard-marked-summary"');
expect(component).toContain('className="dashboard-risk-tool"');
expect(component).toContain('<option value={fullRiskProfileId}>Full Risk Profile</option>');
expect(component).toContain('className="dashboard-risk-donut"');
expect(component).toContain('<span className="dashboard-donut-primary is-compact"><strong>100</strong><small>%</small></span>');
expect(styles).toContain('.dashboard-risk-donut-track { stroke: var(--donut-track); }');
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)');
expect(component).toContain('matchesDynamicFacets(record, [sourceFacet]');
expect(component).not.toContain('dashboard-review-block');
expect(styles).toMatch(/\.dashboard-facts\s*\{[^}]*grid-template-columns:\s*repeat\(4,/s);
expect(styles).toMatch(/\.dashboard-risk-body\s*\{[^}]*grid-template-columns:/s);
});
it('removes only a trailing scan timestamp from source labels', () => {
expect(dashboardScanSourceLabel('PurpleKnight · 2026-08-22T08:15:30.000Z')).toBe('PurpleKnight');
expect(dashboardScanSourceLabel('CIS-CAT Pro · 2026-08-22')).toBe('CIS-CAT Pro');
expect(dashboardScanSourceLabel('Scanner · Production')).toBe('Scanner · Production');
});
it('groups scan runs into one selectable risk profile per tool', () => {
expect(dashboardRiskTools([
{ value: 'pingcastle-a', label: 'PingCastle · 2026-08-22T08:15:30.000Z', count: 20 },
{ value: 'pingcastle-b', label: 'PingCastle · 2026-08-23T08:15:30.000Z', count: 24 },
{ value: 'purpleknight-a', label: 'PurpleKnight · 2026-08-23T09:00:00.000Z', count: 30 }
])).toEqual([
{ id: 'pingcastle', label: 'PingCastle', sourceValues: ['pingcastle-a', 'pingcastle-b'], count: 44 },
{ id: 'purpleknight', label: 'PurpleKnight', sourceValues: ['purpleknight-a'], count: 30 }
]);
});
it('keeps the complete PingCastle risk catalog including zero scores', () => {
expect(dashboardPingCastleRiskCategories([
{ property: 'risk.anomalies', normalizedValue: 20, originalValue: '20' },
{ property: 'risk.stale_objects', normalizedValue: 10, originalValue: '10' },
{ property: 'risk.privileged_accounts', normalizedValue: 7, originalValue: '7' },
{ property: 'risk.trusts', normalizedValue: 0, originalValue: '0' }
], [])).toEqual([
{ value: 'anomalies', label: 'Anomalies', count: 20 },
{ value: 'stale objects', label: 'Stale Objects', count: 10 },
{ value: 'privileged accounts', label: 'Privileged Accounts', count: 7 },
{ value: 'trusts', label: 'Trusts', count: 0 }
]);
});
it('counts only explicit IOE-found source results for the top summary', () => {
const record = (id: string, value: string): FindingRecord => ({
id, index: 0, title: id,
fields: [{ index: 0, label: 'Result', value }],
filled: [], meta: []
});
expect(dashboardExplicitIoeCount([
record('ioe', 'IOE Found'),
record('ioes', 'IOEs found'),
record('deviation', 'Failed'),
record('pass', 'Passed')
])).toBe(2);
expect(dashboardExplicitIoeRecordIds([
record('ioe', 'IOE Found'),
record('ioes', 'IOEs found'),
record('deviation', 'Failed')
])).toEqual(['ioe', 'ioes']);
});
});

View File

@ -7,6 +7,7 @@ import { DatahubHistoryOverview } from '../../src/features/project-import/Datahu
const datahubStyles = readFileSync(new URL('../../src/styles/datahub-redesign.css', import.meta.url), 'utf8');
const datahubTableStyles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
const componentStyles = readFileSync(new URL('../../src/styles/components.css', import.meta.url), 'utf8');
const tileContractStyles = readFileSync(new URL('../../src/styles/tile-contract.css', import.meta.url), 'utf8');
const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8');
const overview = readFileSync(new URL('../../src/features/project-import/DatahubHistoryOverview.tsx', import.meta.url), 'utf8');
const donut = readFileSync(new URL('../../src/components/DonutMetricCard/DonutMetricCard.tsx', import.meta.url), 'utf8');
@ -16,7 +17,7 @@ const baselines = readFileSync(new URL('../../src/features/project-import/Micros
describe('Datahub centered reference rack layout', () => {
it('uses the binding six-card donut design for the selected timeslot', () => {
expect(datahubStyles).toContain('.datahub-page > .datahub-rack-module { width: min(1180px, 100%); margin-inline: auto; }');
expect(datahubStyles).toContain('.datahub-tenant-card-rack { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr));');
expect(datahubStyles).toContain('.datahub-tenant-card-rack { display: grid; grid-template-columns: repeat(auto-fill, var(--tile-compact-width)); justify-content: start;');
expect(datahubStyles).toContain('.datahub-tenant-card.is-selected');
expect(datahubStyles).toContain('.datahub-tenant-processing-dot { position: absolute; z-index: 3; top: var(--datahub-readiness-dot-inset); left: var(--datahub-readiness-dot-inset);');
expect(datahubStyles).toContain('background: var(--state-online); pointer-events: none;');
@ -24,10 +25,13 @@ describe('Datahub centered reference rack layout', () => {
expect(datahubStyles).toContain('.datahub-tenant-processing-dot[data-status="error"] { background: var(--state-offline); }');
expect(datahubStyles).toContain('.datahub-tenant-show[data-status="update"]');
expect(datahubStyles).toContain('border-color: var(--warning); color: var(--warning);');
expect(datahubStyles).toContain('outline: var(--bw-emphasis) solid var(--button-marked-bg);');
expect(datahubStyles).toContain('outline: var(--bw-emphasis) solid var(--tile-selected-border);');
expect(datahubStyles).toContain('.datahub-tenant-card::before { content: none; }');
expect(datahubStyles).toContain('transition: background .12s ease, border-color .12s ease, box-shadow .12s ease, transform .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\(--panel-hover\);[^}]*box-shadow:\s*var\(--shadow-raised-hover\);[^}]*transform:\s*translateY\(-2px\);/s);
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).not.toMatch(/\.datahub-tenant-card\s*\{[^}]*box-shadow/s);
expect(datahubStyles).toContain('box-shadow: var(--tile-card-shadow);');
expect(datahubStyles).not.toContain('translateY(-2px)');
expect(datahubStyles).toContain('.datahub-tenant-select { position: absolute; inset: 0;');
expect(datahubStyles).toContain('grid-template-rows: minmax(0, 1fr) auto auto;');
expect(datahubStyles).toContain('font: var(--fw-semibold) calc(var(--donut-title-size) * 1.35)');
@ -44,12 +48,14 @@ describe('Datahub centered reference rack layout', () => {
expect(componentStyles).toContain('Verbindlicher Donut-Standard: Referenz vom 2026-08-05.');
expect(componentStyles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr));');
expect(componentStyles).toContain('padding: var(--space-3);');
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('.donut-metric-card::before');
expect(componentStyles).toContain('border-radius: var(--donut-card-radius);');
expect(componentStyles).toContain('background: linear-gradient(145deg, var(--donut-card-surface), var(--donut-card-surface-end));');
expect(componentStyles).toContain('border-radius: var(--tile-radius);');
expect(componentStyles).toContain('background: var(--tile-surface);');
expect(componentStyles).toContain('.donut-metric-card__progress');
expect(componentStyles).toContain('stroke-linecap: round;');
expect(componentStyles).not.toContain('stroke-linecap: round;');
expect(componentStyles).toContain('transform: rotate(-90deg);');
expect(componentStyles).toContain('.donut-metric-card--all { color: var(--donut-all); }');
expect(componentStyles).toContain('.donut-metric-card--deviation { color: var(--donut-deviation); }');
expect(componentStyles).toContain('.donut-metric-card--passed { color: var(--donut-passed); }');
@ -57,16 +63,19 @@ describe('Datahub centered reference rack layout', () => {
expect(componentStyles).toContain('.donut-metric-card--not-run { color: var(--donut-not-run); }');
expect(componentStyles).toContain('.donut-metric-card--error { color: var(--donut-error); }');
expect(tokens).toContain('--font-donut-display: "Bahnschrift Condensed"');
expect(tokens).toContain('--font-ui: "Inter Variable", sans-serif;');
expect(tokens).toContain('--font-donut-display: var(--font-ui);');
expect(tokens).toContain('--donut-rack-surface:');
expect(tokens).toContain('--donut-ring-size: 128px;');
expect(tokens).toContain('--donut-ring-stroke: 12px;');
expect(tokens).toContain('--datahub-readiness-dot-size: 14px;');
expect(tokens).toContain('--datahub-readiness-dot-inset: var(--space-1);');
expect(tokens).toContain('Donut-Standard: identische Geometrie, helle Referenzpalette.');
expect(tokens).toContain('--donut-rack-surface: #E7E9ED;');
expect(tokens).toContain('--segment-surface: #E7E9ED;');
expect(tokens).toContain('--donut-rack-surface: var(--segment-surface);');
expect(tokens).toContain('--donut-deviation: #9A6500;');
expect(donut).toContain('strokeDasharray="75 25"');
expect(donut).toContain('const arcLength = boundedPercentage * 0.75;');
expect(donut).toContain('<circle className="donut-metric-card__track" cx="60" cy="60" r="48" pathLength="100" />');
expect(donut).toContain("const dashArray = String(boundedPercentage) + ' ' + String(100 - boundedPercentage);");
expect(donut).toContain("const progressStyle = { '--donut-progress-dash': dashArray } as CSSProperties;");
expect(donut).toContain('key={label + \'-\' + value + \'-\' + boundedPercentage}');
expect(donut).not.toContain('<animate');

View File

@ -43,7 +43,7 @@ describe('Datahub client processing readiness', () => {
file('run-old', 'pingcastle', '1.1.0', 'pingcastle-canonical-v1.1')
])).toMatchObject({
state: 'update',
explanation: 'ASSESSOR profile update: 1.1.0 → 1.3.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
explanation: 'ASSESSOR profile update: 1.1.0 → 1.4.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
});
});
@ -52,7 +52,7 @@ describe('Datahub client processing readiness', () => {
file('run-contextless', 'pingcastle', '1.2.0', 'pingcastle-canonical-v1.1')
])).toMatchObject({
state: 'update',
explanation: 'ASSESSOR profile update: 1.2.0 → 1.3.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
explanation: 'ASSESSOR profile update: 1.2.0 → 1.4.0. Reimporting applies the newer ASSESSOR processing profile; the pingcastle report itself has not changed. The newer ASSESSOR profile captures additional environment context for the assessment.'
});
});

View File

@ -69,7 +69,7 @@ describe('Mandant detail reference', () => {
expect(dataHub).toContain('className="tenant-field-value tenant-field-prose"');
expect(dataHub).toContain('<span className="tenant-field-label">Name');
expect(dataHub).not.toContain('tenant-identity-name-input');
expect(styles).toMatch(/\.tenant-detail-page \.tenant-editor-form \.tenant-field-value\s*\{[^}]*justify-self:\s*start;[^}]*font-size:\s*var\(--fs-lg\);[^}]*text-align:\s*left;/s);
expect(styles).toMatch(/\.tenant-detail-page \.tenant-editor-form \.tenant-field-value\s*\{[^}]*justify-self:\s*start;[^}]*font-size:\s*var\(--type-body-size\);[^}]*text-align:\s*left;/s);
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).toContain('const toggleScanReport =');
@ -81,9 +81,9 @@ describe('Mandant detail reference', () => {
expect(styles).toMatch(/\.tenant-detail-page \.tenant-editor-form\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/s);
expect(styles).toMatch(/accent-color:\s*var\(--state-active\);/s);
expect(styles).toMatch(/\.tenant-detail-page \.tenant-editor-form label\s*\{[^}]*display:\s*block;[^}]*border-bottom:/s);
expect(styles).toMatch(/\.tenant-field-label\s*\{[^}]*background:\s*transparent;[^}]*text-transform:\s*uppercase;/s);
expect(styles).toMatch(/\.tenant-detail-page \.tenant-editor-form \.tenant-field-label\s*\{[^}]*background:\s*transparent;[^}]*font-size:\s*var\(--type-label-size\);[^}]*text-transform:\s*none;/s);
expect(styles).toMatch(/\.tenant-report-copy b\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s);
expect(styles).toMatch(/\.tenant-report-copy small\s*\{[^}]*font-size:\s*var\(--fs-sm\);/s);
expect(styles).toMatch(/\.tenant-report-copy small\s*\{[^}]*font-size:\s*var\(--type-meta-size\);/s);
expect(styles).toMatch(/\.tenant-report-row\s*\{[^}]*grid-template-columns:[^;}]*calc\(var\(--space-8\) \* 4\.25\)/s);
expect(dataHub).toContain('rows={10} placeholder="Notes about the client or project"');
expect(styles).toMatch(/\.tenant-detail-page \.tenant-editor-form textarea\.input\s*\{[^}]*width:\s*100%;/s);

View File

@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const components = readFileSync(new URL('../../src/styles/components.css', import.meta.url), 'utf8');
const legacyViews = readFileSync(new URL('../../src/styles/legacy-views.css', import.meta.url), 'utf8');
const tokens = readFileSync(new URL('../../src/styles/tokens.css', import.meta.url), 'utf8');
describe('Finding title typography', () => {
it('uses the emphasized 20px detail-title role in the reader', () => {
expect(components).toMatch(/\.reader-heading h2\s*\{[^}]*font-size:\s*var\(--type-detail-title-size\);/s);
expect(components).toMatch(/\.reader-summary h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);/s);
expect(legacyViews).toMatch(/\.reader-heading h2\s*\{[^}]*font-size:\s*var\(--type-detail-title-size\);/s);
expect(legacyViews).toMatch(/\.reader-summary h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);/s);
});
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-weight:\s*var\(--fw-content-medium\);/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);
});
it('uses the compact badge role for finding labels and outcomes', () => {
expect(tokens).toContain('--ui-badge-size: var(--type-badge-size);');
expect(legacyViews).toMatch(/\.finding-badges, \.mitre-line\s*\{[^}]*font-size:\s*var\(--ui-badge-size\);/s);
expect(legacyViews).toMatch(/\.status-badge, \.severity-badge, \.mitre-badge, \.reader-severity\s*\{[^}]*font-size:\s*var\(--ui-badge-size\);/s);
});
});

View File

@ -224,8 +224,8 @@ describe('Obsidian-style interactive graph', () => {
expect(graphStyles).toMatch(/\.obsidian-graph-card\.is-selected \.obsidian-node-card,[\s\S]*?\.obsidian-graph-card\.is-selected \.obsidian-node-matrix-card \.data-tile\s*\{/s);
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card\s*\{[^}]*width:\s*100%;[^}]*height:\s*100%;/s);
expect(graphStyles).not.toContain('--graph-card-content-scale');
expect(graphStyles).toMatch(/\.obsidian-node-card\.data-tile:hover,[\s\S]*?\{[^}]*box-shadow:\s*none;[^}]*transform:\s*none;/);
expect(graphStyles).toMatch(/\.obsidian-node-matrix-card \.data-tile:hover,[\s\S]*?\{[^}]*box-shadow:\s*none;[^}]*transform:\s*none;/);
expect(graphStyles).not.toContain('box-shadow');
expect(graphStyles).not.toContain('translateY(-2px)');
expect(graphStyles).toMatch(/\.obsidian-node-card \.data-tile-content h3\s*\{[^}]*font-size:\s*var\(--pathfinder-micro-title-size\);/s);
expect(graphStyles).toMatch(/\.obsidian-node-inline-label\s*\{[^}]*font-size:\s*var\(--fs-lg\);/s);
expect(graphStyles).toMatch(/\.obsidian-node-card \.reader-severity\s*\{[^}]*font-size:\s*var\(--pathfinder-micro-relation-badge-size\);/s);

View File

@ -17,7 +17,7 @@ describe('rounded panel content inset standard', () => {
});
it('uses the shared inset in every Work presentation including responsive tiles', () => {
expect(layout).toMatch(/\.tile-grid\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);
expect(layout).toMatch(/\.tile-view-scroll\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);
expect(layout).toMatch(/\.raw-view\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);
expect(legacy).toMatch(/\.records-layout\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);
expect(legacy).toMatch(/\.table-scroll\s*\{[^}]*padding:\s*var\(--panel-inset\);/s);

View File

@ -12,7 +12,7 @@ describe('Result DB timeslot scope', () => {
expect(appSource).toContain('scanRunIds={app.activeScanRunIds}');
expect(browserSource).toContain("backendStatus !== 'connected' || !projectId || !scanRunIds?.length");
expect(browserSource).toContain('<h3>No timeslot selected</h3>');
expect(browserSource).toContain('scanRunIds\n };');
expect(browserSource).toMatch(/scanRunIds\r?\n };/);
expect(browserSource).toContain('queryUnifiedResultFacets(scopedQuery)');
expect(browserSource).toContain('queryUnifiedResults({ ...scopedQuery, offset: 0, limit })');
expect(browserSource).toContain("item.sourceKind === 'environment-observation'");

View File

@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8');
const tokens = read('../../src/styles/ui-tokens.css');
const components = read('../../src/styles/components.css');
const tileContract = read('../../src/styles/tile-contract.css');
const datahub = read('../../src/styles/datahub.css');
const datahubRedesign = read('../../src/styles/datahub-redesign.css');
const legacy = read('../../src/styles/legacy-views.css');
const layout = read('../../src/styles/layout.css');
const uiComponents = read('../../src/styles/ui-components.css');
const policy = read('../../src/styles/policy-analysis.css');
const pathfinder = read('../../src/styles/mitre-graph.css');
const viewer = read('../../src/features/dataset-viewer/DatasetViewer.tsx');
const overview = read('../../src/features/project-import/DatahubHistoryOverview.tsx');
const guidance = read('../../AGENTS.md');
describe('binding segment and tile grammar', () => {
it('defines one semantic geometry and surface contract for both themes', () => {
for (const token of [
'--segment-surface:', '--segment-border:', '--segment-radius: 10px;',
'--segment-inset: var(--space-3);', '--segment-gap: var(--space-2);',
'--tile-surface:', '--tile-surface-hover:', '--tile-border:',
'--tile-selected-border:', '--tile-radius: 8px;',
'--tile-compact-width: 186px;', '--tile-content-width: 320px;',
'--tile-card-shadow:'
]) expect(tokens).toContain(token);
expect(tokens).toContain('--donut-rack-surface: var(--segment-surface);');
expect(tokens).toContain('--donut-card-surface: var(--tile-surface);');
expect(tokens).toContain('--donut-card-shadow: var(--tile-card-shadow);');
});
it('binds the shared grammar to Datahub, dashboard, sidebar, Work, policy and Pathfinder tiles', () => {
expect(components).toMatch(/\.donut-metric-rack\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);[^}]*padding:\s*var\(--segment-inset\);[^}]*border-radius:\s*var\(--segment-radius\);/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(components).toMatch(/\.donut-metric-card\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
expect(components).toMatch(/\.sidebar-severity-rack\s*\{[^}]*gap:\s*var\(--segment-gap\);[^}]*background:\s*var\(--segment-surface\);/s);
expect(datahubRedesign).toMatch(/\.datahub-tenant-card-rack\s*\{[^}]*repeat\(auto-fill, var\(--tile-compact-width\)\);[^}]*justify-content:\s*start;/s);
expect(datahubRedesign).toMatch(/\.datahub-tenant-card\.is-selected\s*\{[^}]*border-color:\s*var\(--tile-selected-border\);/s);
expect(overview).toContain('donut-metric-card donut-metric-card--all datahub-tenant-card datahub-tenant-add');
expect(datahub).toMatch(/\.dashboard-status-rack\s*\{[^}]*padding:\s*var\(--segment-inset\);[^}]*background:\s*var\(--segment-surface\);/s);
expect(datahub).toMatch(/\.dashboard-status-metric\s*\{[^}]*border-radius:\s*var\(--tile-radius\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
expect(datahub).toMatch(/\.dashboard-distribution-grid\s*\{[^}]*border-radius:\s*var\(--segment-radius\);/s);
expect(datahub).toMatch(/\.db-structure-card\s*\{[^}]*background:\s*var\(--tile-surface\);[^}]*box-shadow:\s*var\(--tile-card-shadow\);/s);
expect(layout).toMatch(/\.tile-view-scroll\s*\{[^}]*padding:\s*var\(--panel-inset\);/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(viewer).toContain('gridTemplateColumns: `repeat(${tileColumns}, minmax(var(--tile-content-width), 1fr))`');
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);
});
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('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('Tile content must never cross the tile boundary or cover a neighboring tile.');
expect(guidance).toContain('never split ordinary words or numeric values across lines');
expect(datahub).toMatch(/\.dashboard-status-metric\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;/s);
expect(datahub).toMatch(/\.dashboard-status-copy\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;/s);
expect(datahub).toMatch(/\.dashboard-status-copy\s*>\s*span\s*\{[^}]*overflow-wrap:\s*normal;[^}]*word-break:\s*normal;/s);
expect(datahub).toMatch(/\.dashboard-status-copy\s*>\s*strong\s*\{[^}]*display:\s*grid;[^}]*white-space:\s*normal;/s);
expect(legacy).toMatch(/\.data-tile\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;/s);
expect(uiComponents).toMatch(/\.tile\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;/s);
expect(datahubRedesign).not.toMatch(/\.datahub-tenant-card(?::hover|:focus-within|\s*\{)[^}]*translateY/s);
expect(legacy).not.toMatch(/\.data-tile(?::hover|:focus-visible|\s*\{)[^}]*translateY/s);
expect(datahub).not.toMatch(/\.dashboard-status-metric(?::hover|\s*\{)[^}]*translateY/s);
});
});

View File

@ -16,15 +16,39 @@ describe('PingCastle native finding extraction', () => {
it('captures non-finding environment sections, typed facts and explicit absence in the canonical model', async () => {
const environmentSections = [
'<p>Date: 2026-08-20 - Engine version: 3.4.0</p>',
'<div id="sectionDomainInformation"><h2>Domain Information</h2>',
'<p>No Entra ID configuration has been found in this domain.</p>',
'<div id="sectionDomainInformation"><h1>Domain Information</h1>',
'<table><caption>Directory</caption><thead><tr>',
'<th>Domain</th><th>Domain Functional Level</th><th>Forest Functional Level</th><th>Schema Version</th><th>Recycle Bin Enabled</th><th>Number of DC</th>',
'</tr></thead><tbody><tr><td>synthetic.example</td><td>2016</td><td>2016</td><td>88</td><td>Yes</td><td>2</td></tr></tbody></table></div>',
'<div id="sectionPKI"><h2>Public Key Infrastructure</h2><table><caption>Certificate Authorities</caption>',
'</tr></thead><tbody><tr><td>synthetic.example</td><td>2016</td><td>2016</td><td>88</td><td>Yes</td><td>2</td></tr></tbody></table>',
'<h2>Entra ID Configuration</h2><p>No Entra ID configuration has been found in this domain.</p></div>',
'<div id="sectionPKI"><h1>Public Key Infrastructure</h1><h2>Certificates</h2><table><caption>Certificate Authorities</caption>',
'<thead><tr><th>Certificate Authority</th></tr></thead><tbody></tbody></table></div>',
'<div id="sectionInfrastructure"><h2>Infrastructure</h2><p>Number of WEF configuration found: 0</p></div>',
'<div id="sectionLAPS"><h2>LAPS</h2><p>Legacy LAPS installation date: Never Windows LAPS installation date: Never</p></div>',
'<div id="sectionInfrastructure"><h1>Infrastructure</h1><h2>Microsoft Entra Connect settings</h2>',
'<table><thead><tr><th>Service</th></tr></thead><tbody><tr><td>Synthetic Infrastructure</td></tr></tbody></table></div>',
'<div id="sectionAnomalyFacts"><p>Number of WEF configuration found: 0</p><p>Legacy LAPS installation date: Never Windows LAPS installation date: Never</p></div>',
'<div id="sectionUserInformation"><h1>User Information</h1><h2>Honey Pot</h2>',
'<table aria-label="Account list"><thead><tr><th>Name</th><th>Creation</th></tr></thead>',
'<tbody><tr><td>Synthetic Honeypot</td><td>2026-08-20</td></tr></tbody></table><h2>Account analysis</h2>',
'<table><thead><tr><th>Nb User Accounts</th><th>Nb Enabled</th></tr></thead>',
'<tbody><tr><td>12</td><td>10</td></tr></tbody></table><h2>Password Age Distribution</h2>',
'<div id="sectionNeverExpiresUser"><h3>Objects with a password which never expires</h3>',
'<table aria-label="Account list"><thead><tr><th>Name</th><th>Last logon</th></tr></thead>',
'<tbody><tr><td>Synthetic User</td><td>Never</td></tr></tbody></table></div></div>',
'<div id="sectionComputerInformation"><h1>Computer Information</h1><h2>Operating Systems</h2>',
'<table aria-label="Computer information list"><thead><tr><th>Name</th><th>Operating system</th></tr></thead>',
'<tbody><tr><td>Synthetic Computer</td><td>Synthetic OS</td></tr></tbody></table></div>',
'<div id="sectionAdminGroups"><h1>Admin Groups</h1><h2>Groups</h2><table><thead><tr><th>Group</th></tr></thead>',
'<tbody><tr><td>Synthetic Admin Group</td></tr></tbody></table></div>',
'<div id="sectionControlPathsAnalysis"><h1>Control Paths Analysis</h1><h2>Indirect links</h2><table><thead><tr><th>Path</th></tr></thead>',
'<tbody><tr><td>Synthetic Control Path</td></tr></tbody></table></div>',
'<div id="sectionAnomalies"><h1>Anomalies</h1><h2>AdminSDHolder (detect temporary elevated accounts)</h2><table><thead><tr><th>Item</th></tr></thead>',
'<tbody><tr><td>Synthetic Anomaly</td></tr></tbody></table></div>',
'<div id="sectionPasswordPolicies"><h1>Password Policies</h1><h2>Password policies</h2><table><thead><tr><th>Policy</th></tr></thead>',
'<tbody><tr><td>Synthetic Password Policy</td></tr></tbody></table></div>',
'<div id="sectionGPO"><h1>GPO</h1><h2>Security settings</h2><table><thead><tr><th>GPO</th></tr></thead>',
'<tbody><tr><td>Synthetic GPO</td></tr></tbody></table></div>',
'<div id="sectionFutureInventory"><h2>Future Inventory</h2><table><thead><tr><th>Value</th></tr></thead>',
'<tbody><tr><td>Synthetic unknown value</td></tr></tbody></table></div>',
'<div id="sectionMaturity"><h2>Maturity</h2><p>This section is reserved for customers who have purchased a license.</p></div>'
].join('');
const content = pingCastleHtml.replace('</main>', environmentSections + '</main>');
@ -36,15 +60,26 @@ describe('PingCastle native finding extraction', () => {
expect.objectContaining({ sectionKey: 'sectionMaturity', state: 'license-restricted' })
]));
expect(dataset.extraction?.environmentObservations).toEqual(expect.arrayContaining([
expect.objectContaining({ property: 'domain.fqdn', normalizedValue: 'synthetic.example' }),
expect.objectContaining({ property: 'domain.fqdn', normalizedValue: 'synthetic.example', subject: expect.objectContaining({ type: 'domain-information' }) }),
expect.objectContaining({ property: 'domain.functional_level', normalizedValue: 2016 }),
expect.objectContaining({ property: 'forest.functional_level', normalizedValue: 2016 }),
expect.objectContaining({ property: 'domain.recycle_bin_enabled', normalizedValue: true }),
expect.objectContaining({ property: 'domain.controller_count', normalizedValue: 2 }),
expect.objectContaining({ property: 'entra.configuration_detected', state: 'explicitly-absent' }),
expect.objectContaining({ property: 'logging.wef.configuration_count', normalizedValue: 0 }),
expect.objectContaining({ property: 'laps.legacy.installation_date', originalValue: 'Never', state: 'explicitly-absent' }),
expect.objectContaining({ property: 'report.engine_version', normalizedValue: '3.4.0' })
expect.objectContaining({ property: 'entra.configuration_detected', state: 'explicitly-absent', subject: expect.objectContaining({ type: 'domain-information.entra-id-configuration' }) }),
expect.objectContaining({ property: 'logging.wef.configuration_count', normalizedValue: 0, subject: expect.objectContaining({ type: 'anomalies.windows-event-forwarding-wef' }) }),
expect.objectContaining({ property: 'laps.legacy.installation_date', originalValue: 'Never', state: 'explicitly-absent', subject: expect.objectContaining({ type: 'anomalies.laps' }) }),
expect.objectContaining({ property: 'report.engine_version', normalizedValue: '3.4.0' }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Honeypot', subject: expect.objectContaining({ type: 'user-information.honey-pot' }) }),
expect.objectContaining({ label: 'Nb User Accounts', normalizedValue: 12, subject: expect.objectContaining({ type: 'user-information.account-analysis' }) }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic User', subject: expect.objectContaining({ type: 'user-information.password-age-distribution' }) }),
expect.objectContaining({ label: 'Name', originalValue: 'Synthetic Computer', subject: expect.objectContaining({ type: 'computer-information.operating-systems' }) }),
expect.objectContaining({ originalValue: 'Synthetic Admin Group', subject: expect.objectContaining({ type: 'admin-groups.groups' }) }),
expect.objectContaining({ originalValue: 'Synthetic Control Path', subject: expect.objectContaining({ type: 'control-paths-analysis.indirect-links' }) }),
expect.objectContaining({ originalValue: 'Synthetic Infrastructure', subject: expect.objectContaining({ type: 'infrastructure.microsoft-entra-connect-settings' }) }),
expect.objectContaining({ originalValue: 'Synthetic Anomaly', subject: expect.objectContaining({ type: 'anomalies.adminsdholder-detect-temporary-elevated-accounts' }) }),
expect.objectContaining({ originalValue: 'Synthetic Password Policy', subject: expect.objectContaining({ type: 'password-policies.password-policies' }) }),
expect.objectContaining({ originalValue: 'Synthetic GPO', subject: expect.objectContaining({ type: 'gpo.security-settings' }) }),
expect.objectContaining({ originalValue: 'Synthetic unknown value', subject: expect.objectContaining({ type: 'pingcastle-table-row' }) })
]));
const preview = previewEntryFromDataset(dataset, {
@ -55,12 +90,44 @@ describe('PingCastle native finding extraction', () => {
expect(model.reportSections).toHaveLength(dataset.extraction?.reportSections?.length ?? 0);
expect(model.environmentObservations).toHaveLength(dataset.extraction?.environmentObservations?.length ?? 0);
expect(model.importFiles[0].processedWith).toMatchObject({
profileId: 'pingcastle', profileVersion: '1.3.0', mappingVersion: '1.1.0',
profileId: 'pingcastle', profileVersion: '1.4.0', mappingVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1'
});
expect(model.scanRuns[0].tool.version).toBe('3.4.0');
});
it('preserves every visible PingCastle environment subsection as a hierarchical subject type', async () => {
const definitions: Array<[string, string, Array<[string, string]>]> = [
['sectionDomainInformation', 'Domain Information', [['Entra ID Configuration', 'domain-information.entra-id-configuration']]],
['sectionUserInformation', 'User Information', [['Honey Pot', 'user-information.honey-pot'], ['Account analysis', 'user-information.account-analysis'], ['Password Age Distribution', 'user-information.password-age-distribution']]],
['sectionComputerInformation', 'Computer Information', [['Account analysis', 'computer-information.account-analysis'], ['Operating Systems', 'computer-information.operating-systems'], ['Domain controllers', 'computer-information.domain-controllers'], ['LAPS Analysis', 'computer-information.laps-analysis']]],
['sectionAdminGroups', 'Admin Groups', [['Groups', 'admin-groups.groups'], ['Last Logon Distribution', 'admin-groups.last-logon-distribution'], ['Delegations', 'admin-groups.delegations']]],
['sectionControlPathsAnalysis', 'Control Paths Analysis', [['Foreign domain involved', 'control-paths-analysis.foreign-domain-involved'], ['Indirect links', 'control-paths-analysis.indirect-links'], ['Admin groups', 'control-paths-analysis.admin-groups'], ['Critical Infrastructure', 'control-paths-analysis.critical-infrastructure']]],
['sectionTrustsdetails', 'Trusts Details', [['Discovered Domains', 'trusts-details.discovered-domains'], ['Reachable Domains', 'trusts-details.reachable-domains']]],
['sectionPKI', 'PKI', [['Certificates', 'pki.certificates'], ['Certificate Templates', 'pki.certificate-templates'], ['Domain Controller Certificate', 'pki.domain-controller-certificate']]],
['sectionInfrastructure', 'Infrastructure', [['Microsoft Entra Connect settings', 'infrastructure.microsoft-entra-connect-settings'], ['WSUS settings', 'infrastructure.wsus-settings'], ['Exchange settings', 'infrastructure.exchange-settings'], ['SCCM settings', 'infrastructure.sccm-settings'], ['Service Connection Points', 'infrastructure.service-connection-points'], ['Replacement of RC4 by AES in kerberos', 'infrastructure.replacement-of-rc4-by-aes-in-kerberos']]],
['sectionAnomalies', 'Anomalies', [['Backup', 'anomalies.backup'], ['LAPS', 'anomalies.laps'], ['Windows Event Forwarding (WEF)', 'anomalies.windows-event-forwarding-wef'], ['krbtgt (Used for Golden ticket attacks)', 'anomalies.krbtgt-used-for-golden-ticket-attacks'], ['AdminSDHolder (detect temporary elevated accounts)', 'anomalies.adminsdholder-detect-temporary-elevated-accounts'], ['Unix Passwords', 'anomalies.unix-passwords'], ['Java code reference', 'anomalies.java-code-reference'], ['Logon scripts', 'anomalies.logon-scripts'], ['Advanced', 'anomalies.advanced']]],
['sectionPasswordPolicies', 'Password Policies', [['Password policies', 'password-policies.password-policies'], ['Screensaver policies', 'password-policies.screensaver-policies']]],
['sectionGPO', 'GPO', [['Obfuscated Passwords', 'gpo.obfuscated-passwords'], ['Restricted Groups', 'gpo.restricted-groups'], ['Security settings', 'gpo.security-settings'], ['Audit settings', 'gpo.audit-settings'], ['Privileges', 'gpo.privileges'], ['Login', 'gpo.login'], ['GPO Login script', 'gpo.gpo-login-script'], ['GPO Deployed Files', 'gpo.gpo-deployed-files'], ['Folder Options', 'gpo.folder-options'], ['Microsoft Defender ASR (attack surface reduction)', 'gpo.microsoft-defender-asr-attack-surface-reduction'], ['Firewall configuration', 'gpo.firewall-configuration']]]
];
const sections = definitions.map(([sectionKey, sectionLabel, subsections]) =>
`<div id="${sectionKey}"><h1>${sectionLabel}</h1>${subsections.map(([label]) =>
`<h2>${label}</h2><table aria-label="${label} list"><thead><tr><th>Value</th></tr></thead><tbody><tr><td>Synthetic value</td></tr></tbody></table>`
).join('')}</div>`
).join('');
const dataset = await importPingCastle(
pingCastleHtml.replace('</main>', sections + '</main>'),
'ad_hc_subsections.synthetic.html',
'text/html'
);
const subjectTypes = new Set((dataset.extraction?.environmentObservations ?? []).map(observation => observation.subject.type));
definitions.flatMap(([, , subsections]) => subsections).forEach(([, expectedType]) => {
expect(subjectTypes.has(expectedType), expectedType).toBe(true);
});
expect([...subjectTypes].some(type => /rule-details/.test(type))).toBe(false);
});
it('maps the same matched rule identity from HTML and XML while preserving format-native provenance', async () => {
const datasets = await Promise.all([
importPingCastle(pingCastleHtml, 'ad_hc_synthetic.example.html', 'text/html'),

View File

@ -44,7 +44,7 @@ assert.doesNotMatch(indexHtml, /<(?:script|link)\b[^>]+(?:src|href)=["']https?:/
const packageJson = JSON.parse(read('package.json'));
assert.deepEqual(
Object.keys(packageJson.dependencies).sort(),
['jsdom', 'pg', 'react', 'react-dom', 'saxes', 'xlsx'],
['@fontsource-variable/inter', 'jsdom', 'pg', 'react', 'react-dom', 'saxes', 'xlsx'],
'production dependencies changed; review same-origin and local-processing guarantees before accepting new packages'
);

View File

@ -207,7 +207,7 @@ describe('LocalAssessorRepository contract', () => {
}];
data.environmentObservations = [{
version: 1, id: 'observation-run-1', scanRunId: run.id, importFileId: data.importFiles[0].id,
tool: run.tool, subject: { type: 'active-directory-domain', identity: 'synthetic.example' },
tool: run.tool, subject: { type: 'domain-information', identity: 'synthetic.example' },
property: 'domain.functional_level', label: 'Domain functional level', originalValue: '2016', normalizedValue: 2016,
valueType: 'number', state: 'observed', sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' },
mappingVersion: '2.0.0', attributes: {}

View File

@ -261,8 +261,8 @@ describe('Unified result read model', () => {
format: 'HTML',
fileName: 'synthetic-pingcastle.html',
processedWith: {
parserId: 'html-report', parserVersion: '1.0.0', profileId: 'pingcastle', profileVersion: '1.3.0',
mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
parserId: 'html-report', parserVersion: '1.0.0', profileId: 'pingcastle', profileVersion: '1.4.0',
mappingVersion: '1.2.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0',
compatibilityKey: 'pingcastle-canonical-v1.1'
}
};
@ -273,11 +273,11 @@ describe('Unified result read model', () => {
importFiles: [importFile],
environmentObservations: [{
version: 1, id: 'environment-dfl', scanRunId: scanRun.id, importFileId: importFile.id, tool: scanRun.tool,
subject: { type: 'active-directory-domain', identity: 'synthetic.example' },
subject: { type: 'domain-information', identity: 'synthetic.example' },
property: 'domain.functional_level', label: 'Domain functional level',
originalValue: 'Windows Server 2016', normalizedValue: 2016, valueType: 'number', state: 'observed',
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' },
mappingVersion: '1.1.0+context-1.0.0', attributes: { extractionMethod: 'synthetic-fixture' }
mappingVersion: '1.2.0+context-1.0.0', attributes: { extractionMethod: 'synthetic-fixture' }
}]
});
await session.commit();
@ -286,7 +286,7 @@ describe('Unified result read model', () => {
projectId: project.id,
sourceKind: ['environment-observation'],
environmentProperty: ['domain.functional_level'],
environmentSubjectType: ['active-directory-domain'],
environmentSubjectType: ['domain-information'],
environmentState: ['observed'],
search: '2016'
});
@ -296,13 +296,13 @@ describe('Unified result read model', () => {
resultProjectionId: 'environment-observation:environment-dfl',
sourceKind: 'environment-observation',
sourceTool: 'pingcastle',
sourceProfileVersion: '1.3.0',
mappingRuleVersion: '1.1.0+context-1.0.0',
sourceProfileVersion: '1.4.0',
mappingRuleVersion: '1.2.0+context-1.0.0',
processingStatus: 'current',
importArtifactId: importFile.id,
sourceLocator: { objectPath: 'html/sections/domain/table/0' },
environmentProperty: 'domain.functional_level',
environmentSubjectType: 'active-directory-domain',
environmentSubjectType: 'domain-information',
environmentSubjectIdentity: 'synthetic.example',
environmentOriginalValue: 'Windows Server 2016',
environmentNormalizedValue: 2016,
@ -327,11 +327,11 @@ describe('Unified result read model', () => {
expect(facets.facets.find(facet => facet.id === 'environmentState')?.values)
.toContainEqual(expect.objectContaining({ value: 'observed', count: 1 }));
expect(facets.facets.find(facet => facet.id === 'environmentSubjectType')?.values)
.toContainEqual(expect.objectContaining({ value: 'active-directory-domain', count: 1 }));
.toContainEqual(expect.objectContaining({ value: 'domain-information', count: 1 }));
const categories = await repository.listResultCategories({ sourceKind: ['environment-observation'] });
expect(categories).toEqual(expect.arrayContaining([
expect.objectContaining({ namespace: 'environment-subject', categoryCode: 'active-directory-domain' }),
expect.objectContaining({ namespace: 'environment-subject', categoryCode: 'domain-information' }),
expect.objectContaining({ namespace: 'environment-property', categoryCode: 'domain' })
]));
});

View File

@ -1,8 +1,19 @@
import { describe, expect, it } from 'vitest';
import { createRepositoryRuntime } from '../../server/repository-factory';
import { createRepositoryRuntime, repositoryConnectionString } from '../../server/repository-factory';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
describe('repository runtime selection', () => {
it('uses the configured PostgreSQL host and keeps loopback as the local default', () => {
expect(repositoryConnectionString({
ASSESSOR_POSTGRES_HOST: 'postgres',
ASSESSOR_POSTGRES_DB: 'assessor_experimental',
ASSESSOR_POSTGRES_USER: 'assessor_experimental',
ASSESSOR_POSTGRES_PASSWORD: 'synthetic password'
})).toBe('postgresql://assessor_experimental:synthetic%20password@postgres:5432/assessor_experimental');
expect(repositoryConnectionString({ ASSESSOR_POSTGRES_PASSWORD: 'synthetic' }))
.toBe('postgresql://assessor:synthetic@127.0.0.1:5432/assessor');
});
it('uses the explicit in-memory development mode when no database URL is configured', async () => {
const runtime = await createRepositoryRuntime({});
expect(runtime.persistence).toBe('memory');

View File

@ -90,7 +90,7 @@ describe('local ASSESSOR HTTP server', () => {
}],
environmentObservations: [{
version: 1, id: 'observation-1', scanRunId: scanRun.id, importFileId: 'file-1', tool: scanRun.tool,
subject: { type: 'active-directory-domain' }, property: 'domain.functional_level', label: 'Domain functional level',
subject: { type: 'domain-information' }, property: 'domain.functional_level', label: 'Domain functional level',
originalValue: '2016', normalizedValue: 2016, valueType: 'number', state: 'observed',
sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' }, mappingVersion: '2.0.0', attributes: {}
}]

View File

@ -104,4 +104,27 @@ describe('Node import runtime', () => {
severity: 'info'
}));
}, 30_000);
it('routes a 1.6 MB PingCastle-style HTML report through bounded extraction before the worker time limit', async () => {
const html = '<!doctype html><html><head><title>PingCastle Healthcheck</title></head><body>'
+ '<!--' + 'x'.repeat(1_600_000) + '-->'
+ '<table id="rules"><caption>Rules</caption>'
+ '<tr><th>Rule ID</th><th>Title</th><th>Severity</th></tr>'
+ '<tr><td>PC-BOUNDED-1</td><td>Bounded synthetic report</td><td>High</td></tr>'
+ '</table></body></html>';
const bytes = new TextEncoder().encode(html);
const dataset = await importSource({
metadata: { name: 'ad_hc_bounded.synthetic.html', sizeBytes: bytes.length, lastModified: 0 }, bytes
}, { profiles: importProfiles }, nodeImportRuntimeAdapters);
expect(dataset.format).toBe('HTML');
expect(dataset.profileId).toBe('pingcastle');
expect(dataset.sheets.find(sheet => sheet.name === 'Rules')?.rows[1]).toEqual([
'PC-BOUNDED-1', 'Bounded synthetic report', 'High'
]);
expect(dataset.intermediate?.diagnostics).toContainEqual(expect.objectContaining({
code: 'html-bounded-extraction',
severity: 'info'
}));
}, 30_000);
});