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 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 @@ + + +
+ + +Der aktuelle Stand laeuft als TypeScript Node/SQLite API.
+Serverstart: npm run dev
Healthcheck: GET /health