diff --git a/.dockerignore b/.dockerignore index 80054d0..abd6bc2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,6 @@ data *.db .env .env.* +# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image. +.codex +.agents diff --git a/.gitignore b/.gitignore index 8d44fb4..e0ef1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ node_modules/ dist/ .next/ +# Streuprodukt von tsc; drizzle-kit liest drizzle.config.ts. +/drizzle.config.js data/*.db data/backups/*.db .codex/*.log +dynamo/output/ diff --git a/AGENTS.md b/AGENTS.md index f1a509d..0ec3242 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,13 @@ Keyboard behavior: 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 may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals. diff --git a/Dockerfile b/Dockerfile index 41a08fd..66147e7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,56 @@ -FROM node:22 -WORKDIR /app +# syntax=docker/dockerfile:1 +# --------------------------------------------------------------------------- +# 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 ./ 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 . . RUN npm run build:api && npm run build:web +RUN npm prune --omit=dev -RUN mkdir -p data && chmod +x scripts/docker-start.sh - -EXPOSE 3001 - -CMD ["sh", "scripts/docker-start.sh"] +# --------------------------------------------------------------------------- +# prod: Laufzeit-Image. node:24-slim teilt sich das Debian-Release mit der +# build-Stufe, damit das kompilierte better-sqlite3-Binding ladbar bleibt. +# Laeuft als "node" (UID 1000) statt als root. +# --------------------------------------------------------------------------- +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"] diff --git a/README.md b/README.md index c333917..58f1832 100644 --- a/README.md +++ b/README.md @@ -50,27 +50,50 @@ docker compose up --build --detach Danach: - Frontend: -- API-Healthcheck: +- API-Healthcheck: (ü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 automatisch aus. Die SQLite-Datei liegt auf dem Host unter `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 docker compose ps docker compose logs --follow docker compose down ``` -Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein -Produktionsdeployment. Details stehen in +`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, +ohne Quellcode-Mounts und ohne Datei-Watcher, mit Speichergrenzen und ohne +root-Rechte im Container. Details stehen in [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 Voraussetzungen: -- Node.js 22 +- Node.js 24 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..b9c64b4 --- /dev/null +++ b/compose.dev.yaml @@ -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 diff --git a/compose.yaml b/compose.yaml index 6a5ec7c..3438f90 100644 --- a/compose.yaml +++ b/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 +x-build: &build + context: . + target: prod + args: + # Wird von next build in .next/routes-manifest.json eingebacken, siehe + # Dockerfile. Muss der API-Adresse in diesem Netz entsprechen. + API_INTERNAL_URL: http://api:3000 + +x-service-defaults: &service-defaults + init: true + 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: + driver: json-file + options: + max-size: "20m" + max-file: "10" + services: api: - build: - context: . + <<: *service-defaults + build: *build + # "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und + # SIGTERM erreicht node nie. command: - sh - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api + - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js environment: + NODE_ENV: production PORT: "3000" - CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" - ports: - - "3000:3000" + # 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: - - ./src:/app/src - - ./scripts:/app/scripts - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + - 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: 12 + retries: 5 start_period: 20s web: - build: - context: . + <<: *service-defaults + build: *build command: - - npm - - run - - dev:web - - -- + - node_modules/.bin/next + - start + - -p + - "3001" - --hostname - 0.0.0.0 environment: + NODE_ENV: production API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" - init: true - restart: unless-stopped - logging: - driver: json-file - options: - max-size: "20m" - max-file: "10" depends_on: api: condition: service_healthy ports: - "3001:3001" - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro + mem_limit: 1g + memswap_limit: 1g 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: - CMD - node - -e - - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 5s + - 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: 12 + retries: 5 start_period: 20s diff --git a/docs/deployment.md b/docs/deployment.md index dfbb3f1..909c2c0 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,21 +2,57 @@ ## 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 -`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, -Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes -Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder -öffentlich erreichbar gemacht werden. +`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und +`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack +für einen Server. Beide Dienste laufen aus der `prod`-Stufe des Dockerfiles, +also als Benutzer `node` statt als root, ohne devDependencies, mit +`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 | -| --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /` | keine | -| Express API | 3000 | `GET /health` | `./data:/app/data` | -| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | +Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch +ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich +erreichbar gemacht werden. + +## Topologie + +| 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: @@ -24,15 +60,22 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - Dateibeobachtung in Docker +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in + `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst `npm run db:migrate` und -`npm run db:verify:circuit-schema`. +Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung +(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im +Entwicklungsstack über `npm run db:migrate` und +`npm run db:verify:circuit-schema`). + +`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 @@ -68,11 +111,25 @@ einem unbekannten Zustand weiterzulaufen. Beide Dienste laufen deshalb mit `restart: unless-stopped`, damit Docker sie danach automatisch neu startet; ohne diese Policy würde ein Crash den Dienst dauerhaft unerreichbar lassen. +## 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 Vor einer produktiven Installation werden mindestens benötigt: -- reproduzierbare Produktionsimages und ein Next.js-Produktionsstartskript - TLS-Termination und Reverse Proxy - Authentifizierung, Autorisierung und Benutzer-/Rollenmodell - definierte Secrets- und Konfigurationsverwaltung diff --git a/drizzle.config.js b/drizzle.config.js deleted file mode 100644 index 004045a..0000000 --- a/drizzle.config.js +++ /dev/null @@ -1,11 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -const drizzle_kit_1 = require("drizzle-kit"); -exports.default = (0, drizzle_kit_1.defineConfig)({ - dialect: "sqlite", - schema: "./src/db/schema/*.ts", - out: "./src/db/migrations", - dbCredentials: { - url: "./data/leistungsbilanz.db", - }, -}); diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py new file mode 100644 index 0000000..a3fe009 --- /dev/null +++ b/dynamo/01_check_model_identity.py @@ -0,0 +1,385 @@ +"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node. + +Optional Dynamo input: + IN[0]: output directory or complete .json file path + +The script intentionally performs no Revit transaction and changes no model +data. OUT contains a compact summary plus the complete report. +""" + +import datetime +import json +import os +import platform +import sys +import tempfile +import traceback + +import clr + +clr.AddReference("RevitAPI") +clr.AddReference("RevitServices") + +from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402 +from RevitServices.Persistence import DocumentManager # noqa: E402 + + +CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId") + + +def safe_text(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def element_id_text(element_id): + if element_id is None: + return None + try: + return str(element_id.Value) + except Exception: + try: + return str(element_id.IntegerValue) + except Exception: + return safe_text(element_id) + + +def forge_type_id_text(value): + if value is None: + return None + try: + return value.TypeId + except Exception: + return safe_text(value) + + +def parameter_value(parameter): + result = { + "hasValue": False, + "raw": None, + "display": None, + } + try: + result["hasValue"] = bool(parameter.HasValue) + except Exception: + pass + + try: + storage_type = parameter.StorageType + if storage_type == StorageType.String: + result["raw"] = parameter.AsString() + elif storage_type == StorageType.Integer: + result["raw"] = int(parameter.AsInteger()) + elif storage_type == StorageType.Double: + result["raw"] = float(parameter.AsDouble()) + elif storage_type == StorageType.ElementId: + result["raw"] = element_id_text(parameter.AsElementId()) + except Exception as error: + result["readError"] = safe_text(error) + + try: + result["display"] = parameter.AsValueString() + except Exception: + pass + return result + + +def describe_parameter(parameter): + definition = None + try: + definition = parameter.Definition + except Exception: + pass + + name = None + if definition is not None: + try: + name = definition.Name + except Exception: + pass + + is_shared = False + try: + is_shared = bool(parameter.IsShared) + except Exception: + pass + + shared_guid = None + if is_shared: + try: + shared_guid = str(parameter.GUID) + except Exception: + pass + + data_type = None + group_type = None + if definition is not None: + try: + data_type = forge_type_id_text(definition.GetDataType()) + except Exception: + pass + try: + group_type = forge_type_id_text(definition.GetGroupTypeId()) + except Exception: + pass + + unit_type = None + try: + unit_type = forge_type_id_text(parameter.GetUnitTypeId()) + except Exception: + pass + + try: + storage_type = str(parameter.StorageType) + except Exception: + storage_type = None + + try: + is_read_only = bool(parameter.IsReadOnly) + except Exception: + is_read_only = None + + try: + user_modifiable = bool(parameter.UserModifiable) + except Exception: + user_modifiable = None + + return { + "name": name, + "parameterId": element_id_text(getattr(parameter, "Id", None)), + "isShared": is_shared, + "sharedGuid": shared_guid, + "storageType": storage_type, + "dataTypeId": data_type, + "groupTypeId": group_type, + "unitTypeId": unit_type, + "isReadOnly": is_read_only, + "userModifiable": user_modifiable, + "value": parameter_value(parameter), + } + + +def sorted_parameters(element): + parameters = [] + try: + parameters = [describe_parameter(parameter) for parameter in element.Parameters] + except Exception: + return [] + return sorted( + parameters, + key=lambda parameter: ( + (parameter.get("name") or "").casefold(), + parameter.get("parameterId") or "", + ), + ) + + +def named_parameter_occurrences(element, parameter_name): + result = [] + try: + parameters = element.GetParameters(parameter_name) + if parameters is not None: + result = [describe_parameter(parameter) for parameter in parameters] + except Exception: + parameter = None + try: + parameter = element.LookupParameter(parameter_name) + except Exception: + pass + if parameter is not None: + result = [describe_parameter(parameter)] + return result + + +def loaded_assembly_versions(): + result = {} + try: + from System import AppDomain + + for assembly in AppDomain.CurrentDomain.GetAssemblies(): + try: + name = assembly.GetName() + simple_name = str(name.Name) + if simple_name in ( + "DynamoCore", + "DynamoCoreWpf", + "DynamoRevitDS", + "RevitAPI", + "RevitServices", + ): + result[simple_name] = str(name.Version) + except Exception: + continue + except Exception: + pass + return dict(sorted(result.items())) + + +def get_cloud_identity(document): + result = {"isModelInCloud": False} + try: + result["isModelInCloud"] = bool(document.IsModelInCloud) + except Exception: + return result + if not result["isModelInCloud"]: + return result + + try: + model_path = document.GetCloudModelPath() + result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( + model_path + ) + for property_name, output_name in ( + ("GetProjectGUID", "projectGuid"), + ("GetModelGUID", "modelGuid"), + ): + try: + result[output_name] = str(getattr(model_path, property_name)()) + except Exception: + pass + except Exception as error: + result["readError"] = safe_text(error) + return result + + +def get_worksharing_identity(document): + result = {"isWorkshared": False} + try: + result["isWorkshared"] = bool(document.IsWorkshared) + except Exception: + return result + if not result["isWorkshared"]: + return result + + try: + model_path = document.GetWorksharingCentralModelPath() + result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( + model_path + ) + except Exception as error: + result["readError"] = safe_text(error) + return result + + +def resolve_output_path(configured_path, report_name): + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") + raw_path = safe_text(configured_path) + if raw_path is None or not raw_path.strip(): + directory = default_directory + file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") + else: + expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) + if expanded.lower().endswith(".json"): + file_path = expanded + directory = os.path.dirname(file_path) + else: + directory = expanded + file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") + if not directory: + directory = os.getcwd() + if not os.path.isdir(directory): + os.makedirs(directory) + return file_path + + +def write_json(file_path, payload): + temporary_path = file_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary_path, file_path) + + +def get_input(index, default=None): + values = globals().get("IN", []) + try: + value = values[index] + return default if value is None else value + except Exception: + return default + + +def build_report(): + document = DocumentManager.Instance.CurrentDBDocument + if document is None: + raise RuntimeError("No active Revit document is available.") + + project_information = document.ProjectInformation + if project_information is None: + raise RuntimeError("The active document has no Project Information element.") + + application = document.Application + checked_parameters = { + name: named_parameter_occurrences(project_information, name) + for name in CHECKED_PARAMETER_NAMES + } + warnings = [] + for name in CHECKED_PARAMETER_NAMES: + occurrences = checked_parameters[name] + populated = [ + parameter + for parameter in occurrences + if parameter.get("value", {}).get("raw") not in (None, "") + ] + if not occurrences: + warnings.append(name + " is not bound to Project Information.") + elif not populated: + warnings.append(name + " exists but has no value on Project Information.") + elif len(occurrences) > 1: + warnings.append(name + " occurs more than once; use a shared-parameter GUID later.") + + return { + "reportSchemaVersion": 1, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "readOnly": True, + "environment": { + "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), + "revitVersionName": safe_text(getattr(application, "VersionName", None)), + "revitSubVersionNumber": safe_text( + getattr(application, "SubVersionNumber", None) + ), + "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), + "pythonVersion": platform.python_version(), + "assemblies": loaded_assembly_versions(), + }, + "document": { + "title": safe_text(document.Title), + "pathName": safe_text(document.PathName), + "isFamilyDocument": bool(document.IsFamilyDocument), + "cloud": get_cloud_identity(document), + "worksharing": get_worksharing_identity(document), + }, + "modelIdentityCandidates": { + "projectInformationUniqueId": safe_text(project_information.UniqueId), + "projectInformationElementId": element_id_text(project_information.Id), + "checkedProjectParameters": checked_parameters, + }, + "projectInformationParameters": sorted_parameters(project_information), + "warnings": warnings, + } + + +try: + report = build_report() + output_path = resolve_output_path(get_input(0), "model-identity") + write_json(output_path, report) + OUT = { + "ok": True, + "filePath": output_path, + "projectInformationUniqueId": report["modelIdentityCandidates"][ + "projectInformationUniqueId" + ], + "warnings": report["warnings"], + "report": report, + } +except Exception as error: + OUT = { + "ok": False, + "error": safe_text(error), + "traceback": traceback.format_exc(), + } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py new file mode 100644 index 0000000..440a222 --- /dev/null +++ b/dynamo/02_export_electrical_fixture_parameter_inventory.py @@ -0,0 +1,517 @@ +"""Export all Electrical Fixtures instance/type parameters from Revit 2026. + +Optional Dynamo inputs: + IN[0]: output directory or complete .json file path + IN[1]: include empty parameters (default True) + IN[2]: maximum aggregated sample values (default 5) + +The script is read-only and performs no Revit transaction. +""" + +import datetime +import json +import os +import platform +import sys +import tempfile +import traceback + +import clr + +clr.AddReference("RevitAPI") +clr.AddReference("RevitServices") + +from Autodesk.Revit.DB import ( # noqa: E402 + BuiltInCategory, + FilteredElementCollector, + ModelPathUtils, + StorageType, +) +from RevitServices.Persistence import DocumentManager # noqa: E402 + + +def safe_text(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def element_id_text(element_id): + if element_id is None: + return None + try: + return str(element_id.Value) + except Exception: + try: + return str(element_id.IntegerValue) + except Exception: + return safe_text(element_id) + + +def forge_type_id_text(value): + if value is None: + return None + try: + return value.TypeId + except Exception: + return safe_text(value) + + +def parameter_value(parameter): + result = {"hasValue": False, "raw": None, "display": None} + try: + result["hasValue"] = bool(parameter.HasValue) + except Exception: + pass + + try: + storage_type = parameter.StorageType + if storage_type == StorageType.String: + result["raw"] = parameter.AsString() + elif storage_type == StorageType.Integer: + result["raw"] = int(parameter.AsInteger()) + elif storage_type == StorageType.Double: + result["raw"] = float(parameter.AsDouble()) + elif storage_type == StorageType.ElementId: + result["raw"] = element_id_text(parameter.AsElementId()) + except Exception as error: + result["readError"] = safe_text(error) + + try: + result["display"] = parameter.AsValueString() + except Exception: + pass + return result + + +def describe_parameter(parameter, scope): + definition = None + try: + definition = parameter.Definition + except Exception: + pass + + name = None + data_type = None + group_type = None + if definition is not None: + try: + name = definition.Name + except Exception: + pass + try: + data_type = forge_type_id_text(definition.GetDataType()) + except Exception: + pass + try: + group_type = forge_type_id_text(definition.GetGroupTypeId()) + except Exception: + pass + + is_shared = False + try: + is_shared = bool(parameter.IsShared) + except Exception: + pass + + shared_guid = None + if is_shared: + try: + shared_guid = str(parameter.GUID) + except Exception: + pass + + unit_type = None + try: + unit_type = forge_type_id_text(parameter.GetUnitTypeId()) + except Exception: + pass + + try: + storage_type = str(parameter.StorageType) + except Exception: + storage_type = None + + try: + is_read_only = bool(parameter.IsReadOnly) + except Exception: + is_read_only = None + + try: + user_modifiable = bool(parameter.UserModifiable) + except Exception: + user_modifiable = None + + return { + "scope": scope, + "name": name, + "parameterId": element_id_text(getattr(parameter, "Id", None)), + "isShared": is_shared, + "sharedGuid": shared_guid, + "storageType": storage_type, + "dataTypeId": data_type, + "groupTypeId": group_type, + "unitTypeId": unit_type, + "isReadOnly": is_read_only, + "userModifiable": user_modifiable, + "value": parameter_value(parameter), + } + + +def has_meaningful_value(parameter_description): + value = parameter_description.get("value", {}) + return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get( + "display" + ) not in (None, "") + + +def read_parameters(element, scope, include_empty): + result = [] + try: + for parameter in element.Parameters: + description = describe_parameter(parameter, scope) + if include_empty or has_meaningful_value(description): + result.append(description) + except Exception as error: + return [], [safe_text(error)] + result.sort( + key=lambda parameter: ( + (parameter.get("name") or "").casefold(), + parameter.get("parameterId") or "", + ) + ) + return result, [] + + +def read_space(element, document): + try: + space = element.Space + except Exception as error: + return None, safe_text(error) + if space is None: + return None, None + + level_name = None + try: + level = document.GetElement(space.LevelId) + level_name = None if level is None else safe_text(level.Name) + except Exception: + pass + + return { + "uniqueId": safe_text(space.UniqueId), + "elementId": element_id_text(space.Id), + "number": safe_text(getattr(space, "Number", None)), + "name": safe_text(getattr(space, "Name", None)), + "levelName": level_name, + }, None + + +def read_family_identity(element, document): + symbol = None + try: + symbol = element.Symbol + except Exception: + try: + symbol = document.GetElement(element.GetTypeId()) + except Exception: + pass + + family_name = None + type_name = None + type_unique_id = None + type_element_id = None + if symbol is not None: + try: + family_name = safe_text(symbol.Family.Name) + except Exception: + family_name = safe_text(getattr(symbol, "FamilyName", None)) + type_name = safe_text(getattr(symbol, "Name", None)) + type_unique_id = safe_text(getattr(symbol, "UniqueId", None)) + type_element_id = element_id_text(getattr(symbol, "Id", None)) + + return { + "familyName": family_name, + "typeName": type_name, + "typeUniqueId": type_unique_id, + "typeElementId": type_element_id, + }, symbol + + +def parameter_inventory_key(parameter): + stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or "" + return "|".join( + ( + parameter.get("scope") or "", + stable_id, + parameter.get("name") or "", + parameter.get("dataTypeId") or "", + ) + ) + + +def sample_value_key(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def add_to_inventory(inventory, parameter, max_samples): + key = parameter_inventory_key(parameter) + entry = inventory.get(key) + if entry is None: + entry = { + "scope": parameter.get("scope"), + "name": parameter.get("name"), + "parameterId": parameter.get("parameterId"), + "isShared": parameter.get("isShared"), + "sharedGuid": parameter.get("sharedGuid"), + "storageType": parameter.get("storageType"), + "dataTypeId": parameter.get("dataTypeId"), + "groupTypeId": parameter.get("groupTypeId"), + "unitTypeId": parameter.get("unitTypeId"), + "occurrenceCount": 0, + "populatedCount": 0, + "sampleValues": [], + "_sampleKeys": set(), + } + inventory[key] = entry + entry["occurrenceCount"] += 1 + if has_meaningful_value(parameter): + entry["populatedCount"] += 1 + value = parameter.get("value") + value_key = sample_value_key(value) + if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]: + entry["_sampleKeys"].add(value_key) + entry["sampleValues"].append(value) + + +def finalize_inventory(inventory): + result = [] + for entry in inventory.values(): + clean_entry = dict(entry) + clean_entry.pop("_sampleKeys", None) + result.append(clean_entry) + return sorted( + result, + key=lambda entry: ( + entry.get("scope") or "", + (entry.get("name") or "").casefold(), + entry.get("sharedGuid") or entry.get("parameterId") or "", + ), + ) + + +def loaded_assembly_versions(): + result = {} + try: + from System import AppDomain + + for assembly in AppDomain.CurrentDomain.GetAssemblies(): + try: + name = assembly.GetName() + simple_name = str(name.Name) + if simple_name in ( + "DynamoCore", + "DynamoCoreWpf", + "DynamoRevitDS", + "RevitAPI", + "RevitServices", + ): + result[simple_name] = str(name.Version) + except Exception: + continue + except Exception: + pass + return dict(sorted(result.items())) + + +def resolve_output_path(configured_path): + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") + raw_path = safe_text(configured_path) + if raw_path is None or not raw_path.strip(): + directory = default_directory + file_path = os.path.join( + directory, "electrical-fixture-parameters-" + timestamp + ".json" + ) + else: + expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) + if expanded.lower().endswith(".json"): + file_path = expanded + directory = os.path.dirname(file_path) + else: + directory = expanded + file_path = os.path.join( + directory, "electrical-fixture-parameters-" + timestamp + ".json" + ) + if not directory: + directory = os.getcwd() + if not os.path.isdir(directory): + os.makedirs(directory) + return file_path + + +def write_json(file_path, payload): + temporary_path = file_path + ".tmp" + with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: + json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary_path, file_path) + + +def get_input(index, default=None): + values = globals().get("IN", []) + try: + value = values[index] + return default if value is None else value + except Exception: + return default + + +def build_report(include_empty, max_samples): + document = DocumentManager.Instance.CurrentDBDocument + if document is None: + raise RuntimeError("No active Revit document is available.") + if document.IsFamilyDocument: + raise RuntimeError("Open a Revit project document, not a family document.") + + application = document.Application + collector = ( + FilteredElementCollector(document) + .OfCategory(BuiltInCategory.OST_ElectricalFixtures) + .WhereElementIsNotElementType() + ) + source_elements = list(collector) + source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "") + + elements = [] + types_by_unique_id = {} + inventory = {} + errors = [] + elements_without_space = 0 + + for element in source_elements: + element_errors = [] + try: + family_identity, symbol = read_family_identity(element, document) + instance_parameters, parameter_errors = read_parameters( + element, "instance", include_empty + ) + element_errors.extend(parameter_errors) + for parameter in instance_parameters: + add_to_inventory(inventory, parameter, max_samples) + + type_unique_id = family_identity.get("typeUniqueId") + if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id: + type_parameters, type_errors = read_parameters(symbol, "type", include_empty) + element_errors.extend(type_errors) + for parameter in type_parameters: + add_to_inventory(inventory, parameter, max_samples) + types_by_unique_id[type_unique_id] = { + **family_identity, + "parameters": type_parameters, + } + + space, space_error = read_space(element, document) + if space_error: + element_errors.append("MEP Space: " + space_error) + if space is None: + elements_without_space += 1 + + elements.append( + { + "uniqueId": safe_text(element.UniqueId), + "elementId": element_id_text(element.Id), + "categoryName": safe_text( + None if element.Category is None else element.Category.Name + ), + "family": family_identity, + "space": space, + "instanceParameters": instance_parameters, + "warnings": element_errors, + } + ) + except Exception as error: + errors.append( + { + "uniqueId": safe_text(getattr(element, "UniqueId", None)), + "elementId": element_id_text(getattr(element, "Id", None)), + "error": safe_text(error), + } + ) + + return { + "reportSchemaVersion": 1, + "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "readOnly": True, + "complete": len(errors) == 0, + "scope": { + "builtInCategory": "OST_ElectricalFixtures", + "wholeDocument": True, + "elementTypesExcluded": True, + "includeEmptyParameters": include_empty, + }, + "environment": { + "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), + "revitVersionName": safe_text(getattr(application, "VersionName", None)), + "revitSubVersionNumber": safe_text( + getattr(application, "SubVersionNumber", None) + ), + "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), + "pythonVersion": platform.python_version(), + "assemblies": loaded_assembly_versions(), + }, + "document": { + "title": safe_text(document.Title), + "pathName": safe_text(document.PathName), + "projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId), + }, + "summary": { + "elementCount": len(source_elements), + "exportedElementCount": len(elements), + "typeCount": len(types_by_unique_id), + "parameterDefinitionCount": len(inventory), + "elementsWithoutMepSpace": elements_without_space, + "elementErrorCount": len(errors), + }, + "parameterInventory": finalize_inventory(inventory), + "types": sorted( + types_by_unique_id.values(), + key=lambda entry: ( + (entry.get("familyName") or "").casefold(), + (entry.get("typeName") or "").casefold(), + entry.get("typeUniqueId") or "", + ), + ), + "elements": elements, + "errors": errors, + } + + +try: + include_empty_input = get_input(1, True) + include_empty = bool(include_empty_input) + try: + max_samples = int(get_input(2, 5)) + except Exception: + max_samples = 5 + max_samples = max(0, min(max_samples, 50)) + + report = build_report(include_empty, max_samples) + output_path = resolve_output_path(get_input(0)) + write_json(output_path, report) + OUT = { + "ok": True, + "filePath": output_path, + "complete": report["complete"], + "summary": report["summary"], + "errors": report["errors"], + } +except Exception as error: + OUT = { + "ok": False, + "error": safe_text(error), + "traceback": traceback.format_exc(), + } diff --git a/dynamo/README.md b/dynamo/README.md new file mode 100644 index 0000000..800eded --- /dev/null +++ b/dynamo/README.md @@ -0,0 +1,69 @@ +# Revit 2026 / Dynamo diagnostics + +This directory contains self-contained Python scripts for a Dynamo **Python +Script** node. They use only Dynamo's built-in Revit integration, the Revit API +and the Python standard library. No Dynamo package is required. + +The scripts are read-only. They do not start a Revit transaction and do not +change the open model. + +## Python engine + +Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with +Revit; optional PythonNet3 packages are not required by these diagnostics. + +## 01 - Check model identity + +File: `01_check_model_identity.py` + +The script reports: + +- Revit, Dynamo and Python versions; +- `ProjectInformation.UniqueId` as a native model-identity candidate; +- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project + Information; +- all Project Information parameters; +- optional cloud/worksharing identity information when the API exposes it. + +Input `IN[0]` is optional. It may be either an output directory or a complete +`.json` file path. With no input, the report is written below the current +Windows temporary directory in `leistungsbilanz-dynamo`. + +## 02 - Inventory Electrical Fixtures parameters + +File: `02_export_electrical_fixture_parameter_inventory.py` + +The script reads every instance of +`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It +exports: + +- element, family, type and MEP Space identities; +- every instance parameter and value; +- every unique family-type parameter and value; +- an aggregated parameter inventory with occurrence counts and sample values; +- per-element warnings instead of aborting at the first unreadable element. + +Inputs: + +- `IN[0]` (optional): output directory or complete `.json` path; +- `IN[1]` (optional): include empty parameters, default `true`; +- `IN[2]` (optional): maximum sample values per aggregated parameter, default + `5`. + +The default output location is again the Windows temporary directory. The +generated report can contain model paths and project-specific parameter values; +review it before sharing or committing it. + +## Running a script + +1. Open the target model in Revit 2026. +2. Open Dynamo from **Manage > Visual Programming > Dynamo**. +3. Create a graph and add a **Python Script** node. +4. Select the `CPython3` engine for the node. +5. Copy the complete content of the desired `.py` file into the node. +6. Optionally connect a String node containing the output path to `IN[0]`. +7. Run the graph and inspect `OUT` for status, counts and the generated path. + +For the first test, run `01_check_model_identity.py` in the Revit main model. +Then run the parameter inventory. Keep both generated JSON files so their +structure can be checked before the production snapshot DTO is finalized. diff --git a/index.html b/index.html deleted file mode 100644 index dcb632f..0000000 --- a/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - Leistungsbilanz API - - - -
-

Leistungsbilanz Backend

-

Der aktuelle Stand laeuft als TypeScript Node/SQLite API.

-

Serverstart: npm run dev

-

Healthcheck: GET /health

-
- - diff --git a/package-lock.json b/package-lock.json index b0abb41..417421b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,15 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" + }, + "engines": { + "node": "24.x" } }, "node_modules/@drizzle-team/brocli": { @@ -1514,12 +1517,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "devOptional": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -3688,10 +3692,11 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "devOptional": true + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 9bc5ae3..414eb53 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "description": "Spreadsheet-style circuit list editor for electrical distribution planning", "main": "dist/server/index.js", + "engines": { + "node": "24.x" + }, "scripts": { "dev": "npm run dev:api", "dev:api": "tsx watch src/server/index.ts", @@ -10,6 +13,9 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", + "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", + "docker:dev:down": "docker compose -f compose.dev.yaml down", + "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", @@ -40,7 +46,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^25.6.0", + "@types/node": "^24.10.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh deleted file mode 100644 index bf0d856..0000000 --- a/scripts/docker-start.sh +++ /dev/null @@ -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 diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..ec92d91 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -487,6 +487,54 @@ a.kpi:hover { 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 { border-color: var(--accent-blue); background: var(--accent-blue); @@ -1153,7 +1201,20 @@ a.kpi:hover { .tree-grid .section-actions { 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 { @@ -1265,7 +1326,7 @@ a.kpi:hover { .tree-grid .action-cell { display: flex; - gap: 0.3rem; + gap: 0.25rem; } .tree-grid .action-cell button { diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts new file mode 100644 index 0000000..ce6f2c4 --- /dev/null +++ b/src/app/web-health/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +// Liveness probe for the web container itself. The "/health" path is +// rewritten to the API in next.config.mjs, so it cannot answer for this +// process. Kept as a route handler so a probe does not render a page. +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/src/db/client.ts b/src/db/client.ts index 02cfc7e..4d0c97b 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -7,3 +7,10 @@ const defaultDatabaseContext = createDatabaseContext( 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(); +} diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index da17458..eb722d7 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { + const values: typeof assignment.target & { + manualQuantity?: number; + } = { ...assignment.target }; + if (assignment.target.quantity !== assignment.expected.quantity) { + const externalTotal = tx + .select({ planningValues: externalModelObjects.planningValues }) + .from(externalModelObjects) + .where( + eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) + ) + .all() + .reduce( + (sum, object) => sum + object.planningValues.effectiveQuantity, + 0 + ); + const manualQuantity = assignment.target.quantity - externalTotal; + if (manualQuantity < 0) { + throw new Error( + "Synchronized quantity is below the total quantity of linked external objects." + ); + } + values.manualQuantity = manualQuantity; + } const updated = tx .update(circuitDeviceRows) - .set(assignment.target) + .set(values) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 03aa38d..cc3d522 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,6 +1,28 @@ "use client"; 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 { inferProjectDeviceSectionKey, isProjectDevicePlacementValid, @@ -2995,32 +3017,37 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
- - - - - {renderColumnSettingsMenu("col-empty")} -
+ icon={Redo2} + label="Wiederholen" + onClick={() => void handleRedo()} + showLabel + /> + + + + {renderColumnSettingsMenu("col-empty")} +
{renderActiveViewSummary()} {renderDistributionPowerSummary()} {error ? ( @@ -3140,82 +3167,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str /> ) : null}
- - + /> + {isSortedView ? ( - + /> ) : null} - - - - - + showLabel + /> + + setIsProjectDeviceDrawerOpen((current) => !current) + } + showLabel + /> {renderColumnSettingsMenu("col")}
{renderActiveViewSummary()} @@ -3717,105 +3752,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
- - - - - - - - - + tabIndex={-1} + title={ + hasActiveSortOrFilter + ? "Vor der Neunummerierung Sortierung und Filter zurücksetzen." + : "Stromkreise dieser Gruppe neu nummerieren" + } + /> + + 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" + } + /> + + + void handleDeleteCircuitGroup(section) + } + tabIndex={-1} + title={ + canDeleteCircuitGroup(section) + ? "Leere Stromkreisgruppe entfernen" + : "Befüllte Stromkreisgruppe entfernen (mit Warnung)" + } + variant="danger" + />
{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" ? ( <> - - + tabIndex={-1} + variant="danger" + /> ) : null} {row.device ? ( - + void handleDeleteDevice(row.device!.id)} + tabIndex={-1} + variant="danger" + /> ) : null} {dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? ( diff --git a/src/frontend/components/icon-button.tsx b/src/frontend/components/icon-button.tsx new file mode 100644 index 0000000..605036d --- /dev/null +++ b/src/frontend/components/icon-button.tsx @@ -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 ( + + ); +} + +/** Thin vertical divider between groups of icon buttons. */ +export function IconButtonSeparator() { + return