forked from jappel/leistungsbilanz-ts
Compare commits
6 commits
main
...
feature/ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5150d4d166 | ||
| 2e3e85a8e5 | |||
| 204499d7e4 | |||
| 906aa751c7 | |||
| c6cdfc42d5 | |||
| 9504905c8f |
26 changed files with 1955 additions and 352 deletions
|
|
@ -6,3 +6,6 @@ data
|
||||||
*.db
|
*.db
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image.
|
||||||
|
.codex
|
||||||
|
.agents
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,6 +1,9 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
|
# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts.
|
||||||
|
/drizzle.config.js
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
|
dynamo/output/
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,13 @@ Keyboard behavior:
|
||||||
|
|
||||||
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
||||||
|
|
||||||
|
Editor actions use `IconButton` (`src/frontend/components/icon-button.tsx`,
|
||||||
|
`lucide-react` icons). The German action text is always the accessible name and
|
||||||
|
tooltip; the toolbar shows icon plus text, group headers and rows show icons
|
||||||
|
only, separated into logical blocks with `IconButtonSeparator`. Destructive
|
||||||
|
actions use `variant="danger"` (trash icon for circuits and groups, `ListX` for
|
||||||
|
removing a device row).
|
||||||
|
|
||||||
## Bootstrap / Styling Rule
|
## Bootstrap / Styling Rule
|
||||||
|
|
||||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
||||||
|
|
|
||||||
56
Dockerfile
56
Dockerfile
|
|
@ -1,14 +1,56 @@
|
||||||
FROM node:22
|
# syntax=docker/dockerfile:1
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deps: vollstaendiger Abhaengigkeitsbaum, gemeinsam genutzt von "dev" und
|
||||||
|
# "build". Bleibt auf dem vollen node-Image, weil better-sqlite3 beim
|
||||||
|
# Installieren eine Toolchain braucht.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:24 AS deps
|
||||||
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# dev: Ziel fuer compose.dev.yaml. Behaelt devDependencies, damit tsx,
|
||||||
|
# drizzle-kit und "next dev" verfuegbar sind. Die Quellen kommen zur Laufzeit
|
||||||
|
# ueber Bind-Mounts, das COPY hier ist nur der Ausgangsstand.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM deps AS dev
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p data
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
CMD ["npm", "run", "dev:api"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build: API nach dist/ und Frontend nach .next/ uebersetzen, danach die
|
||||||
|
# devDependencies entfernen, damit das Laufzeit-Image nur noch traegt, was es
|
||||||
|
# auch ausfuehrt. Die Migrationsskripte kommen mit drizzle-orm und
|
||||||
|
# better-sqlite3 aus, beides sind regulaere dependencies.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM deps AS build
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# next build schreibt die Rewrite-Ziele aus next.config.mjs fest in
|
||||||
|
# .next/routes-manifest.json, "next start" kann eine andere API-Adresse
|
||||||
|
# spaeter nicht mehr uebernehmen. Der Wert muss hier bekannt sein, nicht erst
|
||||||
|
# zur Laufzeit. Server-Komponenten lesen die Variable zur Laufzeit.
|
||||||
|
ARG API_INTERNAL_URL=http://localhost:3000
|
||||||
|
ENV API_INTERNAL_URL=$API_INTERNAL_URL
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build:api && npm run build:web
|
RUN npm run build:api && npm run build:web
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
# ---------------------------------------------------------------------------
|
||||||
|
# prod: Laufzeit-Image. node:24-slim teilt sich das Debian-Release mit der
|
||||||
EXPOSE 3001
|
# build-Stufe, damit das kompilierte better-sqlite3-Binding ladbar bleibt.
|
||||||
|
# Laeuft als "node" (UID 1000) statt als root.
|
||||||
CMD ["sh", "scripts/docker-start.sh"]
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM node:24-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"]
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -50,27 +50,50 @@ docker compose up --build --detach
|
||||||
Danach:
|
Danach:
|
||||||
|
|
||||||
- Frontend: <http://localhost:3001>
|
- Frontend: <http://localhost:3001>
|
||||||
- API-Healthcheck: <http://localhost:3000/health>
|
- API-Healthcheck: <http://localhost:3001/health> (über den Next.js-Rewrite)
|
||||||
|
|
||||||
|
Die Express-API ist nicht mehr auf dem Host veröffentlicht. Sie hat keine
|
||||||
|
Authentifizierung und ist nur innerhalb des Compose-Netzes erreichbar; der
|
||||||
|
Zugriff läuft über den Rewrite im Web-Dienst.
|
||||||
|
|
||||||
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
Der API-Container führt ausstehende Migrationen und die Schemaprüfung beim Start
|
||||||
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
automatisch aus. Die SQLite-Datei liegt auf dem Host unter
|
||||||
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
|
`data/leistungsbilanz.db` und bleibt beim Stoppen erhalten.
|
||||||
|
|
||||||
|
Das Laufzeit-Image läuft als Benutzer `node` (UID 1000), nicht als root. Ein
|
||||||
|
`data/`-Verzeichnis, das ein früherer root-Container angelegt hat, braucht
|
||||||
|
deshalb einmalig `sudo chown -R 1000:1000 data` auf dem Host.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs --follow
|
docker compose logs --follow
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
||||||
Produktionsdeployment. Details stehen in
|
ohne Quellcode-Mounts und ohne Datei-Watcher, mit Speichergrenzen und ohne
|
||||||
|
root-Rechte im Container. Details stehen in
|
||||||
[Deployment und Betrieb](docs/deployment.md).
|
[Deployment und Betrieb](docs/deployment.md).
|
||||||
|
|
||||||
|
Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit
|
||||||
|
Quellcode-Mounts und Watchern:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Direkte lokale Entwicklung
|
||||||
|
|
||||||
Voraussetzungen:
|
Voraussetzungen:
|
||||||
|
|
||||||
- Node.js 22
|
- Node.js 24
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
101
compose.dev.yaml
Normal file
101
compose.dev.yaml
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Entwicklungs-Stack: Quellcode-Mounts, laufende Dev-Server, Hot Reload.
|
||||||
|
# docker compose -f compose.dev.yaml up --build
|
||||||
|
#
|
||||||
|
# Die Polling-Watcher unten sind fuer Bind-Mounts unter Windows und macOS
|
||||||
|
# noetig, weil inotify-Events die VM-Grenze nicht passieren. Sie kosten
|
||||||
|
# dauerhaft CPU; genau deshalb laufen im Server-Stack (compose.yaml) gar
|
||||||
|
# keine Watcher.
|
||||||
|
#
|
||||||
|
# Der Projektname muss sich von dem des Server-Stacks unterscheiden, damit
|
||||||
|
# "docker compose down" in diesem Verzeichnis niemals einen laufenden
|
||||||
|
# Server-Stack abraeumen kann.
|
||||||
|
name: leistungsbilanz-dev
|
||||||
|
|
||||||
|
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: dev
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- npm run db:migrate && npm run db:verify:circuit-schema && exec npm run dev:api
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
||||||
|
# Nur auf dem Loopback: nichts davon ist authentifiziert, die API darf
|
||||||
|
# nicht aus dem LAN erreichbar sein.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
# Kein memswap_limit wie im Server-Stack: beim Entwickeln ist Swap als
|
||||||
|
# Puffer erwuenscht, die Grenze soll nur den Host vor Dauerlast schuetzen.
|
||||||
|
mem_limit: 1g
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./scripts:/app/scripts
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://127.0.0.1:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
web:
|
||||||
|
<<: *service-defaults
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: dev
|
||||||
|
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}"
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
# "next dev" haelt Kompilierzustand und waechst im Dauerbetrieb stetig;
|
||||||
|
# die Grenze macht daraus einen Container-Neustart statt Host-Swapping.
|
||||||
|
mem_limit: 2g
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./next.config.mjs:/app/next.config.mjs:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- node
|
||||||
|
- -e
|
||||||
|
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
|
interval: 30s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
106
compose.yaml
106
compose.yaml
|
|
@ -1,82 +1,104 @@
|
||||||
|
# Server-Stack: gebauter Stand, keine Quellcode-Mounts, keine Datei-Watcher.
|
||||||
|
# docker compose up --build --detach
|
||||||
|
#
|
||||||
|
# Fuer die Entwicklung mit Hot Reload compose.dev.yaml verwenden.
|
||||||
name: leistungsbilanz
|
name: leistungsbilanz
|
||||||
|
|
||||||
services:
|
x-build: &build
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: .
|
context: .
|
||||||
command:
|
target: prod
|
||||||
- sh
|
args:
|
||||||
- -c
|
# Wird von next build in .next/routes-manifest.json eingebacken, siehe
|
||||||
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
# Dockerfile. Muss der API-Adresse in diesem Netz entsprechen.
|
||||||
environment:
|
API_INTERNAL_URL: http://api:3000
|
||||||
PORT: "3000"
|
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
x-service-defaults: &service-defaults
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# Der API-Prozess beendet HTTP-Server und SQLite-Handle auf SIGTERM; die
|
||||||
|
# Frist gibt laufenden Requests Zeit, statt sie nach 10s in ein SIGKILL
|
||||||
|
# laufen zu lassen.
|
||||||
|
stop_grace_period: 20s
|
||||||
|
# Das Image laeuft als "node" (UID 1000); zusaetzliche Rechte braucht
|
||||||
|
# weder der API- noch der Web-Prozess.
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options:
|
options:
|
||||||
max-size: "20m"
|
max-size: "20m"
|
||||||
max-file: "10"
|
max-file: "10"
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
services:
|
||||||
|
api:
|
||||||
|
<<: *service-defaults
|
||||||
|
build: *build
|
||||||
|
# "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und
|
||||||
|
# SIGTERM erreicht node nie.
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "3000"
|
||||||
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
|
# Nicht auf dem Host veroeffentlicht: die API hat keine Authentifizierung
|
||||||
|
# und wird ueber den Next.js-Rewrite im Web-Dienst erreicht.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
# Harte Obergrenze statt unbegrenztem Wachstum. memswap_limit == mem_limit
|
||||||
|
# schaltet Swap fuer den Container ab, damit ein Leck den Container trifft
|
||||||
|
# und nicht den Host.
|
||||||
|
mem_limit: 512m
|
||||||
|
memswap_limit: 512m
|
||||||
volumes:
|
volumes:
|
||||||
- ./src:/app/src
|
|
||||||
- ./scripts:/app/scripts
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
|
||||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://127.0.0.1:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
<<: *service-defaults
|
||||||
context: .
|
build: *build
|
||||||
command:
|
command:
|
||||||
- npm
|
- node_modules/.bin/next
|
||||||
- run
|
- start
|
||||||
- dev:web
|
- -p
|
||||||
- --
|
- "3001"
|
||||||
- --hostname
|
- --hostname
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
environment:
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
WATCHPACK_POLLING: "true"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
volumes:
|
mem_limit: 1g
|
||||||
- ./src:/app/src
|
memswap_limit: 1g
|
||||||
- ./next.config.mjs:/app/next.config.mjs:ro
|
|
||||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
|
||||||
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
# /web-health statt /health: next.config.mjs leitet /health an die API
|
||||||
|
# weiter, und "/" wuerde wegen des Redirects auf /projects bei jeder
|
||||||
|
# Probe zwei Seiten rendern.
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,57 @@
|
||||||
|
|
||||||
## Aktueller Status
|
## Aktueller Status
|
||||||
|
|
||||||
Es gibt derzeit kein unterstütztes Produktionsdeployment.
|
Es gibt zwei Compose-Stacks.
|
||||||
|
|
||||||
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
|
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
||||||
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
||||||
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
für einen Server. Beide Dienste laufen aus der `prod`-Stufe des Dockerfiles,
|
||||||
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
also als Benutzer `node` statt als root, ohne devDependencies, mit
|
||||||
öffentlich erreichbar gemacht werden.
|
`cap_drop: ALL`, `no-new-privileges` und harten Speichergrenzen.
|
||||||
|
|
||||||
## Entwicklungs-Topologie
|
`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.
|
||||||
|
|
||||||
| Komponente | Port | Healthcheck | Persistenz |
|
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
||||||
| --- | ---: | --- | --- |
|
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
||||||
| Next.js Web | 3001 | `GET /` | keine |
|
erreichbar gemacht werden.
|
||||||
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
|
||||||
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
## Topologie
|
||||||
|
|
||||||
|
| Komponente | Port | Auf dem Host | Healthcheck | Persistenz |
|
||||||
|
| --- | ---: | --- | --- | --- |
|
||||||
|
| Next.js Web | 3001 | `3001` (Server) / `127.0.0.1:3001` (Dev) | `GET /web-health` | keine |
|
||||||
|
| Express API | 3000 | nicht veröffentlicht (Server) / `127.0.0.1:3000` (Dev) | `GET /health` | `./data:/app/data` |
|
||||||
|
| SQLite | Datei | – | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||||
|
|
||||||
|
Die Express-API wird im Server-Stack nicht mehr auf dem Host veröffentlicht
|
||||||
|
(`expose` statt `ports`). Sie hat keine Authentifizierung; jeder, der sie
|
||||||
|
erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
|
||||||
|
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
|
||||||
|
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
|
||||||
|
|
||||||
|
### Speichergrenzen
|
||||||
|
|
||||||
|
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
||||||
|
`memswap_limit` auf denselben Wert gesetzt, womit Swap für den Container
|
||||||
|
abgeschaltet ist: ein Speicherleck lässt dann den Container am OOM-Killer
|
||||||
|
sterben und `restart: unless-stopped` greift, statt dass der Host in den Swap
|
||||||
|
läuft. Aktuell 512 MB für die API und 1 GB für das Frontend. Der
|
||||||
|
Entwicklungs-Stack bekommt 1 GB bzw. 2 GB und behält Swap, weil `next dev`
|
||||||
|
Kompilierzustand hält und im Dauerbetrieb stetig wächst.
|
||||||
|
|
||||||
|
### Rechte im Container
|
||||||
|
|
||||||
|
Das Laufzeit-Image läuft als `node` (UID 1000). Ein `data/`-Verzeichnis, das
|
||||||
|
ein früherer root-Container angelegt hat, ist dann nicht beschreibbar und
|
||||||
|
braucht einmalig auf dem Host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R 1000:1000 data
|
||||||
|
```
|
||||||
|
|
||||||
Verwendete Umgebungsvariablen:
|
Verwendete Umgebungsvariablen:
|
||||||
|
|
||||||
|
|
@ -24,15 +60,22 @@ Verwendete Umgebungsvariablen:
|
||||||
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
||||||
`http://api:3000`
|
`http://api:3000`
|
||||||
- `NEXT_TELEMETRY_DISABLED=1`
|
- `NEXT_TELEMETRY_DISABLED=1`
|
||||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
|
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in
|
||||||
Dateibeobachtung in Docker
|
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
|
||||||
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
||||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
`LOG_LEVEL=verbose docker compose up`.
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
|
|
||||||
Beim API-Start laufen zuerst `npm run db:migrate` und
|
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
||||||
`npm run db:verify:circuit-schema`.
|
(`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`).
|
||||||
|
|
||||||
|
`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
|
## Logging
|
||||||
|
|
||||||
|
|
@ -68,11 +111,25 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit
|
||||||
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
|
`restart: unless-stopped`, damit Docker sie danach automatisch neu startet;
|
||||||
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
|
ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen.
|
||||||
|
|
||||||
|
## Herunterfahren
|
||||||
|
|
||||||
|
`SIGTERM` und `SIGINT` schließen in der API den HTTP-Server und anschließend
|
||||||
|
das SQLite-Handle und beenden den Prozess. Offene Keep-Alive-Verbindungen –
|
||||||
|
vor allem die des Next.js-Servers – werden dabei über
|
||||||
|
`server.closeIdleConnections()` freigegeben, laufende Requests dürfen zu Ende
|
||||||
|
laufen. Vorher wurde das Signal nur geloggt, der Prozess lief weiter und
|
||||||
|
Docker beendete ihn nach zehn Sekunden mit `SIGKILL`, mitten in einem
|
||||||
|
möglichen Schreibvorgang. Greift der Abbau nicht innerhalb von 15 Sekunden,
|
||||||
|
beendet sich der Prozess selbst mit Exit-Code 1; `stop_grace_period: 20s` in
|
||||||
|
beiden Compose-Dateien liegt bewusst darüber.
|
||||||
|
|
||||||
|
Die Startbefehle in `compose.yaml` enden mit `exec node …`. Ohne das `exec`
|
||||||
|
bleibt `sh` der Vordergrundprozess, und `SIGTERM` erreicht Node nie.
|
||||||
|
|
||||||
## Voraussetzungen für ein späteres Produktionssetup
|
## Voraussetzungen für ein späteres Produktionssetup
|
||||||
|
|
||||||
Vor einer produktiven Installation werden mindestens benötigt:
|
Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
|
|
||||||
- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript
|
|
||||||
- TLS-Termination und Reverse Proxy
|
- TLS-Termination und Reverse Proxy
|
||||||
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
- Authentifizierung, Autorisierung und Benutzer-/Rollenmodell
|
||||||
- definierte Secrets- und Konfigurationsverwaltung
|
- definierte Secrets- und Konfigurationsverwaltung
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
"use strict";
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
const drizzle_kit_1 = require("drizzle-kit");
|
|
||||||
exports.default = (0, drizzle_kit_1.defineConfig)({
|
|
||||||
dialect: "sqlite",
|
|
||||||
schema: "./src/db/schema/*.ts",
|
|
||||||
out: "./src/db/migrations",
|
|
||||||
dbCredentials: {
|
|
||||||
url: "./data/leistungsbilanz.db",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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
17
index.html
|
|
@ -1,17 +0,0 @@
|
||||||
<!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": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
|
|
@ -1514,12 +1517,13 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.0",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
|
|
@ -3688,10 +3692,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"devOptional": true
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
|
"engines": {
|
||||||
|
"node": "24.x"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:api",
|
"dev": "npm run dev:api",
|
||||||
"dev:api": "tsx watch src/server/index.ts",
|
"dev:api": "tsx watch src/server/index.ts",
|
||||||
|
|
@ -10,6 +13,9 @@
|
||||||
"docker:up": "docker compose up --build --detach",
|
"docker:up": "docker compose up --build --detach",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs --follow",
|
"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": "npm run build:api",
|
||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
|
|
@ -40,7 +46,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Running migrations..."
|
|
||||||
node scripts/run-migrations.js
|
|
||||||
|
|
||||||
echo "Starting API server on :3000..."
|
|
||||||
node dist/server/index.js &
|
|
||||||
|
|
||||||
echo "Waiting for API..."
|
|
||||||
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..."
|
|
||||||
exec node_modules/.bin/next start -p 3001
|
|
||||||
|
|
@ -487,6 +487,54 @@ a.kpi:hover {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Icon buttons (circuit list editor toolbar, group and row actions) */
|
||||||
|
.icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s ease, border-color 0.12s ease, background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:not(:disabled):hover {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-danger:not(:disabled):hover {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar .project-device-drawer-toggle:not(:disabled):hover {
|
||||||
|
color: #fff;
|
||||||
|
filter: brightness(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button-separator {
|
||||||
|
align-self: stretch;
|
||||||
|
width: 1px;
|
||||||
|
margin: 0.1rem 0.15rem;
|
||||||
|
background: var(--panel-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-toolbar .project-device-drawer-toggle {
|
.editor-toolbar .project-device-drawer-toggle {
|
||||||
border-color: var(--accent-blue);
|
border-color: var(--accent-blue);
|
||||||
background: var(--accent-blue);
|
background: var(--accent-blue);
|
||||||
|
|
@ -1153,7 +1201,20 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .section-actions {
|
.tree-grid .section-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-actions .icon-button,
|
||||||
|
.tree-grid .action-cell .icon-button {
|
||||||
|
min-width: 1.75rem;
|
||||||
|
min-height: 1.6rem;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-actions .icon-button:disabled,
|
||||||
|
.tree-grid .action-cell .icon-button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .section-actions button {
|
.tree-grid .section-actions button {
|
||||||
|
|
@ -1265,7 +1326,7 @@ a.kpi:hover {
|
||||||
|
|
||||||
.tree-grid .action-cell {
|
.tree-grid .action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .action-cell button {
|
.tree-grid .action-cell button {
|
||||||
|
|
|
||||||
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 });
|
||||||
|
}
|
||||||
|
|
@ -7,3 +7,10 @@ const defaultDatabaseContext = createDatabaseContext(
|
||||||
|
|
||||||
export const db = defaultDatabaseContext.db;
|
export const db = defaultDatabaseContext.db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schliesst das SQLite-Handle des Prozesses. Nur beim Herunterfahren
|
||||||
|
* aufrufen: danach schlaegt jede Abfrage ueber {@link db} fehl.
|
||||||
|
*/
|
||||||
|
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 { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const assignment of input.command.payload.rows) {
|
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
|
const updated = tx
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set(assignment.target)
|
.set(values)
|
||||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowDown01,
|
||||||
|
ArrowUp,
|
||||||
|
Check,
|
||||||
|
Columns3,
|
||||||
|
FilterX,
|
||||||
|
FolderPlus,
|
||||||
|
ListOrdered,
|
||||||
|
ListPlus,
|
||||||
|
ListX,
|
||||||
|
PackagePlus,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Redo2,
|
||||||
|
ShieldPlus,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||||
import {
|
import {
|
||||||
inferProjectDeviceSectionKey,
|
inferProjectDeviceSectionKey,
|
||||||
isProjectDevicePlacementValid,
|
isProjectDevicePlacementValid,
|
||||||
|
|
@ -2995,30 +3017,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
return (
|
return (
|
||||||
<div className="tree-editor-shell">
|
<div className="tree-editor-shell">
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndo()}
|
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
>
|
icon={Undo2}
|
||||||
Rückgängig
|
label="Rückgängig"
|
||||||
</button>
|
onClick={() => void handleUndo()}
|
||||||
<button
|
showLabel
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleRedo()}
|
<IconButton
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
>
|
icon={Redo2}
|
||||||
Wiederholen
|
label="Wiederholen"
|
||||||
</button>
|
onClick={() => void handleRedo()}
|
||||||
<button
|
showLabel
|
||||||
type="button"
|
/>
|
||||||
onClick={clearSortAndFilters}
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
|
ariaExpanded={isColumnMenuOpen}
|
||||||
|
icon={Columns3}
|
||||||
|
label="Spalten"
|
||||||
|
onClick={toggleColumnSettingsMenu}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||||
>
|
icon={FilterX}
|
||||||
Sortierung/Filter zurücksetzen
|
label="Sortierung/Filter zurücksetzen"
|
||||||
</button>
|
onClick={clearSortAndFilters}
|
||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
showLabel
|
||||||
Spalten
|
/>
|
||||||
</button>
|
|
||||||
{renderColumnSettingsMenu("col-empty")}
|
{renderColumnSettingsMenu("col-empty")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3140,82 +3167,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleUndo()}
|
|
||||||
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
|
||||||
|
icon={Undo2}
|
||||||
|
label="Rückgängig"
|
||||||
|
onClick={() => void handleUndo()}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
historyState?.undoDepth
|
historyState?.undoDepth
|
||||||
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Rückgängig
|
<IconButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleRedo()}
|
|
||||||
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving}
|
||||||
|
icon={Redo2}
|
||||||
|
label="Wiederholen"
|
||||||
|
onClick={() => void handleRedo()}
|
||||||
|
showLabel
|
||||||
title={
|
title={
|
||||||
historyState?.redoDepth
|
historyState?.redoDepth
|
||||||
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
? `${historyState.redoDepth} Änderung(en) können wiederholt werden.`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
Wiederholen
|
<IconButtonSeparator />
|
||||||
</button>
|
|
||||||
{isSortedView ? (
|
{isSortedView ? (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => void handleApplySortedOrder()}
|
|
||||||
disabled={hasActiveFilters || isSaving || historyBusy}
|
disabled={hasActiveFilters || isSaving || historyBusy}
|
||||||
|
icon={Check}
|
||||||
|
label="Sortierung übernehmen"
|
||||||
|
onClick={() => void handleApplySortedOrder()}
|
||||||
|
showLabel
|
||||||
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined}
|
||||||
>
|
/>
|
||||||
Sortierung übernehmen
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
<IconButton
|
||||||
Spalten
|
ariaExpanded={isColumnMenuOpen}
|
||||||
</button>
|
icon={Columns3}
|
||||||
<button
|
label="Spalten"
|
||||||
aria-controls="project-device-drawer"
|
onClick={toggleColumnSettingsMenu}
|
||||||
aria-expanded={isProjectDeviceDrawerOpen}
|
showLabel
|
||||||
className="project-device-drawer-toggle"
|
/>
|
||||||
type="button"
|
<IconButton
|
||||||
onClick={() =>
|
disabled={!isSortedView && !hasActiveFilters}
|
||||||
setIsProjectDeviceDrawerOpen((current) => !current)
|
icon={FilterX}
|
||||||
}
|
label="Sortierung/Filter zurücksetzen"
|
||||||
>
|
onClick={clearSortAndFilters}
|
||||||
{isProjectDeviceDrawerOpen
|
showLabel
|
||||||
? "Projektgeräte schließen"
|
/>
|
||||||
: `Projektgeräte öffnen (${projectDevices.length})`}
|
<IconButtonSeparator />
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={isSaving || historyBusy}
|
disabled={isSaving || historyBusy}
|
||||||
|
icon={FolderPlus}
|
||||||
|
label="Stromkreisgruppe hinzufügen"
|
||||||
|
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
||||||
|
showLabel
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={isSaving || historyBusy}
|
||||||
|
icon={PackagePlus}
|
||||||
|
label="Verteilergerät hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
role: "auxiliary",
|
role: "auxiliary",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
showLabel
|
||||||
Verteilergerät hinzufügen
|
/>
|
||||||
</button>
|
<IconButton
|
||||||
<button
|
ariaControls="project-device-drawer"
|
||||||
type="button"
|
ariaExpanded={isProjectDeviceDrawerOpen}
|
||||||
disabled={isSaving || historyBusy}
|
className="project-device-drawer-toggle"
|
||||||
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
|
icon={isProjectDeviceDrawerOpen ? PanelRightClose : PanelRightOpen}
|
||||||
>
|
label={
|
||||||
Stromkreisgruppe hinzufügen
|
isProjectDeviceDrawerOpen
|
||||||
</button>
|
? "Projektgeräte schließen"
|
||||||
<button
|
: `Projektgeräte öffnen (${projectDevices.length})`
|
||||||
type="button"
|
}
|
||||||
onClick={clearSortAndFilters}
|
onClick={() =>
|
||||||
disabled={!isSortedView && !hasActiveFilters}
|
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||||
>
|
}
|
||||||
Sortierung/Filter zurücksetzen
|
showLabel
|
||||||
</button>
|
/>
|
||||||
{renderColumnSettingsMenu("col")}
|
{renderColumnSettingsMenu("col")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
|
@ -3717,105 +3752,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={!section.category || !section.groupNumber}
|
|
||||||
onClick={() =>
|
|
||||||
setCircuitGroupEditorIntent({
|
|
||||||
kind: "edit",
|
|
||||||
section,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppe bearbeiten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
!buildCircuitGroupReorderAssignments(
|
|
||||||
data.sections,
|
|
||||||
section.id,
|
|
||||||
-1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleMoveCircuitGroup(section.id, -1)
|
|
||||||
}
|
|
||||||
title="Innerhalb derselben Kategorie nach oben verschieben"
|
|
||||||
>
|
|
||||||
Gruppe nach oben
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
!buildCircuitGroupReorderAssignments(
|
|
||||||
data.sections,
|
|
||||||
section.id,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleMoveCircuitGroup(section.id, 1)
|
|
||||||
}
|
|
||||||
title="Innerhalb derselben Kategorie nach unten verschieben"
|
|
||||||
>
|
|
||||||
Gruppe nach unten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={
|
|
||||||
isSaving ||
|
|
||||||
hasActiveSortOrFilter ||
|
|
||||||
!section.category ||
|
|
||||||
!canRenumberCircuitGroups(
|
|
||||||
data.sections,
|
|
||||||
section.category
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
section.category
|
|
||||||
? void handleRenumberCircuitGroups(
|
|
||||||
section.category
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
hasActiveSortOrFilter
|
|
||||||
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
|
|
||||||
: "Alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppen-BMK neu nummerieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
title={
|
icon={Plus}
|
||||||
canDeleteCircuitGroup(section)
|
label="Stromkreis hinzufügen"
|
||||||
? "Leere Stromkreisgruppe entfernen"
|
onClick={() => void handleAddReserveCircuit(section.id)}
|
||||||
: "Befüllte Stromkreisgruppe mit vollständiger Warnung entfernen"
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
void handleDeleteCircuitGroup(section)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Gruppe entfernen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
hasUpstreamProtection ||
|
hasUpstreamProtection ||
|
||||||
!section.category ||
|
!section.category ||
|
||||||
!section.groupNumber
|
!section.groupNumber
|
||||||
}
|
}
|
||||||
|
icon={ShieldPlus}
|
||||||
|
label="Vorsicherung hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
|
|
@ -3828,17 +3779,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
|
||||||
Vorsicherung hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<span className="icon-button-text">VS</span>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
disabled={
|
disabled={
|
||||||
hasGroupRcd ||
|
hasGroupRcd ||
|
||||||
!section.category ||
|
!section.category ||
|
||||||
!section.groupNumber
|
!section.groupNumber
|
||||||
}
|
}
|
||||||
|
icon={ShieldPlus}
|
||||||
|
label="Gruppen-FI hinzufügen"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setComponentEditorIntent({
|
setComponentEditorIntent({
|
||||||
kind: "create",
|
kind: "create",
|
||||||
|
|
@ -3851,26 +3803,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
|
||||||
Gruppen-FI hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
disabled={isSaving}
|
|
||||||
onClick={() => void handleAddReserveCircuit(section.id)}
|
|
||||||
>
|
>
|
||||||
Stromkreis hinzufügen
|
<span className="icon-button-text">FI</span>
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButtonSeparator />
|
||||||
type="button"
|
<IconButton
|
||||||
|
disabled={!section.category || !section.groupNumber}
|
||||||
|
icon={Pencil}
|
||||||
|
label="Gruppe bearbeiten"
|
||||||
|
onClick={() =>
|
||||||
|
setCircuitGroupEditorIntent({
|
||||||
|
kind: "edit",
|
||||||
|
section,
|
||||||
|
})
|
||||||
|
}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!buildCircuitGroupReorderAssignments(
|
||||||
|
data.sections,
|
||||||
|
section.id,
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ArrowUp}
|
||||||
|
label="Gruppe nach oben"
|
||||||
|
onClick={() =>
|
||||||
|
void handleMoveCircuitGroup(section.id, -1)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Gruppe nach oben (innerhalb derselben Kategorie)"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
!buildCircuitGroupReorderAssignments(
|
||||||
|
data.sections,
|
||||||
|
section.id,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ArrowDown}
|
||||||
|
label="Gruppe nach unten"
|
||||||
|
onClick={() =>
|
||||||
|
void handleMoveCircuitGroup(section.id, 1)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title="Gruppe nach unten (innerhalb derselben Kategorie)"
|
||||||
|
/>
|
||||||
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
disabled={hasActiveSortOrFilter || isSaving}
|
disabled={hasActiveSortOrFilter || isSaving}
|
||||||
|
icon={ArrowDown01}
|
||||||
|
label="Bereich neu nummerieren"
|
||||||
onClick={() => void handleRenumberSection(section.id)}
|
onClick={() => void handleRenumberSection(section.id)}
|
||||||
title={hasActiveSortOrFilter ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." : undefined}
|
tabIndex={-1}
|
||||||
>
|
title={
|
||||||
Bereich neu nummerieren
|
hasActiveSortOrFilter
|
||||||
</button>
|
? "Vor der Neunummerierung Sortierung und Filter zurücksetzen."
|
||||||
|
: "Stromkreise dieser Gruppe neu nummerieren"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
hasActiveSortOrFilter ||
|
||||||
|
!section.category ||
|
||||||
|
!canRenumberCircuitGroups(
|
||||||
|
data.sections,
|
||||||
|
section.category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
icon={ListOrdered}
|
||||||
|
label="Gruppen-BMK neu nummerieren"
|
||||||
|
onClick={() =>
|
||||||
|
section.category
|
||||||
|
? void handleRenumberCircuitGroups(
|
||||||
|
section.category
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title={
|
||||||
|
hasActiveSortOrFilter
|
||||||
|
? "Vor der Gruppenneunummerierung Sortierung und Filter zurücksetzen."
|
||||||
|
: "Gruppen-BMK: alle Gruppen dieser Kategorie gemäß ihrer Reihenfolge neu nummerieren"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<IconButtonSeparator />
|
||||||
|
<IconButton
|
||||||
|
disabled={isSaving}
|
||||||
|
icon={Trash2}
|
||||||
|
label="Gruppe entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
void handleDeleteCircuitGroup(section)
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
title={
|
||||||
|
canDeleteCircuitGroup(section)
|
||||||
|
? "Leere Stromkreisgruppe entfernen"
|
||||||
|
: "Befüllte Stromkreisgruppe entfernen (mit Warnung)"
|
||||||
|
}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
||||||
|
|
@ -4467,28 +4505,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
>
|
>
|
||||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={ListPlus}
|
||||||
|
label="Manuelles Gerät hinzufügen"
|
||||||
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}
|
||||||
>
|
|
||||||
Manuelles Gerät hinzufügen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
|
icon={Trash2}
|
||||||
|
label="Stromkreis löschen"
|
||||||
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
onClick={() => void handleDeleteCircuit(row.circuit!.id)}
|
||||||
>
|
tabIndex={-1}
|
||||||
Stromkreis löschen
|
variant="danger"
|
||||||
</button>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{row.device ? (
|
{row.device ? (
|
||||||
<button type="button" tabIndex={-1} disabled={isSaving} onClick={() => void handleDeleteDevice(row.device!.id)}>
|
<IconButton
|
||||||
Gerät löschen
|
disabled={isSaving}
|
||||||
</button>
|
icon={ListX}
|
||||||
|
label="Gerät löschen"
|
||||||
|
onClick={() => void handleDeleteDevice(row.device!.id)}
|
||||||
|
tabIndex={-1}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
||||||
<span className="drop-hint">
|
<span className="drop-hint">
|
||||||
|
|
|
||||||
59
src/frontend/components/icon-button.tsx
Normal file
59
src/frontend/components/icon-button.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact editor button with a lucide icon. `label` is always the accessible
|
||||||
|
* name; it is also the tooltip unless a more specific `title` is given.
|
||||||
|
* With `showLabel` the label (or `children`) is rendered next to the icon.
|
||||||
|
*/
|
||||||
|
export function IconButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
variant = "default",
|
||||||
|
showLabel = false,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
tabIndex,
|
||||||
|
ariaExpanded,
|
||||||
|
ariaControls,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: "default" | "danger" | "primary";
|
||||||
|
showLabel?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
tabIndex?: number;
|
||||||
|
ariaExpanded?: boolean;
|
||||||
|
ariaControls?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-controls={ariaControls}
|
||||||
|
aria-expanded={ariaExpanded}
|
||||||
|
aria-label={showLabel ? undefined : label}
|
||||||
|
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
title={title ?? label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||||
|
{showLabel ? <span>{children ?? label}</span> : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thin vertical divider between groups of icon buttons. */
|
||||||
|
export function IconButtonSeparator() {
|
||||||
|
return <span aria-hidden="true" className="icon-button-separator" />;
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger";
|
||||||
const logger = createLogger("web:navigation");
|
const logger = createLogger("web:navigation");
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
// The Docker healthcheck hits "/" every few seconds with no User-Agent
|
// Probes hit "/web-health" and are excluded by the matcher below. The
|
||||||
// header; skip it so real navigation isn't drowned out in the logs.
|
// 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")) {
|
if (request.headers.get("user-agent")) {
|
||||||
logger.info("page request", {
|
logger.info("page request", {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
|
@ -17,5 +18,7 @@ export function proxy(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import { projectDeviceRouter } from "./routes/project-device.routes.js";
|
||||||
import { projectRouter } from "./routes/project.routes.js";
|
import { projectRouter } from "./routes/project.routes.js";
|
||||||
import { errorMiddleware } from "./middleware/error.middleware.js";
|
import { errorMiddleware } from "./middleware/error.middleware.js";
|
||||||
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
|
||||||
|
import { closeDefaultDatabase } from "../db/client.js";
|
||||||
|
|
||||||
const logger = createLogger("api");
|
const logger = createLogger("api");
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
const heartbeatIntervalMs = 5 * 60 * 1000;
|
const heartbeatIntervalMs = 5 * 60 * 1000;
|
||||||
|
const shutdownTimeoutMs = 15 * 1000;
|
||||||
|
|
||||||
app.use(express.json({ limit: "25mb" }));
|
app.use(express.json({ limit: "25mb" }));
|
||||||
|
|
||||||
|
|
@ -62,9 +64,6 @@ process.on("unhandledRejection", (reason) => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => logger.info("received SIGTERM"));
|
|
||||||
process.on("SIGINT", () => logger.info("received SIGINT"));
|
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const memory = process.memoryUsage();
|
const memory = process.memoryUsage();
|
||||||
logger.verbose("heartbeat", {
|
logger.verbose("heartbeat", {
|
||||||
|
|
@ -74,6 +73,40 @@ setInterval(() => {
|
||||||
});
|
});
|
||||||
}, heartbeatIntervalMs).unref();
|
}, heartbeatIntervalMs).unref();
|
||||||
|
|
||||||
app.listen(port, () => {
|
const server = app.listen(port, () => {
|
||||||
logger.info("server started", { port });
|
logger.info("server started", { port });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
function shutdown(signal: NodeJS.Signals): void {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
logger.info("shutting down", { signal });
|
||||||
|
|
||||||
|
// Falls ein Request haengt oder ein Socket nicht freigegeben wird, soll der
|
||||||
|
// Prozess trotzdem enden, statt auf das SIGKILL des Containers zu warten.
|
||||||
|
const forceExit = setTimeout(() => {
|
||||||
|
logger.error("shutdown timed out, forcing exit");
|
||||||
|
process.exit(1);
|
||||||
|
}, shutdownTimeoutMs);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ohne das warten offene Keep-Alive-Verbindungen (z. B. die des
|
||||||
|
// Next.js-Servers) den Timeout oben aus; laufende Requests laufen weiter.
|
||||||
|
server.closeIdleConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
|
||||||
15
styles.css
15
styles.css
|
|
@ -1,15 +0,0 @@
|
||||||
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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.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 { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceRowSyncProjectCommand,
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
type ProjectDeviceSyncRowSnapshot,
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
|
@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) {
|
||||||
return row;
|
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(
|
function snapshot(
|
||||||
context: DatabaseContext,
|
context: DatabaseContext,
|
||||||
rowId: string
|
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", () => {
|
it("rolls back synchronized rows for a stale project revision", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue