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