Leistungsbilanz Backend
-Der aktuelle Stand laeuft als TypeScript Node/SQLite API.
-Serverstart: npm run dev
Healthcheck: GET /health
diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index ed5ffe6..19da578 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ 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 100644 new mode 100755 index f1a509d..d781e10 --- a/AGENTS.md +++ b/AGENTS.md @@ -531,13 +531,44 @@ 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 -- supported production deployment +- authentication, authorization and TLS termination; until they exist the + deployment belongs in a trusted network or behind an authenticating proxy Do not implement these while working on an unrelated phase. diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 31d28c4..4b4ed55 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,51 @@ -FROM node:24 -WORKDIR /app +# 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 +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"] -# 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. +# --------------------------------------------------------------------------- +# 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. 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 -RUN mkdir -p data && chmod +x scripts/docker-start.sh - -EXPOSE 3001 - -CMD ["sh", "scripts/docker-start.sh"] +# --------------------------------------------------------------------------- +# 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"] diff --git a/README.md b/README.md old mode 100644 new mode 100755 index fc82fca..db895e6 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ 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. Der lokale Entwicklungsbetrieb -mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, +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, Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht implementiert. @@ -62,29 +63,27 @@ docker compose logs --follow docker compose down ``` -`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). +`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. -Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit -Quellcode-Mounts und Watchern: +## Deployment -```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 +```bash +docker compose -f compose.prod.yaml up --build --detach ``` -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. +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). ## Direkte lokale Entwicklung Voraussetzungen: -- Node.js 24 +- Node.js 22 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml deleted file mode 100644 index 86e42e2..0000000 --- a/compose.dev.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# 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 new file mode 100755 index 0000000..a7a56f3 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,102 @@ +# 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 100644 new mode 100755 index 503a4b2..5d25db7 --- a/compose.yaml +++ b/compose.yaml @@ -1,73 +1,94 @@ -name: leistungsbilanz +# 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 -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" +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: - build: *build + <<: *service-defaults + build: + context: . + target: dev command: - sh - -c - - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api environment: - NODE_ENV: production PORT: "3000" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: *logging + # Development ports stay on the loopback interface; nothing here is + # authenticated and the API must not be reachable from the LAN. ports: - - "3000:3000" + - "127.0.0.1:3000:3000" + mem_limit: 1g 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)) + - fetch('http://127.0.0.1:3000/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) interval: 30s - timeout: 3s + timeout: 5s retries: 5 - start_period: 20s + start_period: 30s web: - build: *build + <<: *service-defaults + build: + context: . + target: dev command: - - node_modules/.bin/next - - start - - -p - - "3001" + - npm + - run + - dev:web + - -- + - --hostname + - 0.0.0.0 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: - - "3001:3001" + - "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 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://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + - fetch('http://127.0.0.1:3001/health').then(response=>process.exit(response.ok?0:1)).catch(()=>process.exit(1)) interval: 30s - timeout: 3s + timeout: 5s retries: 5 - start_period: 20s + start_period: 30s diff --git a/docs/deployment.md b/docs/deployment.md old mode 100644 new mode 100755 index 06289a7..355cf40 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,29 +2,40 @@ ## Aktueller Status -Es gibt zwei Compose-Stacks. +Es gibt zwei getrennte Compose-Dateien. Sie dürfen nicht verwechselt werden. -`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. +| Datei | Projektname | Zweck | +| --- | --- | --- | +| `compose.yaml` | `leistungsbilanz-dev` | ausschließlich lokale Entwicklung | +| `compose.prod.yaml` | `leistungsbilanz` | Dauerbetrieb im LAN | -`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.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. -Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch -ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich -erreichbar gemacht werden. +`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. ## Topologie -| 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` | +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` | Verwendete Umgebungsvariablen: @@ -32,22 +43,66 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in - `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg +- `NODE_ENV=production` – nur in `compose.prod.yaml` - `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 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`). +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. -`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. +## 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. ## Logging @@ -83,23 +138,40 @@ 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. -## Voraussetzungen für ein späteres Produktionssetup +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`. -Vor einer produktiven Installation werden mindestens benötigt: +## 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: -- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript - TLS-Termination und Reverse Proxy - Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung -- persistenter, gesicherter Datenbankbetrieb -- kontrollierter einmaliger Migrationsschritt vor dem API-Rollout -- Monitoring, strukturierte Logs und Alarmierung +- Alarmierung auf Basis der Healthchecks - getestete Backup-, Restore- und Rollback-Prozeduren - Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für Mehrbenutzerbetrieb erforderlich ist -Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem -lokalen Entwicklungsstart in der README. +Solange Authentifizierung fehlt, gehört der Stack ausschließlich in ein +vertrauenswürdiges Netz oder hinter einen authentifizierenden Reverse Proxy. ## Backup und Wiederherstellung diff --git a/drizzle.config.js b/drizzle.config.js deleted file mode 100644 index 004045a..0000000 --- a/drizzle.config.js +++ /dev/null @@ -1,11 +0,0 @@ -"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 deleted file mode 100644 index a3fe009..0000000 --- a/dynamo/01_check_model_identity.py +++ /dev/null @@ -1,385 +0,0 @@ -"""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 deleted file mode 100644 index 440a222..0000000 --- a/dynamo/02_export_electrical_fixture_parameter_inventory.py +++ /dev/null @@ -1,517 +0,0 @@ -"""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 deleted file mode 100644 index 800eded..0000000 --- a/dynamo/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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 deleted file mode 100644 index dcb632f..0000000 --- a/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - -
- - -Der aktuelle Stand laeuft als TypeScript Node/SQLite API.
-Serverstart: npm run dev
Healthcheck: GET /health