Compare commits

...

5 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
17 changed files with 616 additions and 12 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
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

@ -1349,6 +1349,7 @@ 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. |
@ -1609,6 +1610,7 @@ 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. |
@ -1793,6 +1795,7 @@ 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. |
@ -2076,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. |

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

@ -5,3 +5,9 @@
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

@ -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

@ -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');
@ -47,6 +48,7 @@ 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(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(--tile-radius);');

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

@ -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');