diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 19da578..ed5ffe6 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ node_modules/ dist/ .next/ -# Stray tsc output; drizzle-kit reads drizzle.config.ts. -/drizzle.config.js data/*.db data/backups/*.db .codex/*.log +dynamo/output/ diff --git a/AGENTS.md b/AGENTS.md old mode 100755 new mode 100644 index d781e10..f1a509d --- a/AGENTS.md +++ b/AGENTS.md @@ -531,44 +531,13 @@ Users must be able to override sizing suggestions. - Preserve stable UUIDs and explicit transaction boundaries for a later PostgreSQL adapter. -## Deployment Rules - -There are two compose files and they must stay separate. - -- `compose.yaml` is development only. Project name `leistungsbilanz-dev`, ports - bound to `127.0.0.1`, watch-mode servers, source bind mounts. -- `compose.prod.yaml` is the deployment. Project name `leistungsbilanz`, image - target `prod`, compiled output, no bind mounts, no file watchers. - -Rules that a running deployment depends on: - -- Never run the development stack permanently. `next dev` and `tsx watch` - produce load and grow in memory with no user activity. -- Never reintroduce `CHOKIDAR_USEPOLLING` or `WATCHPACK_POLLING`. They make the - watcher poll the source tree continuously. -- Container healthchecks target `/health` only. `/` is a redirect to - `/projects`, and `fetch` follows redirects, so probing `/` renders a full - page on every interval. -- Every service keeps a `mem_limit`; production services also keep a - `memswap_limit` of the same size so a leak kills the container instead of the - host. -- The production image runs as `node`. Data volumes written by an earlier root - container need a one-time `chown` (see `docs/deployment.md`). -- The Express API is not published on the host. It has no authentication and is - reached through the Next.js rewrite. -- Development migrates with `drizzle-kit`; the production image has no - devDependencies and migrates with `node scripts/run-migrations.js`. -- `SIGTERM`/`SIGINT` must close the HTTP server and the SQLite handle and then - exit. Do not reduce these handlers to logging. - ## Current Deferred Work - Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and `docs/spec/revit-csv-integration-requirements.md` is being followed - full electrical sizing - multi-user/PostgreSQL operation -- authentication, authorization and TLS termination; until they exist the - deployment belongs in a trusted network or behind an authenticating proxy +- supported production deployment Do not implement these while working on an unrelated phase. diff --git a/Dockerfile b/Dockerfile old mode 100755 new mode 100644 index 4b4ed55..31d28c4 --- a/Dockerfile +++ b/Dockerfile @@ -1,51 +1,21 @@ -# syntax=docker/dockerfile:1 - -# --------------------------------------------------------------------------- -# deps: complete dependency tree, shared by the build and the dev target. -# Stays on the full node image because better-sqlite3 needs a toolchain. -# --------------------------------------------------------------------------- -FROM node:22 AS deps +FROM node:24 WORKDIR /app + COPY package*.json ./ RUN npm ci -# --------------------------------------------------------------------------- -# dev: watch-mode target used by compose.yaml. Keeps devDependencies so -# tsx, drizzle-kit and next dev are available. Sources come from bind mounts. -# --------------------------------------------------------------------------- -FROM deps AS dev -ENV NODE_ENV=development COPY . . -RUN mkdir -p data -EXPOSE 3000 3001 -CMD ["npm", "run", "dev:api"] -# --------------------------------------------------------------------------- -# build: compile the API to dist/ and the frontend to .next/, then drop -# devDependencies so the runtime stage carries only what it executes. -# --------------------------------------------------------------------------- -FROM deps AS build -ENV NEXT_TELEMETRY_DISABLED=1 -# Next.js resolves next.config.mjs rewrites at build time and writes the literal -# destination into .next/routes-manifest.json. Setting API_INTERNAL_URL only at -# runtime has no effect on `next start`, so the API address of the target -# topology has to be known here. Server components read the variable at runtime. +# next build writes the rewrite destinations from next.config.mjs into +# .next/routes-manifest.json, so "next start" cannot pick up a different +# API URL later. The value has to be known here, not just at runtime. ARG API_INTERNAL_URL=http://localhost:3000 ENV API_INTERNAL_URL=$API_INTERNAL_URL -COPY . . -RUN npm run build:api && npm run build:web -RUN npm prune --omit=dev -# --------------------------------------------------------------------------- -# prod: runtime image. node:22-slim shares the Debian release of the build -# stage, so the compiled better-sqlite3 binding stays loadable. -# --------------------------------------------------------------------------- -FROM node:22-slim AS prod -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 -WORKDIR /app -COPY --from=build --chown=node:node /app ./ -RUN mkdir -p data && chown node:node data -USER node -EXPOSE 3000 3001 -CMD ["node", "dist/server/index.js"] +RUN npm run build:api && npm run build:web + +RUN mkdir -p data && chmod +x scripts/docker-start.sh + +EXPOSE 3001 + +CMD ["sh", "scripts/docker-start.sh"] diff --git a/README.md b/README.md old mode 100755 new mode 100644 index db895e6..fc82fca --- a/README.md +++ b/README.md @@ -4,9 +4,8 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung. Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise, Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. -Das Projekt befindet sich in aktiver Entwicklung. Unterstützt sind der lokale -Entwicklungsbetrieb (`compose.yaml`) und ein Einzelbenutzer-Deployment im -vertrauenswürdigen Netz (`compose.prod.yaml`). Authentifizierung, +Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb +mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht implementiert. @@ -63,27 +62,29 @@ docker compose logs --follow docker compose down ``` -`compose.yaml` startet Entwicklungsserver mit Quellcode-Mounts, veröffentlicht -seine Ports nur auf `127.0.0.1` und ist **kein Produktionsdeployment**. Es -gehört nicht in den Dauerbetrieb: `next dev` und `tsx watch` erzeugen auch ohne -Benutzer dauerhaft Last und wachsen im Speicher. +`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, +ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in +[Deployment und Betrieb](docs/deployment.md). -## Deployment +Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit +Quellcode-Mounts und Watchern: -```bash -docker compose -f compose.prod.yaml up --build --detach +```powershell +docker compose -f compose.dev.yaml up --build --detach +docker compose -f compose.dev.yaml logs --follow +docker compose -f compose.dev.yaml down ``` -Startet die kompilierte API und `next start` in getrennten Containern, mit -Speicherlimits und ohne Dateibeobachter. Das Frontend hört auf Port 3090, die -API ist nur intern erreichbar. Vorbereitung eines bestehenden Datenvolumes und -weitere Details stehen in [Deployment und Betrieb](docs/deployment.md). +Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und +macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn +niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen +Server. ## Direkte lokale Entwicklung Voraussetzungen: -- Node.js 22 +- Node.js 24 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..86e42e2 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,87 @@ +# Development stack: source mounts, watching dev servers, hot reload. +# docker compose -f compose.dev.yaml up --build +# +# The polling watchers below are needed for bind mounts on Windows and +# macOS, where inotify events do not cross the VM boundary. They cost +# continuous CPU, which is why the production stack in compose.yaml does +# not run watchers at all. +name: leistungsbilanz-dev + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" + +services: + api: + build: + context: . + command: + - sh + - -c + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + environment: + PORT: "3000" + CHOKIDAR_USEPOLLING: "true" + LOG_LEVEL: "${LOG_LEVEL:-debug}" + init: true + restart: unless-stopped + logging: *logging + ports: + - "3000:3000" + volumes: + - ./src:/app/src + - ./scripts:/app/scripts + - ./data:/app/data + - ./drizzle.config.ts:/app/drizzle.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + retries: 5 + start_period: 20s + + web: + build: + context: . + command: + - npm + - run + - dev:web + - -- + - --hostname + - 0.0.0.0 + environment: + API_INTERNAL_URL: http://api:3000 + WATCHPACK_POLLING: "true" + NEXT_TELEMETRY_DISABLED: "1" + LOG_LEVEL: "${LOG_LEVEL:-debug}" + init: true + restart: unless-stopped + logging: *logging + depends_on: + api: + condition: service_healthy + ports: + - "3001:3001" + volumes: + - ./src:/app/src + - ./next.config.mjs:/app/next.config.mjs:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./tsconfig.next.json:/app/tsconfig.next.json:ro + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 30s + timeout: 3s + retries: 5 + start_period: 20s diff --git a/compose.prod.yaml b/compose.prod.yaml deleted file mode 100755 index a7a56f3..0000000 --- a/compose.prod.yaml +++ /dev/null @@ -1,102 +0,0 @@ -# Production deployment. -# -# Differences to compose.yaml that matter operationally: -# - runs compiled output (node dist/, next start) instead of watch mode -# - no source bind mounts and no file watchers, so an idle stack costs ~0% CPU -# - healthchecks hit the cheap /health JSON endpoint, not a rendered page -# - every service has a hard memory limit and may not spill into host swap -# - the Express API is reachable only inside the compose network -# -# Usage: -# docker compose -f compose.prod.yaml up --build --detach -name: leistungsbilanz - -x-service-defaults: &service-defaults - init: true - restart: unless-stopped - stop_grace_period: 20s - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" - -services: - api: - <<: *service-defaults - build: - context: . - target: prod - args: - # Baked into the Next.js rewrite manifest; see the build stage in the - # Dockerfile. Must match the API service address in this network. - API_INTERNAL_URL: http://api:3000 - command: - - sh - - -c - - node scripts/run-migrations.js && exec node dist/server/index.js - environment: - NODE_ENV: production - PORT: "3000" - LOG_LEVEL: "${LOG_LEVEL:-info}" - # Not published on the host: the API has no authentication and is reached - # through the Next.js rewrite in the web service. - expose: - - "3000" - mem_limit: 512m - memswap_limit: 512m - volumes: - - leistungsbilanz-data:/app/data - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s - - web: - <<: *service-defaults - build: - context: . - target: prod - args: - # Baked into the Next.js rewrite manifest; see the build stage in the - # Dockerfile. Must match the API service address in this network. - API_INTERNAL_URL: http://api:3000 - command: - - node_modules/.bin/next - - start - - -p - - "3001" - - --hostname - - 0.0.0.0 - environment: - NODE_ENV: production - API_INTERNAL_URL: http://api:3000 - LOG_LEVEL: "${LOG_LEVEL:-info}" - depends_on: - api: - condition: service_healthy - ports: - - "3090:3001" - mem_limit: 1g - memswap_limit: 1g - healthcheck: - # /health is rewritten to the API, so this verifies the web process and - # its API connectivity for the cost of one JSON response. - test: - - CMD - - node - - -e - - fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) - interval: 30s - timeout: 5s - retries: 3 - start_period: 30s - -volumes: - leistungsbilanz-data: diff --git a/compose.yaml b/compose.yaml old mode 100755 new mode 100644 index 5d25db7..503a4b2 --- a/compose.yaml +++ b/compose.yaml @@ -1,94 +1,73 @@ -# Local development only. Runs watch-mode servers against bind-mounted sources. -# For a deployment use compose.prod.yaml. -# -# The project name must stay distinct from the production project so that -# `docker compose down` in this directory can never tear down a running -# production stack. -name: leistungsbilanz-dev +name: leistungsbilanz -x-service-defaults: &service-defaults - init: true - restart: unless-stopped - stop_grace_period: 20s - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" +x-build: &build + context: . + args: + # Baked into .next/routes-manifest.json by next build; see Dockerfile. + API_INTERNAL_URL: http://api:3000 + +x-logging: &logging + driver: json-file + options: + max-size: "20m" + max-file: "10" services: api: - <<: *service-defaults - build: - context: . - target: dev + build: *build command: - sh - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js environment: + NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - # Development ports stay on the loopback interface; nothing here is - # authenticated and the API must not be reachable from the LAN. + init: true + restart: unless-stopped + logging: *logging ports: - - "127.0.0.1:3000:3000" - mem_limit: 1g + - "3000:3000" volumes: - - ./src:/app/src - - ./scripts:/app/scripts - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) + - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) interval: 30s - timeout: 5s + timeout: 3s retries: 5 - start_period: 30s + start_period: 20s web: - <<: *service-defaults - build: - context: . - target: dev + build: *build command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 + - node_modules/.bin/next + - start + - -p + - "3001" environment: + NODE_ENV: production API_INTERNAL_URL: http://api:3000 NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" + init: true + restart: unless-stopped + logging: *logging depends_on: api: condition: service_healthy ports: - - "127.0.0.1:3001:3001" - # next dev grows steadily under long-running use; the limit turns that into - # a container restart instead of host-wide swapping. - mem_limit: 2g - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro + - "3001:3001" healthcheck: - # Must stay on /health: "/" redirects to /projects and Node's fetch - # follows redirects, which turned every probe into a full page render. test: - CMD - node - -e - - fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) + - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) interval: 30s - timeout: 5s + timeout: 3s retries: 5 - start_period: 30s + start_period: 20s diff --git a/docs/deployment.md b/docs/deployment.md old mode 100755 new mode 100644 index 355cf40..06289a7 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,40 +2,29 @@ ## Aktueller Status -Es gibt zwei getrennte Compose-Dateien. Sie dürfen nicht verwechselt werden. +Es gibt zwei Compose-Stacks. -| Datei | Projektname | Zweck | -| --- | --- | --- | -| `compose.yaml` | `leistungsbilanz-dev` | ausschließlich lokale Entwicklung | -| `compose.prod.yaml` | `leistungsbilanz` | Dauerbetrieb im LAN | +`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und +`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack +für einen Server. -`compose.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom Host -ein. Dieser Stack ist **nicht** für Dauerbetrieb geeignet (siehe -[Warum kein Dev-Stack im Dauerbetrieb](#warum-kein-dev-stack-im-dauerbetrieb)). -Seine Ports sind deshalb an `127.0.0.1` gebunden. +`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom +Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows +und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch +ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen +Entwicklungsrechner. -`compose.prod.yaml` startet kompilierten Code ohne Dateibeobachter. Es enthält -weiterhin weder TLS, Authentifizierung noch ein Benutzer-/Rollenmodell; die -Express-API wird deshalb nicht auf dem Host veröffentlicht, sondern nur über den -Next.js-Rewrite erreicht. Der Stack gehört hinter einen Reverse Proxy mit -Authentifizierung und darf nicht öffentlich erreichbar gemacht werden. +Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch +ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich +erreichbar gemacht werden. ## Topologie -Entwicklung (`compose.yaml`): - -| Komponente | Port | Healthcheck | Speicherlimit | Persistenz | -| --- | ---: | --- | ---: | --- | -| Next.js Web | `127.0.0.1:3001` | `GET /health` alle 30 s | 2 GB | keine | -| Express API | `127.0.0.1:3000` | `GET /health` alle 30 s | 1 GB | `./data:/app/data` | - -Produktion (`compose.prod.yaml`): - -| Komponente | Port | Healthcheck | Speicherlimit | Persistenz | -| --- | ---: | --- | ---: | --- | -| Next.js Web | `3090` | `GET /health` alle 30 s | 1 GB, kein Swap | keine | -| Express API | nur intern | `GET /health` alle 30 s | 512 MB, kein Swap | Volume `leistungsbilanz-data` | -| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | – | `data/leistungsbilanz.db` | +| Komponente | Port | Healthcheck | Persistenz | +| --- | ---: | --- | --- | +| Next.js Web | 3001 | `GET /web-health` | keine | +| Express API | 3000 | `GET /health` | `./data:/app/data` | +| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | Verwendete Umgebungsvariablen: @@ -43,66 +32,22 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `NODE_ENV=production` – nur in `compose.prod.yaml` +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in + `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen in der Entwicklung zuerst `npm run db:migrate` und -`npm run db:verify:circuit-schema` (drizzle-kit, eine devDependency). Das -Produktionsimage enthält keine devDependencies und migriert stattdessen über -`node scripts/run-migrations.js`, das denselben Migrationsordner mit -`drizzle-orm` anwendet. +Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung +(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im +Entwicklungsstack über `npm run db:migrate` und +`npm run db:verify:circuit-schema`). -## Warum kein Dev-Stack im Dauerbetrieb - -`compose.yaml` lief einmal fünf Tage durchgehend auf einem Server. Ergebnis: -10,3 GB belegter Arbeitsspeicher im Web-Container, 12,2 % Dauer-CPU im -API-Container und ein Host, der 13 GB Swap belegt hatte. Vier Ursachen wirkten -zusammen; alle vier sind inzwischen behoben: - -1. **Polling-Dateibeobachter.** `CHOKIDAR_USEPOLLING` und `WATCHPACK_POLLING` - ließen `tsx watch` permanent den Quellbaum abklappern. Beide Variablen sind - entfernt; unter Linux funktioniert `inotify` auf Bind-Mounts. -2. **Healthcheck als Seitenrendering.** Der Web-Healthcheck rief `/` alle fünf - Sekunden auf. `/` ist ein `redirect("/projects")`, und Node's `fetch` folgt - Redirects – jede Prüfung rendert also die vollständige Projektliste, rund - 16.000-mal pro Tag. Der Healthcheck zeigt jetzt auf `/health` und läuft alle - 30 Sekunden. -3. **Kein Speicherlimit.** `next dev` hält Kompilierungs-State und wächst unter - Dauerlast unbegrenzt. Beide Compose-Dateien setzen jetzt `mem_limit`; in der - Produktion zusätzlich `memswap_limit` in gleicher Höhe, damit ein Leck den - Container beendet statt den Host in den Swap zu ziehen. -4. **Kollidierender Projektname.** `compose.yaml` hieß `leistungsbilanz` und - damit genauso wie das Produktionsprojekt; ein `docker compose down` im - Entwicklungsverzeichnis zielte auf den Produktionsstack. Der Entwicklungsname - ist jetzt `leistungsbilanz-dev`. - -## Produktionsdeployment - -```bash -docker compose -f compose.prod.yaml up --build --detach -docker compose -f compose.prod.yaml ps -docker compose -f compose.prod.yaml logs --follow -``` - -Das Frontend hört danach auf Port 3090. Die API ist nur innerhalb des -Compose-Netzes erreichbar. - -Das Produktionsimage läuft als Benutzer `node` statt als `root`. Wenn ein -bestehendes Datenvolume von einem früheren root-Container beschrieben wurde, -müssen dessen Dateien einmalig übereignet werden, sonst schlagen Schreibzugriffe -mit `SQLITE_READONLY` fehl: - -```bash -docker run --rm -v leistungsbilanz_leistungsbilanz-data:/data alpine \ - chown -R 1000:1000 /data -``` - -Ein bestehendes Ein-Container-Deployment über `scripts/docker-start.sh` wird -abgelöst, indem dessen Stack gestoppt und `compose.prod.yaml` mit demselben -Projektnamen `leistungsbilanz` gestartet wird; das Volume bleibt dabei erhalten. +`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build` +schreibt die Rewrite-Ziele aus `next.config.mjs` fest in +`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als +Build-Argument an das Image durch, nicht nur als Laufzeit-Variable. ## Logging @@ -138,40 +83,23 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit `restart: unless-stopped`, damit Docker sie danach automatisch neu startet; ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen. -Anfragen von Healthcheck- und Monitoring-Clients (`node`, `curl`, `wget`, …) -werden anhand des User-Agents aus dem Navigationslog gefiltert und `/health` -läuft gar nicht erst durch `src/proxy.ts`. +## Voraussetzungen für ein späteres Produktionssetup -## Herunterfahren - -`SIGTERM` und `SIGINT` schließen den HTTP-Server, danach das SQLite-Handle, und -beenden den Prozess mit Code 0. Kommt der Server binnen 15 Sekunden nicht -herunter, beendet sich der Prozess trotzdem. Vorher wurde das Signal nur -geloggt: jeder `docker stop` lief in die Grace Period und endete mit `SIGKILL`, -möglicherweise mitten in einem Schreibvorgang. `stop_grace_period` steht in -beiden Compose-Dateien auf 20 s und liegt damit über dem internen Timeout. - -## Offene Punkte für einen vollwertigen Produktionsbetrieb - -Umgesetzt: - -- reproduzierbares Produktionsimage und Next.js-Produktionsstart -- kontrollierter Migrationsschritt vor dem API-Start -- strukturierte Logs mit Rotation, Healthchecks, Speicherlimits -- definiertes Herunterfahren - -Weiterhin offen: +Vor einer produktiven Installation werden mindestens benötigt: +- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript - TLS-Termination und Reverse Proxy - Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung -- Alarmierung auf Basis der Healthchecks +- persistenter, gesicherter Datenbankbetrieb +- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout +- Monitoring, strukturierte Logs und Alarmierung - getestete Backup-, Restore- und Rollback-Prozeduren - Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für Mehrbenutzerbetrieb erforderlich ist -Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein -vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy. +Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem +lokalen Entwicklungsstart in der README. ## Backup und Wiederherstellung diff --git a/drizzle.config.js b/drizzle.config.js new file mode 100644 index 0000000..004045a --- /dev/null +++ b/drizzle.config.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const drizzle_kit_1 = require("drizzle-kit"); +exports.default = (0, drizzle_kit_1.defineConfig)({ + dialect: "sqlite", + schema: "./src/db/schema/*.ts", + out: "./src/db/migrations", + dbCredentials: { + url: "./data/leistungsbilanz.db", + }, +}); diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py new file mode 100644 index 0000000..a3fe009 --- /dev/null +++ b/dynamo/01_check_model_identity.py @@ -0,0 +1,385 @@ +"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node. + +Optional Dynamo input: + IN[0]: output directory or complete .json file path + +The script intentionally performs no Revit transaction and changes no model +data. OUT contains a compact summary plus the complete report. +""" + +import datetime +import json +import os +import platform +import sys +import tempfile +import traceback + +import clr + +clr.AddReference("RevitAPI") +clr.AddReference("RevitServices") + +from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402 +from RevitServices.Persistence import DocumentManager # noqa: E402 + + +CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId") + + +def safe_text(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def element_id_text(element_id): + if element_id is None: + return None + try: + return str(element_id.Value) + except Exception: + try: + return str(element_id.IntegerValue) + except Exception: + return safe_text(element_id) + + +def forge_type_id_text(value): + if value is None: + return None + try: + return value.TypeId + except Exception: + return safe_text(value) + + +def parameter_value(parameter): + result = { + "hasValue": False, + "raw": None, + "display": None, + } + try: + result["hasValue"] = bool(parameter.HasValue) + except Exception: + pass + + try: + storage_type = parameter.StorageType + if storage_type == StorageType.String: + result["raw"] = parameter.AsString() + elif storage_type == StorageType.Integer: + result["raw"] = int(parameter.AsInteger()) + elif storage_type == StorageType.Double: + result["raw"] = float(parameter.AsDouble()) + elif storage_type == StorageType.ElementId: + result["raw"] = element_id_text(parameter.AsElementId()) + except Exception as error: + result["readError"] = safe_text(error) + + try: + result["display"] = parameter.AsValueString() + except Exception: + pass + return result + + +def describe_parameter(parameter): + definition = None + try: + definition = parameter.Definition + except Exception: + pass + + name = None + if definition is not None: + try: + name = definition.Name + except Exception: + pass + + is_shared = False + try: + is_shared = bool(parameter.IsShared) + except Exception: + pass + + shared_guid = None + if is_shared: + try: + shared_guid = str(parameter.GUID) + except Exception: + pass + + data_type = None + group_type = None + if definition is not None: + try: + data_type = forge_type_id_text(definition.GetDataType()) + except Exception: + pass + try: + group_type = forge_type_id_text(definition.GetGroupTypeId()) + except Exception: + pass + + unit_type = None + try: + unit_type = forge_type_id_text(parameter.GetUnitTypeId()) + except Exception: + pass + + try: + storage_type = str(parameter.StorageType) + except Exception: + storage_type = None + + try: + is_read_only = bool(parameter.IsReadOnly) + except Exception: + is_read_only = None + + try: + user_modifiable = bool(parameter.UserModifiable) + except Exception: + user_modifiable = None + + return { + "name": name, + "parameterId": element_id_text(getattr(parameter, "Id", None)), + "isShared": is_shared, + "sharedGuid": shared_guid, + "storageType": storage_type, + "dataTypeId": data_type, + "groupTypeId": group_type, + "unitTypeId": unit_type, + "isReadOnly": is_read_only, + "userModifiable": user_modifiable, + "value": parameter_value(parameter), + } + + +def sorted_parameters(element): + parameters = [] + try: + parameters = [describe_parameter(parameter) for parameter in element.Parameters] + except Exception: + return [] + return sorted( + parameters, + key=lambda parameter: ( + (parameter.get("name") or "").casefold(), + parameter.get("parameterId") or "", + ), + ) + + +def named_parameter_occurrences(element, parameter_name): + result = [] + try: + parameters = element.GetParameters(parameter_name) + if parameters is not None: + result = [describe_parameter(parameter) for parameter in parameters] + except Exception: + parameter = None + try: + parameter = element.LookupParameter(parameter_name) + except Exception: + pass + if parameter is not None: + result = [describe_parameter(parameter)] + return result + + +def loaded_assembly_versions(): + result = {} + try: + from System import AppDomain + + for assembly in AppDomain.CurrentDomain.GetAssemblies(): + try: + name = assembly.GetName() + simple_name = str(name.Name) + if simple_name in ( + "DynamoCore", + "DynamoCoreWpf", + "DynamoRevitDS", + "RevitAPI", + "RevitServices", + ): + result[simple_name] = str(name.Version) + except Exception: + continue + except Exception: + pass + return dict(sorted(result.items())) + + +def get_cloud_identity(document): + result = {"isModelInCloud": False} + try: + result["isModelInCloud"] = bool(document.IsModelInCloud) + except Exception: + return result + if not result["isModelInCloud"]: + return result + + try: + model_path = document.GetCloudModelPath() + result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( + model_path + ) + for property_name, output_name in ( + ("GetProjectGUID", "projectGuid"), + ("GetModelGUID", "modelGuid"), + ): + try: + result[output_name] = str(getattr(model_path, property_name)()) + except Exception: + pass + except Exception as error: + result["readError"] = safe_text(error) + return result + + +def get_worksharing_identity(document): + result = {"isWorkshared": False} + try: + result["isWorkshared"] = bool(document.IsWorkshared) + except Exception: + return result + if not result["isWorkshared"]: + return result + + try: + model_path = document.GetWorksharingCentralModelPath() + result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( + model_path + ) + except Exception as error: + result["readError"] = safe_text(error) + return result + + +def resolve_output_path(configured_path, report_name): + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") + raw_path = safe_text(configured_path) + if raw_path is None or not raw_path.strip(): + directory = default_directory + file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") + else: + expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) + if expanded.lower().endswith(".json"): + file_path = expanded + directory = os.path.dirname(file_path) + else: + directory = expanded + file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") + if not directory: + directory = os.getcwd() + if not os.path.isdir(directory): + os.makedirs(directory) + return file_path + + +def write_json(file_path, payload): + temporary_path = file_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary_path, file_path) + + +def get_input(index, default=None): + values = globals().get("IN", []) + try: + value = values[index] + return default if value is None else value + except Exception: + return default + + +def build_report(): + document = DocumentManager.Instance.CurrentDBDocument + if document is None: + raise RuntimeError("No active Revit document is available.") + + project_information = document.ProjectInformation + if project_information is None: + raise RuntimeError("The active document has no Project Information element.") + + application = document.Application + checked_parameters = { + name: named_parameter_occurrences(project_information, name) + for name in CHECKED_PARAMETER_NAMES + } + warnings = [] + for name in CHECKED_PARAMETER_NAMES: + occurrences = checked_parameters[name] + populated = [ + parameter + for parameter in occurrences + if parameter.get("value", {}).get("raw") not in (None, "") + ] + if not occurrences: + warnings.append(name + " is not bound to Project Information.") + elif not populated: + warnings.append(name + " exists but has no value on Project Information.") + elif len(occurrences) > 1: + warnings.append(name + " occurs more than once; use a shared-parameter GUID later.") + + return { + "reportSchemaVersion": 1, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "readOnly": True, + "environment": { + "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), + "revitVersionName": safe_text(getattr(application, "VersionName", None)), + "revitSubVersionNumber": safe_text( + getattr(application, "SubVersionNumber", None) + ), + "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), + "pythonVersion": platform.python_version(), + "assemblies": loaded_assembly_versions(), + }, + "document": { + "title": safe_text(document.Title), + "pathName": safe_text(document.PathName), + "isFamilyDocument": bool(document.IsFamilyDocument), + "cloud": get_cloud_identity(document), + "worksharing": get_worksharing_identity(document), + }, + "modelIdentityCandidates": { + "projectInformationUniqueId": safe_text(project_information.UniqueId), + "projectInformationElementId": element_id_text(project_information.Id), + "checkedProjectParameters": checked_parameters, + }, + "projectInformationParameters": sorted_parameters(project_information), + "warnings": warnings, + } + + +try: + report = build_report() + output_path = resolve_output_path(get_input(0), "model-identity") + write_json(output_path, report) + OUT = { + "ok": True, + "filePath": output_path, + "projectInformationUniqueId": report["modelIdentityCandidates"][ + "projectInformationUniqueId" + ], + "warnings": report["warnings"], + "report": report, + } +except Exception as error: + OUT = { + "ok": False, + "error": safe_text(error), + "traceback": traceback.format_exc(), + } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py new file mode 100644 index 0000000..440a222 --- /dev/null +++ b/dynamo/02_export_electrical_fixture_parameter_inventory.py @@ -0,0 +1,517 @@ +"""Export all Electrical Fixtures instance/type parameters from Revit 2026. + +Optional Dynamo inputs: + IN[0]: output directory or complete .json file path + IN[1]: include empty parameters (default True) + IN[2]: maximum aggregated sample values (default 5) + +The script is read-only and performs no Revit transaction. +""" + +import datetime +import json +import os +import platform +import sys +import tempfile +import traceback + +import clr + +clr.AddReference("RevitAPI") +clr.AddReference("RevitServices") + +from Autodesk.Revit.DB import ( # noqa: E402 + BuiltInCategory, + FilteredElementCollector, + ModelPathUtils, + StorageType, +) +from RevitServices.Persistence import DocumentManager # noqa: E402 + + +def safe_text(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def element_id_text(element_id): + if element_id is None: + return None + try: + return str(element_id.Value) + except Exception: + try: + return str(element_id.IntegerValue) + except Exception: + return safe_text(element_id) + + +def forge_type_id_text(value): + if value is None: + return None + try: + return value.TypeId + except Exception: + return safe_text(value) + + +def parameter_value(parameter): + result = {"hasValue": False, "raw": None, "display": None} + try: + result["hasValue"] = bool(parameter.HasValue) + except Exception: + pass + + try: + storage_type = parameter.StorageType + if storage_type == StorageType.String: + result["raw"] = parameter.AsString() + elif storage_type == StorageType.Integer: + result["raw"] = int(parameter.AsInteger()) + elif storage_type == StorageType.Double: + result["raw"] = float(parameter.AsDouble()) + elif storage_type == StorageType.ElementId: + result["raw"] = element_id_text(parameter.AsElementId()) + except Exception as error: + result["readError"] = safe_text(error) + + try: + result["display"] = parameter.AsValueString() + except Exception: + pass + return result + + +def describe_parameter(parameter, scope): + definition = None + try: + definition = parameter.Definition + except Exception: + pass + + name = None + data_type = None + group_type = None + if definition is not None: + try: + name = definition.Name + except Exception: + pass + try: + data_type = forge_type_id_text(definition.GetDataType()) + except Exception: + pass + try: + group_type = forge_type_id_text(definition.GetGroupTypeId()) + except Exception: + pass + + is_shared = False + try: + is_shared = bool(parameter.IsShared) + except Exception: + pass + + shared_guid = None + if is_shared: + try: + shared_guid = str(parameter.GUID) + except Exception: + pass + + unit_type = None + try: + unit_type = forge_type_id_text(parameter.GetUnitTypeId()) + except Exception: + pass + + try: + storage_type = str(parameter.StorageType) + except Exception: + storage_type = None + + try: + is_read_only = bool(parameter.IsReadOnly) + except Exception: + is_read_only = None + + try: + user_modifiable = bool(parameter.UserModifiable) + except Exception: + user_modifiable = None + + return { + "scope": scope, + "name": name, + "parameterId": element_id_text(getattr(parameter, "Id", None)), + "isShared": is_shared, + "sharedGuid": shared_guid, + "storageType": storage_type, + "dataTypeId": data_type, + "groupTypeId": group_type, + "unitTypeId": unit_type, + "isReadOnly": is_read_only, + "userModifiable": user_modifiable, + "value": parameter_value(parameter), + } + + +def has_meaningful_value(parameter_description): + value = parameter_description.get("value", {}) + return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get( + "display" + ) not in (None, "") + + +def read_parameters(element, scope, include_empty): + result = [] + try: + for parameter in element.Parameters: + description = describe_parameter(parameter, scope) + if include_empty or has_meaningful_value(description): + result.append(description) + except Exception as error: + return [], [safe_text(error)] + result.sort( + key=lambda parameter: ( + (parameter.get("name") or "").casefold(), + parameter.get("parameterId") or "", + ) + ) + return result, [] + + +def read_space(element, document): + try: + space = element.Space + except Exception as error: + return None, safe_text(error) + if space is None: + return None, None + + level_name = None + try: + level = document.GetElement(space.LevelId) + level_name = None if level is None else safe_text(level.Name) + except Exception: + pass + + return { + "uniqueId": safe_text(space.UniqueId), + "elementId": element_id_text(space.Id), + "number": safe_text(getattr(space, "Number", None)), + "name": safe_text(getattr(space, "Name", None)), + "levelName": level_name, + }, None + + +def read_family_identity(element, document): + symbol = None + try: + symbol = element.Symbol + except Exception: + try: + symbol = document.GetElement(element.GetTypeId()) + except Exception: + pass + + family_name = None + type_name = None + type_unique_id = None + type_element_id = None + if symbol is not None: + try: + family_name = safe_text(symbol.Family.Name) + except Exception: + family_name = safe_text(getattr(symbol, "FamilyName", None)) + type_name = safe_text(getattr(symbol, "Name", None)) + type_unique_id = safe_text(getattr(symbol, "UniqueId", None)) + type_element_id = element_id_text(getattr(symbol, "Id", None)) + + return { + "familyName": family_name, + "typeName": type_name, + "typeUniqueId": type_unique_id, + "typeElementId": type_element_id, + }, symbol + + +def parameter_inventory_key(parameter): + stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or "" + return "|".join( + ( + parameter.get("scope") or "", + stable_id, + parameter.get("name") or "", + parameter.get("dataTypeId") or "", + ) + ) + + +def sample_value_key(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def add_to_inventory(inventory, parameter, max_samples): + key = parameter_inventory_key(parameter) + entry = inventory.get(key) + if entry is None: + entry = { + "scope": parameter.get("scope"), + "name": parameter.get("name"), + "parameterId": parameter.get("parameterId"), + "isShared": parameter.get("isShared"), + "sharedGuid": parameter.get("sharedGuid"), + "storageType": parameter.get("storageType"), + "dataTypeId": parameter.get("dataTypeId"), + "groupTypeId": parameter.get("groupTypeId"), + "unitTypeId": parameter.get("unitTypeId"), + "occurrenceCount": 0, + "populatedCount": 0, + "sampleValues": [], + "_sampleKeys": set(), + } + inventory[key] = entry + entry["occurrenceCount"] += 1 + if has_meaningful_value(parameter): + entry["populatedCount"] += 1 + value = parameter.get("value") + value_key = sample_value_key(value) + if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]: + entry["_sampleKeys"].add(value_key) + entry["sampleValues"].append(value) + + +def finalize_inventory(inventory): + result = [] + for entry in inventory.values(): + clean_entry = dict(entry) + clean_entry.pop("_sampleKeys", None) + result.append(clean_entry) + return sorted( + result, + key=lambda entry: ( + entry.get("scope") or "", + (entry.get("name") or "").casefold(), + entry.get("sharedGuid") or entry.get("parameterId") or "", + ), + ) + + +def loaded_assembly_versions(): + result = {} + try: + from System import AppDomain + + for assembly in AppDomain.CurrentDomain.GetAssemblies(): + try: + name = assembly.GetName() + simple_name = str(name.Name) + if simple_name in ( + "DynamoCore", + "DynamoCoreWpf", + "DynamoRevitDS", + "RevitAPI", + "RevitServices", + ): + result[simple_name] = str(name.Version) + except Exception: + continue + except Exception: + pass + return dict(sorted(result.items())) + + +def resolve_output_path(configured_path): + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") + raw_path = safe_text(configured_path) + if raw_path is None or not raw_path.strip(): + directory = default_directory + file_path = os.path.join( + directory, "electrical-fixture-parameters-" + timestamp + ".json" + ) + else: + expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) + if expanded.lower().endswith(".json"): + file_path = expanded + directory = os.path.dirname(file_path) + else: + directory = expanded + file_path = os.path.join( + directory, "electrical-fixture-parameters-" + timestamp + ".json" + ) + if not directory: + directory = os.getcwd() + if not os.path.isdir(directory): + os.makedirs(directory) + return file_path + + +def write_json(file_path, payload): + temporary_path = file_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary_path, file_path) + + +def get_input(index, default=None): + values = globals().get("IN", []) + try: + value = values[index] + return default if value is None else value + except Exception: + return default + + +def build_report(include_empty, max_samples): + document = DocumentManager.Instance.CurrentDBDocument + if document is None: + raise RuntimeError("No active Revit document is available.") + if document.IsFamilyDocument: + raise RuntimeError("Open a Revit project document, not a family document.") + + application = document.Application + collector = ( + FilteredElementCollector(document) + .OfCategory(BuiltInCategory.OST_ElectricalFixtures) + .WhereElementIsNotElementType() + ) + source_elements = list(collector) + source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "") + + elements = [] + types_by_unique_id = {} + inventory = {} + errors = [] + elements_without_space = 0 + + for element in source_elements: + element_errors = [] + try: + family_identity, symbol = read_family_identity(element, document) + instance_parameters, parameter_errors = read_parameters( + element, "instance", include_empty + ) + element_errors.extend(parameter_errors) + for parameter in instance_parameters: + add_to_inventory(inventory, parameter, max_samples) + + type_unique_id = family_identity.get("typeUniqueId") + if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id: + type_parameters, type_errors = read_parameters(symbol, "type", include_empty) + element_errors.extend(type_errors) + for parameter in type_parameters: + add_to_inventory(inventory, parameter, max_samples) + types_by_unique_id[type_unique_id] = { + **family_identity, + "parameters": type_parameters, + } + + space, space_error = read_space(element, document) + if space_error: + element_errors.append("MEP Space: " + space_error) + if space is None: + elements_without_space += 1 + + elements.append( + { + "uniqueId": safe_text(element.UniqueId), + "elementId": element_id_text(element.Id), + "categoryName": safe_text( + None if element.Category is None else element.Category.Name + ), + "family": family_identity, + "space": space, + "instanceParameters": instance_parameters, + "warnings": element_errors, + } + ) + except Exception as error: + errors.append( + { + "uniqueId": safe_text(getattr(element, "UniqueId", None)), + "elementId": element_id_text(getattr(element, "Id", None)), + "error": safe_text(error), + } + ) + + return { + "reportSchemaVersion": 1, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "readOnly": True, + "complete": len(errors) == 0, + "scope": { + "builtInCategory": "OST_ElectricalFixtures", + "wholeDocument": True, + "elementTypesExcluded": True, + "includeEmptyParameters": include_empty, + }, + "environment": { + "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), + "revitVersionName": safe_text(getattr(application, "VersionName", None)), + "revitSubVersionNumber": safe_text( + getattr(application, "SubVersionNumber", None) + ), + "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), + "pythonVersion": platform.python_version(), + "assemblies": loaded_assembly_versions(), + }, + "document": { + "title": safe_text(document.Title), + "pathName": safe_text(document.PathName), + "projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId), + }, + "summary": { + "elementCount": len(source_elements), + "exportedElementCount": len(elements), + "typeCount": len(types_by_unique_id), + "parameterDefinitionCount": len(inventory), + "elementsWithoutMepSpace": elements_without_space, + "elementErrorCount": len(errors), + }, + "parameterInventory": finalize_inventory(inventory), + "types": sorted( + types_by_unique_id.values(), + key=lambda entry: ( + (entry.get("familyName") or "").casefold(), + (entry.get("typeName") or "").casefold(), + entry.get("typeUniqueId") or "", + ), + ), + "elements": elements, + "errors": errors, + } + + +try: + include_empty_input = get_input(1, True) + include_empty = bool(include_empty_input) + try: + max_samples = int(get_input(2, 5)) + except Exception: + max_samples = 5 + max_samples = max(0, min(max_samples, 50)) + + report = build_report(include_empty, max_samples) + output_path = resolve_output_path(get_input(0)) + write_json(output_path, report) + OUT = { + "ok": True, + "filePath": output_path, + "complete": report["complete"], + "summary": report["summary"], + "errors": report["errors"], + } +except Exception as error: + OUT = { + "ok": False, + "error": safe_text(error), + "traceback": traceback.format_exc(), + } diff --git a/dynamo/README.md b/dynamo/README.md new file mode 100644 index 0000000..800eded --- /dev/null +++ b/dynamo/README.md @@ -0,0 +1,69 @@ +# Revit 2026 / Dynamo diagnostics + +This directory contains self-contained Python scripts for a Dynamo **Python +Script** node. They use only Dynamo's built-in Revit integration, the Revit API +and the Python standard library. No Dynamo package is required. + +The scripts are read-only. They do not start a Revit transaction and do not +change the open model. + +## Python engine + +Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with +Revit; optional PythonNet3 packages are not required by these diagnostics. + +## 01 - Check model identity + +File: `01_check_model_identity.py` + +The script reports: + +- Revit, Dynamo and Python versions; +- `ProjectInformation.UniqueId` as a native model-identity candidate; +- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project + Information; +- all Project Information parameters; +- optional cloud/worksharing identity information when the API exposes it. + +Input `IN[0]` is optional. It may be either an output directory or a complete +`.json` file path. With no input, the report is written below the current +Windows temporary directory in `leistungsbilanz-dynamo`. + +## 02 - Inventory Electrical Fixtures parameters + +File: `02_export_electrical_fixture_parameter_inventory.py` + +The script reads every instance of +`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It +exports: + +- element, family, type and MEP Space identities; +- every instance parameter and value; +- every unique family-type parameter and value; +- an aggregated parameter inventory with occurrence counts and sample values; +- per-element warnings instead of aborting at the first unreadable element. + +Inputs: + +- `IN[0]` (optional): output directory or complete `.json` path; +- `IN[1]` (optional): include empty parameters, default `true`; +- `IN[2]` (optional): maximum sample values per aggregated parameter, default + `5`. + +The default output location is again the Windows temporary directory. The +generated report can contain model paths and project-specific parameter values; +review it before sharing or committing it. + +## Running a script + +1. Open the target model in Revit 2026. +2. Open Dynamo from **Manage > Visual Programming > Dynamo**. +3. Create a graph and add a **Python Script** node. +4. Select the `CPython3` engine for the node. +5. Copy the complete content of the desired `.py` file into the node. +6. Optionally connect a String node containing the output path to `IN[0]`. +7. Run the graph and inspect `OUT` for status, counts and the generated path. + +For the first test, run `01_check_model_identity.py` in the Revit main model. +Then run the parameter inventory. Keep both generated JSON files so their +structure can be checked before the production snapshot DTO is finalized. diff --git a/index.html b/index.html new file mode 100644 index 0000000..dcb632f --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + Leistungsbilanz API + + + +
+

Leistungsbilanz Backend

+

Der aktuelle Stand laeuft als TypeScript Node/SQLite API.

+

Serverstart: npm run dev

+

Healthcheck: GET /health

+
+ + diff --git a/package-lock.json b/package-lock.json index b0abb41..417421b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" + }, + "engines": { + "node": "24.x" } }, "node_modules/@drizzle-team/brocli": { @@ -1514,12 +1517,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -3688,10 +3692,11 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 9bc5ae3..414eb53 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "Spreadsheet-style circuit list editor for electrical distribution planning", "main": "dist/server/index.js", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "npm run dev:api", "dev:api": "tsx watch src/server/index.ts", @@ -10,6 +13,9 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", + "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", + "docker:dev:down": "docker compose -f compose.dev.yaml down", + "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", @@ -40,7 +46,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh old mode 100755 new mode 100644 index 85a27a6..bf0d856 --- a/scripts/docker-start.sh +++ b/scripts/docker-start.sh @@ -1,7 +1,4 @@ #!/bin/sh -# Legacy single-container entrypoint. Kept for the existing deployment that -# still runs both processes in one container; new deployments use -# compose.prod.yaml, which runs the API and the web server separately. set -e echo "Running migrations..." @@ -9,34 +6,11 @@ node scripts/run-migrations.js echo "Starting API server on :3000..." node dist/server/index.js & -api_pid=$! echo "Waiting for API..." -until node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" 2>/dev/null; do - if ! kill -0 "$api_pid" 2>/dev/null; then - echo "API process exited during startup." - exit 1 - fi +until node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; do sleep 1 done echo "Starting Next.js on :3001..." -node_modules/.bin/next start -p 3001 & -web_pid=$! - -terminate() { - kill -TERM "$api_pid" "$web_pid" 2>/dev/null || true -} -trap terminate TERM INT - -# Previously the API ran unsupervised in the background: when it died the -# container stayed "up" and served a frontend whose every request failed. -# Exiting here lets the restart policy replace the container instead. -while kill -0 "$api_pid" 2>/dev/null && kill -0 "$web_pid" 2>/dev/null; do - sleep 5 -done - -echo "A server process exited; stopping the container." -terminate -wait "$api_pid" "$web_pid" 2>/dev/null || true -exit 1 +exec node_modules/.bin/next start -p 3001 diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts new file mode 100644 index 0000000..ce6f2c4 --- /dev/null +++ b/src/app/web-health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +// Liveness probe for the web container itself. The "/health" path is +// rewritten to the API in next.config.mjs, so it cannot answer for this +// process. Kept as a route handler so a probe does not render a page. +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/db/client.ts b/src/db/client.ts old mode 100755 new mode 100644 index 39f4125..02cfc7e --- a/src/db/client.ts +++ b/src/db/client.ts @@ -7,11 +7,3 @@ const defaultDatabaseContext = createDatabaseContext( export const db = defaultDatabaseContext.db; -/** - * Closes the process-wide SQLite handle. Only the composition root calls this, - * during shutdown, so pending writes are flushed before the process exits. - */ -export function closeDefaultDatabase(): void { - defaultDatabaseContext.close(); -} - diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index da17458..eb722d7 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { + const values: typeof assignment.target & { + manualQuantity?: number; + } = { ...assignment.target }; + if (assignment.target.quantity !== assignment.expected.quantity) { + const externalTotal = tx + .select({ planningValues: externalModelObjects.planningValues }) + .from(externalModelObjects) + .where( + eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) + ) + .all() + .reduce( + (sum, object) => sum + object.planningValues.effectiveQuantity, + 0 + ); + const manualQuantity = assignment.target.quantity - externalTotal; + if (manualQuantity < 0) { + throw new Error( + "Synchronized quantity is below the total quantity of linked external objects." + ); + } + values.manualQuantity = manualQuantity; + } const updated = tx .update(circuitDeviceRows) - .set(assignment.target) + .set(values) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/src/proxy.ts b/src/proxy.ts old mode 100755 new mode 100644 index a2cb084..3f4b83a --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,13 +4,11 @@ import { createLogger } from "./shared/logging/logger"; const logger = createLogger("web:navigation"); -// Node's fetch() sends "User-Agent: node", so the previous "no User-Agent" -// check never matched and every healthcheck was logged as page navigation. -const PROBE_USER_AGENT = /^(node|curl|wget|go-http-client|kube-probe)\b/i; - export function proxy(request: NextRequest) { - const userAgent = request.headers.get("user-agent") ?? ""; - if (userAgent && !PROBE_USER_AGENT.test(userAgent)) { + // Probes hit "/web-health" and are excluded by the matcher below. The + // User-Agent guard stays as a fallback for anything else that polls + // without one, so real navigation isn't drowned out in the logs. + if (request.headers.get("user-agent")) { logger.info("page request", { method: request.method, path: request.nextUrl.pathname, @@ -20,5 +18,7 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ["/((?!_next/static|_next/image|favicon.ico|api|health).*)"], + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|api|web-health).*)", + ], }; diff --git a/src/server/index.ts b/src/server/index.ts old mode 100755 new mode 100644 index 3ee21f1..2f6ac5e --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,6 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectRouter } from "./routes/project.routes.js"; import { errorMiddleware } from "./middleware/error.middleware.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; -import { closeDefaultDatabase } from "../db/client.js"; const logger = createLogger("api"); const app = express(); @@ -63,35 +62,8 @@ process.on("unhandledRejection", (reason) => { process.exit(1); }); -let shuttingDown = false; - -function shutdown(signal: NodeJS.Signals): void { - if (shuttingDown) return; - shuttingDown = true; - logger.info("shutting down", { signal }); - - // Without this the process only logged the signal and kept running, so every - // stop waited for Docker's grace period and ended in SIGKILL mid-write. - const forceExit = setTimeout(() => { - logger.warn("shutdown timed out, exiting anyway"); - process.exit(1); - }, 15_000); - forceExit.unref(); - - server.close((error) => { - if (error) logger.error("http server close failed", toErrorMeta(error)); - try { - closeDefaultDatabase(); - } catch (closeError) { - logger.error("database close failed", toErrorMeta(closeError)); - } - logger.info("shutdown complete"); - process.exit(error ? 1 : 0); - }); -} - -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); +process.on("SIGTERM", () => logger.info("received SIGTERM")); +process.on("SIGINT", () => logger.info("received SIGINT")); setInterval(() => { const memory = process.memoryUsage(); @@ -102,6 +74,6 @@ setInterval(() => { }); }, heartbeatIntervalMs).unref(); -const server = app.listen(port, () => { +app.listen(port, () => { logger.info("server started", { port }); }); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..58c8f79 --- /dev/null +++ b/styles.css @@ -0,0 +1,15 @@ +body { + margin: 0; + font-family: Arial, sans-serif; + background: #f5f6f8; + color: #1f2937; +} + +.info { + max-width: 760px; + margin: 40px auto; + background: #ffffff; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 24px; +} diff --git a/tests/project-device-row-sync-project-command.repository.test.ts b/tests/project-device-row-sync-project-command.repository.test.ts index 75d56a8..593a7f6 100644 --- a/tests/project-device-row-sync-project-command.repository.test.ts +++ b/tests/project-device-row-sync-project-command.repository.test.ts @@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; +import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; +import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; +import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; import { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, @@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) { return row; } +function linkExternalObject( + context: DatabaseContext, + rowId: string, + effectiveQuantity: number +) { + context.db + .insert(externalModelSources) + .values({ + id: "source-1", + projectId: "project-1", + name: "Revit", + sourceType: "revit_csv", + }) + .run(); + context.db + .insert(externalImportBatches) + .values({ + id: "batch-1", + projectId: "project-1", + sourceId: "source-1", + importKind: "initial", + importedAtIso: "2026-08-02T16:00:00.000Z", + fileName: "revit.csv", + sha256: "a".repeat(64), + appliedProjectRevision: 0, + configurationVersion: 1, + configurationSnapshot: externalCsvTestConfiguration, + originalBytes: Buffer.from("test"), + document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, + }) + .run(); + context.db + .insert(externalModelObjects) + .values({ + id: "object-1", + projectId: "project-1", + sourceId: "source-1", + ifcGuid: "ifc-1", + lastSeenImportBatchId: "batch-1", + lastAcceptedImportBatchId: "batch-1", + acceptedSourceValues: { + rowNumber: 2, + roomNumber: "101", + roomName: "Büro", + familyAndType: "Leuchte: Standard", + selectionMarker: "Leuchte", + circuitIdentifier: "-1F1", + power: "30", + quantity: String(effectiveQuantity), + additionalSourceValues: {}, + }, + planningValues: { + displayName: "Leuchte", + internalDeviceType: "luminaire", + category: "single_phase", + connectionKind: "fixed", + effectiveQuantity, + powerPerUnitW: 30, + simultaneityFactor: 1, + cosPhi: null, + costGroup: null, + remark: null, + }, + overriddenFields: [], + externalRoomMappingId: null, + distributionBoardId: null, + linkedProjectDeviceId: null, + circuitDeviceRowId: rowId, + presenceStatus: "present", + }) + .run(); +} + function snapshot( context: DatabaseContext, rowId: string @@ -474,6 +551,112 @@ describe("project-device row sync project-command repository", () => { } }); + it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 5 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [ + { + rowId: "row-1", + expected, + target: { ...expected, quantity: 2 }, + }, + ] + ), + }); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 2); + assert.equal(row.manualQuantity, 2); + } finally { + fixture.context.close(); + } + }); + + it("subtracts linked external objects when a synced quantity shrinks", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 2 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + linkExternalObject(fixture.context, "row-1", 3); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }] + ), + }); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 4); + assert.equal(row.manualQuantity, 1); + } finally { + fixture.context.close(); + } + }); + + it("rejects a synced quantity below the linked external total", () => { + const fixture = createTestDatabase(); + try { + fixture.context.db + .update(circuitDeviceRows) + .set({ quantity: 5, manualQuantity: 2 }) + .where(eq(circuitDeviceRows.id, "row-1")) + .run(); + linkExternalObject(fixture.context, "row-1", 3); + const store = new ProjectDeviceRowSyncProjectCommandRepository( + fixture.context.db + ); + const expected = snapshot(fixture.context, "row-1"); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createProjectDeviceRowSyncProjectCommand( + "project-device-1", + "synchronize", + [{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }] + ), + }), + /below the total quantity of linked external objects/ + ); + const row = getRow(fixture.context, "row-1"); + assert.equal(row.quantity, 5); + assert.equal(row.manualQuantity, 2); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + it("rolls back synchronized rows for a stale project revision", () => { const fixture = createTestDatabase(); try {