diff --git a/.dockerignore b/.dockerignore index abd6bc2..80054d0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,3 @@ data *.db .env .env.* -# Lokale Werkzeug- und Agenten-Artefakte gehoeren nicht ins Image. -.codex -.agents diff --git a/.gitignore b/.gitignore index e0ef1d2..8d44fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ 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 0ec3242..f1a509d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,13 +167,6 @@ 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 66147e7..41a08fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,56 +1,14 @@ -# 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 +FROM node:22 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 -# --------------------------------------------------------------------------- -# 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"] +RUN mkdir -p data && chmod +x scripts/docker-start.sh + +EXPOSE 3001 + +CMD ["sh", "scripts/docker-start.sh"] diff --git a/README.md b/README.md index 58f1832..c333917 100644 --- a/README.md +++ b/README.md @@ -50,50 +50,27 @@ docker compose up --build --detach Danach: - Frontend: -- 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. +- API-Healthcheck: 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 ``` -`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 +Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein +Produktionsdeployment. 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 24 +- Node.js 22 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml deleted file mode 100644 index b9c64b4..0000000 --- a/compose.dev.yaml +++ /dev/null @@ -1,101 +0,0 @@ -# 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 3438f90..6a5ec7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,104 +1,82 @@ -# 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: - <<: *service-defaults - build: *build - # "exec" beim letzten Schritt: sonst bleibt sh der Vordergrundprozess und - # SIGTERM erreicht node nie. + build: + context: . command: - sh - -c - - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && exec node dist/server/index.js + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api environment: - NODE_ENV: production PORT: "3000" + CHOKIDAR_USEPOLLING: "true" 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 + init: true + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" + ports: + - "3000:3000" volumes: + - ./src:/app/src + - ./scripts:/app/scripts - ./data:/app/data + - ./drizzle.config.ts:/app/drizzle.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://127.0.0.1:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s + - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 5s timeout: 3s - retries: 5 + retries: 12 start_period: 20s web: - <<: *service-defaults - build: *build + build: + context: . command: - - node_modules/.bin/next - - start - - -p - - "3001" + - npm + - run + - dev:web + - -- - --hostname - 0.0.0.0 environment: - NODE_ENV: production API_INTERNAL_URL: http://api:3000 + 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" - mem_limit: 1g - memswap_limit: 1g + 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: - # /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://127.0.0.1:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s + - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 5s timeout: 3s - retries: 5 + retries: 12 start_period: 20s diff --git a/docs/deployment.md b/docs/deployment.md index 909c2c0..dfbb3f1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,57 +2,21 @@ ## Aktueller Status -Es gibt zwei Compose-Stacks. +Es gibt derzeit kein unterstütztes Produktionsdeployment. -`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. +`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.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. +## Entwicklungs-Topologie -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 -``` +| 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` | Verwendete Umgebungsvariablen: @@ -60,22 +24,15 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in - `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale + Dateibeobachtung in Docker - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung -(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im -Entwicklungsstack über `npm run db:migrate` und -`npm run db:verify:circuit-schema`). - -`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. +Beim API-Start laufen zuerst `npm run db:migrate` und +`npm run db:verify:circuit-schema`. ## Logging @@ -111,25 +68,11 @@ 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 new file mode 100644 index 0000000..004045a --- /dev/null +++ b/drizzle.config.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const drizzle_kit_1 = require("drizzle-kit"); +exports.default = (0, drizzle_kit_1.defineConfig)({ + dialect: "sqlite", + schema: "./src/db/schema/*.ts", + out: "./src/db/migrations", + dbCredentials: { + url: "./data/leistungsbilanz.db", + }, +}); diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py deleted file mode 100644 index a3fe009..0000000 --- a/dynamo/01_check_model_identity.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node. - -Optional Dynamo input: - IN[0]: output directory or complete .json file path - -The script intentionally performs no Revit transaction and changes no model -data. OUT contains a compact summary plus the complete report. -""" - -import datetime -import json -import os -import platform -import sys -import tempfile -import traceback - -import clr - -clr.AddReference("RevitAPI") -clr.AddReference("RevitServices") - -from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402 -from RevitServices.Persistence import DocumentManager # noqa: E402 - - -CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId") - - -def safe_text(value): - if value is None: - return None - try: - return str(value) - except Exception: - return None - - -def element_id_text(element_id): - if element_id is None: - return None - try: - return str(element_id.Value) - except Exception: - try: - return str(element_id.IntegerValue) - except Exception: - return safe_text(element_id) - - -def forge_type_id_text(value): - if value is None: - return None - try: - return value.TypeId - except Exception: - return safe_text(value) - - -def parameter_value(parameter): - result = { - "hasValue": False, - "raw": None, - "display": None, - } - try: - result["hasValue"] = bool(parameter.HasValue) - except Exception: - pass - - try: - storage_type = parameter.StorageType - if storage_type == StorageType.String: - result["raw"] = parameter.AsString() - elif storage_type == StorageType.Integer: - result["raw"] = int(parameter.AsInteger()) - elif storage_type == StorageType.Double: - result["raw"] = float(parameter.AsDouble()) - elif storage_type == StorageType.ElementId: - result["raw"] = element_id_text(parameter.AsElementId()) - except Exception as error: - result["readError"] = safe_text(error) - - try: - result["display"] = parameter.AsValueString() - except Exception: - pass - return result - - -def describe_parameter(parameter): - definition = None - try: - definition = parameter.Definition - except Exception: - pass - - name = None - if definition is not None: - try: - name = definition.Name - except Exception: - pass - - is_shared = False - try: - is_shared = bool(parameter.IsShared) - except Exception: - pass - - shared_guid = None - if is_shared: - try: - shared_guid = str(parameter.GUID) - except Exception: - pass - - data_type = None - group_type = None - if definition is not None: - try: - data_type = forge_type_id_text(definition.GetDataType()) - except Exception: - pass - try: - group_type = forge_type_id_text(definition.GetGroupTypeId()) - except Exception: - pass - - unit_type = None - try: - unit_type = forge_type_id_text(parameter.GetUnitTypeId()) - except Exception: - pass - - try: - storage_type = str(parameter.StorageType) - except Exception: - storage_type = None - - try: - is_read_only = bool(parameter.IsReadOnly) - except Exception: - is_read_only = None - - try: - user_modifiable = bool(parameter.UserModifiable) - except Exception: - user_modifiable = None - - return { - "name": name, - "parameterId": element_id_text(getattr(parameter, "Id", None)), - "isShared": is_shared, - "sharedGuid": shared_guid, - "storageType": storage_type, - "dataTypeId": data_type, - "groupTypeId": group_type, - "unitTypeId": unit_type, - "isReadOnly": is_read_only, - "userModifiable": user_modifiable, - "value": parameter_value(parameter), - } - - -def sorted_parameters(element): - parameters = [] - try: - parameters = [describe_parameter(parameter) for parameter in element.Parameters] - except Exception: - return [] - return sorted( - parameters, - key=lambda parameter: ( - (parameter.get("name") or "").casefold(), - parameter.get("parameterId") or "", - ), - ) - - -def named_parameter_occurrences(element, parameter_name): - result = [] - try: - parameters = element.GetParameters(parameter_name) - if parameters is not None: - result = [describe_parameter(parameter) for parameter in parameters] - except Exception: - parameter = None - try: - parameter = element.LookupParameter(parameter_name) - except Exception: - pass - if parameter is not None: - result = [describe_parameter(parameter)] - return result - - -def loaded_assembly_versions(): - result = {} - try: - from System import AppDomain - - for assembly in AppDomain.CurrentDomain.GetAssemblies(): - try: - name = assembly.GetName() - simple_name = str(name.Name) - if simple_name in ( - "DynamoCore", - "DynamoCoreWpf", - "DynamoRevitDS", - "RevitAPI", - "RevitServices", - ): - result[simple_name] = str(name.Version) - except Exception: - continue - except Exception: - pass - return dict(sorted(result.items())) - - -def get_cloud_identity(document): - result = {"isModelInCloud": False} - try: - result["isModelInCloud"] = bool(document.IsModelInCloud) - except Exception: - return result - if not result["isModelInCloud"]: - return result - - try: - model_path = document.GetCloudModelPath() - result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( - model_path - ) - for property_name, output_name in ( - ("GetProjectGUID", "projectGuid"), - ("GetModelGUID", "modelGuid"), - ): - try: - result[output_name] = str(getattr(model_path, property_name)()) - except Exception: - pass - except Exception as error: - result["readError"] = safe_text(error) - return result - - -def get_worksharing_identity(document): - result = {"isWorkshared": False} - try: - result["isWorkshared"] = bool(document.IsWorkshared) - except Exception: - return result - if not result["isWorkshared"]: - return result - - try: - model_path = document.GetWorksharingCentralModelPath() - result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( - model_path - ) - except Exception as error: - result["readError"] = safe_text(error) - return result - - -def resolve_output_path(configured_path, report_name): - timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") - default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") - raw_path = safe_text(configured_path) - if raw_path is None or not raw_path.strip(): - directory = default_directory - file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") - else: - expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) - if expanded.lower().endswith(".json"): - file_path = expanded - directory = os.path.dirname(file_path) - else: - directory = expanded - file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") - if not directory: - directory = os.getcwd() - if not os.path.isdir(directory): - os.makedirs(directory) - return file_path - - -def write_json(file_path, payload): - temporary_path = file_path + ".tmp" - with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: - json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) - output.write("\n") - os.replace(temporary_path, file_path) - - -def get_input(index, default=None): - values = globals().get("IN", []) - try: - value = values[index] - return default if value is None else value - except Exception: - return default - - -def build_report(): - document = DocumentManager.Instance.CurrentDBDocument - if document is None: - raise RuntimeError("No active Revit document is available.") - - project_information = document.ProjectInformation - if project_information is None: - raise RuntimeError("The active document has no Project Information element.") - - application = document.Application - checked_parameters = { - name: named_parameter_occurrences(project_information, name) - for name in CHECKED_PARAMETER_NAMES - } - warnings = [] - for name in CHECKED_PARAMETER_NAMES: - occurrences = checked_parameters[name] - populated = [ - parameter - for parameter in occurrences - if parameter.get("value", {}).get("raw") not in (None, "") - ] - if not occurrences: - warnings.append(name + " is not bound to Project Information.") - elif not populated: - warnings.append(name + " exists but has no value on Project Information.") - elif len(occurrences) > 1: - warnings.append(name + " occurs more than once; use a shared-parameter GUID later.") - - return { - "reportSchemaVersion": 1, - "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "readOnly": True, - "environment": { - "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), - "revitVersionName": safe_text(getattr(application, "VersionName", None)), - "revitSubVersionNumber": safe_text( - getattr(application, "SubVersionNumber", None) - ), - "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), - "pythonVersion": platform.python_version(), - "assemblies": loaded_assembly_versions(), - }, - "document": { - "title": safe_text(document.Title), - "pathName": safe_text(document.PathName), - "isFamilyDocument": bool(document.IsFamilyDocument), - "cloud": get_cloud_identity(document), - "worksharing": get_worksharing_identity(document), - }, - "modelIdentityCandidates": { - "projectInformationUniqueId": safe_text(project_information.UniqueId), - "projectInformationElementId": element_id_text(project_information.Id), - "checkedProjectParameters": checked_parameters, - }, - "projectInformationParameters": sorted_parameters(project_information), - "warnings": warnings, - } - - -try: - report = build_report() - output_path = resolve_output_path(get_input(0), "model-identity") - write_json(output_path, report) - OUT = { - "ok": True, - "filePath": output_path, - "projectInformationUniqueId": report["modelIdentityCandidates"][ - "projectInformationUniqueId" - ], - "warnings": report["warnings"], - "report": report, - } -except Exception as error: - OUT = { - "ok": False, - "error": safe_text(error), - "traceback": traceback.format_exc(), - } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py deleted file mode 100644 index 440a222..0000000 --- a/dynamo/02_export_electrical_fixture_parameter_inventory.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Export all Electrical Fixtures instance/type parameters from Revit 2026. - -Optional Dynamo inputs: - IN[0]: output directory or complete .json file path - IN[1]: include empty parameters (default True) - IN[2]: maximum aggregated sample values (default 5) - -The script is read-only and performs no Revit transaction. -""" - -import datetime -import json -import os -import platform -import sys -import tempfile -import traceback - -import clr - -clr.AddReference("RevitAPI") -clr.AddReference("RevitServices") - -from Autodesk.Revit.DB import ( # noqa: E402 - BuiltInCategory, - FilteredElementCollector, - ModelPathUtils, - StorageType, -) -from RevitServices.Persistence import DocumentManager # noqa: E402 - - -def safe_text(value): - if value is None: - return None - try: - return str(value) - except Exception: - return None - - -def element_id_text(element_id): - if element_id is None: - return None - try: - return str(element_id.Value) - except Exception: - try: - return str(element_id.IntegerValue) - except Exception: - return safe_text(element_id) - - -def forge_type_id_text(value): - if value is None: - return None - try: - return value.TypeId - except Exception: - return safe_text(value) - - -def parameter_value(parameter): - result = {"hasValue": False, "raw": None, "display": None} - try: - result["hasValue"] = bool(parameter.HasValue) - except Exception: - pass - - try: - storage_type = parameter.StorageType - if storage_type == StorageType.String: - result["raw"] = parameter.AsString() - elif storage_type == StorageType.Integer: - result["raw"] = int(parameter.AsInteger()) - elif storage_type == StorageType.Double: - result["raw"] = float(parameter.AsDouble()) - elif storage_type == StorageType.ElementId: - result["raw"] = element_id_text(parameter.AsElementId()) - except Exception as error: - result["readError"] = safe_text(error) - - try: - result["display"] = parameter.AsValueString() - except Exception: - pass - return result - - -def describe_parameter(parameter, scope): - definition = None - try: - definition = parameter.Definition - except Exception: - pass - - name = None - data_type = None - group_type = None - if definition is not None: - try: - name = definition.Name - except Exception: - pass - try: - data_type = forge_type_id_text(definition.GetDataType()) - except Exception: - pass - try: - group_type = forge_type_id_text(definition.GetGroupTypeId()) - except Exception: - pass - - is_shared = False - try: - is_shared = bool(parameter.IsShared) - except Exception: - pass - - shared_guid = None - if is_shared: - try: - shared_guid = str(parameter.GUID) - except Exception: - pass - - unit_type = None - try: - unit_type = forge_type_id_text(parameter.GetUnitTypeId()) - except Exception: - pass - - try: - storage_type = str(parameter.StorageType) - except Exception: - storage_type = None - - try: - is_read_only = bool(parameter.IsReadOnly) - except Exception: - is_read_only = None - - try: - user_modifiable = bool(parameter.UserModifiable) - except Exception: - user_modifiable = None - - return { - "scope": scope, - "name": name, - "parameterId": element_id_text(getattr(parameter, "Id", None)), - "isShared": is_shared, - "sharedGuid": shared_guid, - "storageType": storage_type, - "dataTypeId": data_type, - "groupTypeId": group_type, - "unitTypeId": unit_type, - "isReadOnly": is_read_only, - "userModifiable": user_modifiable, - "value": parameter_value(parameter), - } - - -def has_meaningful_value(parameter_description): - value = parameter_description.get("value", {}) - return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get( - "display" - ) not in (None, "") - - -def read_parameters(element, scope, include_empty): - result = [] - try: - for parameter in element.Parameters: - description = describe_parameter(parameter, scope) - if include_empty or has_meaningful_value(description): - result.append(description) - except Exception as error: - return [], [safe_text(error)] - result.sort( - key=lambda parameter: ( - (parameter.get("name") or "").casefold(), - parameter.get("parameterId") or "", - ) - ) - return result, [] - - -def read_space(element, document): - try: - space = element.Space - except Exception as error: - return None, safe_text(error) - if space is None: - return None, None - - level_name = None - try: - level = document.GetElement(space.LevelId) - level_name = None if level is None else safe_text(level.Name) - except Exception: - pass - - return { - "uniqueId": safe_text(space.UniqueId), - "elementId": element_id_text(space.Id), - "number": safe_text(getattr(space, "Number", None)), - "name": safe_text(getattr(space, "Name", None)), - "levelName": level_name, - }, None - - -def read_family_identity(element, document): - symbol = None - try: - symbol = element.Symbol - except Exception: - try: - symbol = document.GetElement(element.GetTypeId()) - except Exception: - pass - - family_name = None - type_name = None - type_unique_id = None - type_element_id = None - if symbol is not None: - try: - family_name = safe_text(symbol.Family.Name) - except Exception: - family_name = safe_text(getattr(symbol, "FamilyName", None)) - type_name = safe_text(getattr(symbol, "Name", None)) - type_unique_id = safe_text(getattr(symbol, "UniqueId", None)) - type_element_id = element_id_text(getattr(symbol, "Id", None)) - - return { - "familyName": family_name, - "typeName": type_name, - "typeUniqueId": type_unique_id, - "typeElementId": type_element_id, - }, symbol - - -def parameter_inventory_key(parameter): - stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or "" - return "|".join( - ( - parameter.get("scope") or "", - stable_id, - parameter.get("name") or "", - parameter.get("dataTypeId") or "", - ) - ) - - -def sample_value_key(value): - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - - -def add_to_inventory(inventory, parameter, max_samples): - key = parameter_inventory_key(parameter) - entry = inventory.get(key) - if entry is None: - entry = { - "scope": parameter.get("scope"), - "name": parameter.get("name"), - "parameterId": parameter.get("parameterId"), - "isShared": parameter.get("isShared"), - "sharedGuid": parameter.get("sharedGuid"), - "storageType": parameter.get("storageType"), - "dataTypeId": parameter.get("dataTypeId"), - "groupTypeId": parameter.get("groupTypeId"), - "unitTypeId": parameter.get("unitTypeId"), - "occurrenceCount": 0, - "populatedCount": 0, - "sampleValues": [], - "_sampleKeys": set(), - } - inventory[key] = entry - entry["occurrenceCount"] += 1 - if has_meaningful_value(parameter): - entry["populatedCount"] += 1 - value = parameter.get("value") - value_key = sample_value_key(value) - if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]: - entry["_sampleKeys"].add(value_key) - entry["sampleValues"].append(value) - - -def finalize_inventory(inventory): - result = [] - for entry in inventory.values(): - clean_entry = dict(entry) - clean_entry.pop("_sampleKeys", None) - result.append(clean_entry) - return sorted( - result, - key=lambda entry: ( - entry.get("scope") or "", - (entry.get("name") or "").casefold(), - entry.get("sharedGuid") or entry.get("parameterId") or "", - ), - ) - - -def loaded_assembly_versions(): - result = {} - try: - from System import AppDomain - - for assembly in AppDomain.CurrentDomain.GetAssemblies(): - try: - name = assembly.GetName() - simple_name = str(name.Name) - if simple_name in ( - "DynamoCore", - "DynamoCoreWpf", - "DynamoRevitDS", - "RevitAPI", - "RevitServices", - ): - result[simple_name] = str(name.Version) - except Exception: - continue - except Exception: - pass - return dict(sorted(result.items())) - - -def resolve_output_path(configured_path): - timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") - default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") - raw_path = safe_text(configured_path) - if raw_path is None or not raw_path.strip(): - directory = default_directory - file_path = os.path.join( - directory, "electrical-fixture-parameters-" + timestamp + ".json" - ) - else: - expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) - if expanded.lower().endswith(".json"): - file_path = expanded - directory = os.path.dirname(file_path) - else: - directory = expanded - file_path = os.path.join( - directory, "electrical-fixture-parameters-" + timestamp + ".json" - ) - if not directory: - directory = os.getcwd() - if not os.path.isdir(directory): - os.makedirs(directory) - return file_path - - -def write_json(file_path, payload): - temporary_path = file_path + ".tmp" - with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: - json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) - output.write("\n") - os.replace(temporary_path, file_path) - - -def get_input(index, default=None): - values = globals().get("IN", []) - try: - value = values[index] - return default if value is None else value - except Exception: - return default - - -def build_report(include_empty, max_samples): - document = DocumentManager.Instance.CurrentDBDocument - if document is None: - raise RuntimeError("No active Revit document is available.") - if document.IsFamilyDocument: - raise RuntimeError("Open a Revit project document, not a family document.") - - application = document.Application - collector = ( - FilteredElementCollector(document) - .OfCategory(BuiltInCategory.OST_ElectricalFixtures) - .WhereElementIsNotElementType() - ) - source_elements = list(collector) - source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "") - - elements = [] - types_by_unique_id = {} - inventory = {} - errors = [] - elements_without_space = 0 - - for element in source_elements: - element_errors = [] - try: - family_identity, symbol = read_family_identity(element, document) - instance_parameters, parameter_errors = read_parameters( - element, "instance", include_empty - ) - element_errors.extend(parameter_errors) - for parameter in instance_parameters: - add_to_inventory(inventory, parameter, max_samples) - - type_unique_id = family_identity.get("typeUniqueId") - if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id: - type_parameters, type_errors = read_parameters(symbol, "type", include_empty) - element_errors.extend(type_errors) - for parameter in type_parameters: - add_to_inventory(inventory, parameter, max_samples) - types_by_unique_id[type_unique_id] = { - **family_identity, - "parameters": type_parameters, - } - - space, space_error = read_space(element, document) - if space_error: - element_errors.append("MEP Space: " + space_error) - if space is None: - elements_without_space += 1 - - elements.append( - { - "uniqueId": safe_text(element.UniqueId), - "elementId": element_id_text(element.Id), - "categoryName": safe_text( - None if element.Category is None else element.Category.Name - ), - "family": family_identity, - "space": space, - "instanceParameters": instance_parameters, - "warnings": element_errors, - } - ) - except Exception as error: - errors.append( - { - "uniqueId": safe_text(getattr(element, "UniqueId", None)), - "elementId": element_id_text(getattr(element, "Id", None)), - "error": safe_text(error), - } - ) - - return { - "reportSchemaVersion": 1, - "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "readOnly": True, - "complete": len(errors) == 0, - "scope": { - "builtInCategory": "OST_ElectricalFixtures", - "wholeDocument": True, - "elementTypesExcluded": True, - "includeEmptyParameters": include_empty, - }, - "environment": { - "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), - "revitVersionName": safe_text(getattr(application, "VersionName", None)), - "revitSubVersionNumber": safe_text( - getattr(application, "SubVersionNumber", None) - ), - "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), - "pythonVersion": platform.python_version(), - "assemblies": loaded_assembly_versions(), - }, - "document": { - "title": safe_text(document.Title), - "pathName": safe_text(document.PathName), - "projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId), - }, - "summary": { - "elementCount": len(source_elements), - "exportedElementCount": len(elements), - "typeCount": len(types_by_unique_id), - "parameterDefinitionCount": len(inventory), - "elementsWithoutMepSpace": elements_without_space, - "elementErrorCount": len(errors), - }, - "parameterInventory": finalize_inventory(inventory), - "types": sorted( - types_by_unique_id.values(), - key=lambda entry: ( - (entry.get("familyName") or "").casefold(), - (entry.get("typeName") or "").casefold(), - entry.get("typeUniqueId") or "", - ), - ), - "elements": elements, - "errors": errors, - } - - -try: - include_empty_input = get_input(1, True) - include_empty = bool(include_empty_input) - try: - max_samples = int(get_input(2, 5)) - except Exception: - max_samples = 5 - max_samples = max(0, min(max_samples, 50)) - - report = build_report(include_empty, max_samples) - output_path = resolve_output_path(get_input(0)) - write_json(output_path, report) - OUT = { - "ok": True, - "filePath": output_path, - "complete": report["complete"], - "summary": report["summary"], - "errors": report["errors"], - } -except Exception as error: - OUT = { - "ok": False, - "error": safe_text(error), - "traceback": traceback.format_exc(), - } diff --git a/dynamo/README.md b/dynamo/README.md deleted file mode 100644 index 800eded..0000000 --- a/dynamo/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Revit 2026 / Dynamo diagnostics - -This directory contains self-contained Python scripts for a Dynamo **Python -Script** node. They use only Dynamo's built-in Revit integration, the Revit API -and the Python standard library. No Dynamo package is required. - -The scripts are read-only. They do not start a Revit transaction and do not -change the open model. - -## Python engine - -Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with -Revit; optional PythonNet3 packages are not required by these diagnostics. - -## 01 - Check model identity - -File: `01_check_model_identity.py` - -The script reports: - -- Revit, Dynamo and Python versions; -- `ProjectInformation.UniqueId` as a native model-identity candidate; -- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project - Information; -- all Project Information parameters; -- optional cloud/worksharing identity information when the API exposes it. - -Input `IN[0]` is optional. It may be either an output directory or a complete -`.json` file path. With no input, the report is written below the current -Windows temporary directory in `leistungsbilanz-dynamo`. - -## 02 - Inventory Electrical Fixtures parameters - -File: `02_export_electrical_fixture_parameter_inventory.py` - -The script reads every instance of -`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It -exports: - -- element, family, type and MEP Space identities; -- every instance parameter and value; -- every unique family-type parameter and value; -- an aggregated parameter inventory with occurrence counts and sample values; -- per-element warnings instead of aborting at the first unreadable element. - -Inputs: - -- `IN[0]` (optional): output directory or complete `.json` path; -- `IN[1]` (optional): include empty parameters, default `true`; -- `IN[2]` (optional): maximum sample values per aggregated parameter, default - `5`. - -The default output location is again the Windows temporary directory. The -generated report can contain model paths and project-specific parameter values; -review it before sharing or committing it. - -## Running a script - -1. Open the target model in Revit 2026. -2. Open Dynamo from **Manage > Visual Programming > Dynamo**. -3. Create a graph and add a **Python Script** node. -4. Select the `CPython3` engine for the node. -5. Copy the complete content of the desired `.py` file into the node. -6. Optionally connect a String node containing the output path to `IN[0]`. -7. Run the graph and inspect `OUT` for status, counts and the generated path. - -For the first test, run `01_check_model_identity.py` in the Revit main model. -Then run the parameter inventory. Keep both generated JSON files so their -structure can be checked before the production snapshot DTO is finalized. diff --git a/index.html b/index.html new file mode 100644 index 0000000..dcb632f --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + 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 417421b..b0abb41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,15 +22,12 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^24.10.1", + "@types/node": "^25.6.0", "@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": { @@ -1517,13 +1514,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, - "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { @@ -3692,11 +3688,10 @@ } }, "node_modules/undici-types": { - "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" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "devOptional": true }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 414eb53..9bc5ae3 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,6 @@ "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", @@ -13,9 +10,6 @@ "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", @@ -46,7 +40,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^24.10.1", + "@types/node": "^25.6.0", "@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 new file mode 100644 index 0000000..bf0d856 --- /dev/null +++ b/scripts/docker-start.sh @@ -0,0 +1,16 @@ +#!/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 ec92d91..22da5df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -487,54 +487,6 @@ 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); @@ -1201,20 +1153,7 @@ a.kpi:hover { .tree-grid .section-actions { display: flex; - 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; + gap: 0.35rem; } .tree-grid .section-actions button { @@ -1326,7 +1265,7 @@ a.kpi:hover { .tree-grid .action-cell { display: flex; - gap: 0.25rem; + gap: 0.3rem; } .tree-grid .action-cell button { diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts deleted file mode 100644 index ce6f2c4..0000000 --- a/src/app/web-health/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 4d0c97b..02cfc7e 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -7,10 +7,3 @@ 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 eb722d7..da17458 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,7 +14,6 @@ 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"; @@ -120,32 +119,9 @@ 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(values) + .set(assignment.target) .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 cc3d522..03aa38d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,28 +1,6 @@ "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, @@ -3017,37 +2995,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
- void handleUndo()} - showLabel - /> - + Rückgängig + +
+ disabled={!historyState || historyState.redoDepth === 0 || historyBusy || isSaving} + > + Wiederholen + + + + {renderColumnSettingsMenu("col-empty")} +
{renderActiveViewSummary()} {renderDistributionPowerSummary()} {error ? ( @@ -3167,90 +3140,82 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str /> ) : null}
- void handleUndo()} - showLabel + disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} title={ historyState?.undoDepth ? `${historyState.undoDepth} Änderung(en) können rückgängig gemacht werden.` : undefined } - /> - + Rückgängig + + {isSortedView ? ( - void handleApplySortedOrder()} - showLabel + disabled={hasActiveFilters || isSaving || historyBusy} title={hasActiveFilters ? "Vor dem Übernehmen der Sortierung zuerst die Filter zurücksetzen." : undefined} - /> + > + Sortierung übernehmen + ) : null} - - - - + Spalten + + + + + {renderColumnSettingsMenu("col")}
{renderActiveViewSummary()} @@ -3752,21 +3717,105 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
- void handleAddReserveCircuit(section.id)} + + + + + + + +
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( @@ -4505,32 +4467,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str > {row.circuit && row.rowType !== "deviceRow" ? ( <> - void handleAddManualDevice(row.circuit!, row.sectionId)} + > + Manuelles Gerät hinzufügen + + ) : 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 deleted file mode 100644 index 605036d..0000000 --- a/src/frontend/components/icon-button.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"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