commit ed72ce8821c8330dbba29a9807c5d13fcbb102b7 Author: Roland Date: Sun Aug 2 21:28:50 2026 +0000 Initialer Import des AssetManagers diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c8578ad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +.git +.github +.env +.env.* +!.env.example +__pycache__ +*.py[cod] +.venv +venv +.pytest_cache +.mypy_cache +.ruff_cache +dist +*.zip +*.tar +*.tar.gz +*.db +*.sqlite* +*.log +data/postgres +data/config/config.json +data/config/APPINFO.json +data/uploads +data/logs +data/backups +data/backup +data/scripts +app/static/uploads/* +!app/static/uploads/.gitkeep +app/main_old.py +PRUEFEN-0.5.5.5.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..299ee14 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +APP_PORT=8088 +POSTGRES_DB=assetmanager +POSTGRES_USER=assetmanager +POSTGRES_PASSWORD=CHANGE_ME +SESSION_SECRET=CHANGE_ME_LONG_RANDOM +MESHCENTRAL_PASSWORD= +LDAP_BIND_PASSWORD= +LOCAL_ADMIN_USERNAME=emergency-admin +LOCAL_ADMIN_PASSWORD=CHANGE_ME_MIN_12_CHARS +BACKUP_INTERVAL_HOURS=8 +BACKUP_RETENTION_DAYS=3 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1280792 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto eol=lf +*.cmd text eol=crlf +*.bat text eol=crlf +*.ps1 text eol=crlf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.xlsx binary +*.zip binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6da548 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Secrets and local configuration +.env +.env.* +!.env.example +.public-release-local-patterns +data/config/config.json +data/config/APPINFO.json + +# Runtime and user data +data/postgres/* +!data/postgres/.gitkeep +data/uploads/* +!data/uploads/.gitkeep +data/logs/* +!data/logs/.gitkeep +data/backups/* +!data/backups/.gitkeep +data/backup/* +!data/backup/.gitkeep +data/scripts/* +!data/scripts/.gitkeep +app/static/uploads/* +!app/static/uploads/.gitkeep + +# Databases, logs, backups and generated files +*.db +*.sqlite +*.sqlite3 +*.log +*.bak +*.backup +*.tar +*.tar.gz +*.zip +dist/ +THIRD_PARTY_LICENSES/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +venv/ + +# IDE and operating system +.vscode/ +.idea/ +.DS_Store +Thumbs.db diff --git a/.public-release-local-patterns.example b/.public-release-local-patterns.example new file mode 100644 index 0000000..f442796 --- /dev/null +++ b/.public-release-local-patterns.example @@ -0,0 +1,5 @@ +# One private literal per line, case-insensitive. +# Copy this file to .public-release-local-patterns and add organization names, +# internal DNS suffixes, account names and hostnames used in your environment. +internal-company-name +internal-account-name diff --git a/APPINFO.example.json b/APPINFO.example.json new file mode 100644 index 0000000..a7e6c58 --- /dev/null +++ b/APPINFO.example.json @@ -0,0 +1,11 @@ +{ + "name": "AssetManager", + "author": "", + "organization": "", + "contact": "", + "website": "", + "repository": "", + "license": "Apache-2.0", + "copyright": "", + "description": "Asset management with optional MeshCentral integration through MeshCtrl" +} diff --git a/APPINFO.json b/APPINFO.json new file mode 100644 index 0000000..5281c08 --- /dev/null +++ b/APPINFO.json @@ -0,0 +1,11 @@ +{ + "name": "AssetManager", + "author": "Roland Reich", + "organization": "Jusaro systems", + "contact": "", + "website": "", + "repository": "", + "license": "Apache-2.0", + "copyright": "Copyright © 2026 Roland Reich", + "description": "Assetmanagement mit optionaler MeshCentral-Integration über MeshCtrl" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ca1ed15 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Mitwirken am AssetManager + +Beiträge, Fehlerberichte und Forks sind willkommen. + +## Pull Requests + +- Ein Pull Request ist ein Vorschlag. Die Projektpflege entscheidet, ob, wann und in welcher Form er aufgenommen wird. +- Durch das bewusste Einreichen eines Beitrags bestätigst du, dass du zur Veröffentlichung berechtigt bist und der Beitrag unter der Apache License 2.0 lizenziert werden darf. +- Kopiere keinen fremden Quellcode, keine Grafiken und keine Texte ohne nachweisbar kompatible Lizenz und erforderliche Urheber- oder Lizenzhinweise. +- Nenne andere Produkte in öffentlichem Programmcode, Kommentaren, Versionshinweisen und Dokumentationen nur, wenn eine tatsächliche technische Integration, Abhängigkeit oder erforderliche Kompatibilität beschrieben wird. Reine Vergleiche mit anderen Produkten gehören nicht in den veröffentlichten Quellstand. +- Änderungen sollen möglichst auf den betroffenen Bereich begrenzt sein und bestehende Funktionen nicht ohne sachlichen Grund verändern. +- Neue sichtbare Texte müssen vollständig in das vorhandene Übersetzungssystem aufgenommen werden. +- Neue oder geänderte Oberflächen müssen mit dem bestehenden Hell- und Darkmode funktionieren. +- Aktualisiere bei einem Release `VERSION`, `app/version.py`, README und den passenden Versionshinweis. + +## Vor dem Einreichen + +```bash +python tools/check_public_release.py +python tools/check_translations.py +python tools/validate_release.py +``` + +## Drittkomponenten + +Neue Abhängigkeiten müssen vor Aufnahme auf Lizenzkompatibilität, Herkunft, Wartungszustand und Sicherheitsrisiken geprüft werden. Ergänze `THIRD_PARTY_NOTICES.md` und stelle sicher, dass erforderliche Lizenz- oder NOTICE-Dateien im ausgelieferten Produkt erhalten bleiben. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a2b7bc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +LABEL org.opencontainers.image.title="AssetManager" \ + org.opencontainers.image.description="Self-hosted asset management with optional MeshCentral integration" \ + org.opencontainers.image.authors="Roland Reich" \ + org.opencontainers.image.licenses="Apache-2.0" + +WORKDIR /opt/meshcentral +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client \ + && rm -rf /var/lib/apt/lists/* \ + && npm install --omit=dev meshcentral + +WORKDIR /app +COPY requirements.txt . +COPY tools/export_dependency_licenses.py /tmp/export_dependency_licenses.py +RUN pip install --no-cache-dir -r requirements.txt \ + && python /tmp/export_dependency_licenses.py \ + --output /app/THIRD_PARTY_LICENSES \ + --node-modules /opt/meshcentral/node_modules \ + && rm /tmp/export_dependency_licenses.py + +COPY app ./app +COPY VERSION APPINFO.json LICENSE.txt THIRD_PARTY_NOTICES.md ./ +RUN mkdir -p /app/app/static/uploads /app/config /app/data/logs/sync /app/data/logs/diagnostics /data/backups + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/software-callback/health', timeout=3).read()"] + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PRUEFEN-0.5.5.5.sh b/PRUEFEN-0.5.5.5.sh new file mode 100755 index 0000000..fe0212d --- /dev/null +++ b/PRUEFEN-0.5.5.5.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +ROOT="${1:-/srv/docker/assetmanager}" +cd "$ROOT" +echo "VERSION: $(cat VERSION)" +grep -n 'APP_VERSION = "0.5.5.5"' app/version.py +grep -n 'powershell -ExecutionPolicy Bypass -File' app/software_control.py +if sed -n '/def _launch_uploaded_script/,/return _run_meshctrl/p' app/software_control.py | grep -q "action=.*--powershell"; then + echo "FEHLER: _launch_uploaded_script enthält --powershell" >&2 + exit 1 +fi +echo "Dateien auf dem Host sind korrekt." diff --git a/README.md b/README.md new file mode 100644 index 0000000..9668d91 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# AssetManager 0.5.5.27 + +AssetManager ist eine selbst gehostete Webanwendung zur Verwaltung von IT- und sonstigen Betriebsmitteln. Das Projekt wird unter der **Apache License 2.0** veröffentlicht und darf privat sowie kommerziell genutzt, verändert und weitergegeben werden. + +## Funktionsumfang + +- frei konfigurierbare Asset-Kategorien, Felder und Statuswerte +- Assetlisten, Detailansichten, Historie, Ausgabe und Rückgabe +- Excel-Import und -Export, Sammeländerungen, Zusammenführen und Baumansicht +- Hardware- und Softwareinventur +- Jobdefinitionen und Jobausführung mit Status, Callback, Wiederholung und Protokollen +- lokale Benutzerverwaltung und optionale LDAP-Anmeldung +- geschütztes lokales Notfallkonto auch bei aktiviertem LDAP +- Rollen, Sichtbarkeitsregeln, Übersetzungen, Darkmode und anpassbare Darstellung +- integrierte Sicherungs- und Wiederherstellungsfunktionen +- optionale MeshCentral-Anbindung über die MeshCtrl-Kommandozeilenschnittstelle + +## MeshCentral-Integration + +Die Integration ist optional. AssetManager verwendet MeshCtrl für unterstützte Geräteabfragen und Remote-Aufträge. Es erfolgt kein direkter Zugriff auf die MeshCentral-Datenbank. MeshCentral und MeshCtrl sind eigenständige Drittprojekte und nicht Bestandteil dieses Repositorys; sie werden beim Docker-Build als Abhängigkeit installiert. + +## Voraussetzungen + +- Docker Engine mit Docker Compose +- ein für den Server geeigneter DNS-Name und HTTPS-Reverse-Proxy werden für produktive Installationen empfohlen +- optional eine erreichbare MeshCentral-Instanz +- optional LDAP/Active Directory + +## Installation + +```bash +cp .env.example .env +``` + +Ändere in `.env` mindestens: + +```env +POSTGRES_PASSWORD=CHANGE_ME +SESSION_SECRET=CHANGE_ME_LONG_RANDOM +LOCAL_ADMIN_USERNAME=emergency-admin +LOCAL_ADMIN_PASSWORD=CHANGE_ME_MIN_12_CHARS +``` + +Danach: + +```bash +docker compose up -d --build +``` + +Standardmäßig ist die Anwendung unter Port `8088` erreichbar. + +## Persistente Daten + +Die Laufzeitdaten werden über Bind-Mounts unter `data/` gespeichert: + +```text +data/ +├── config/ Anwendungskonfiguration +├── postgres/ PostgreSQL-Datenbank +├── uploads/ hochgeladene Dateien +├── logs/ Protokolle und Diagnosedaten +├── backups/ Anwendungssicherungen +└── scripts/ bereitgestellte Skripte +``` + +Diese Inhalte sowie `.env` enthalten möglicherweise Zugangsdaten oder personenbezogene beziehungsweise betriebliche Daten und dürfen nicht in ein öffentliches Repository aufgenommen werden. + +## Aktualisierung + +Vor einer Aktualisierung immer `.env` und den gesamten Ordner `data/` sichern. Danach den neuen Quellstand über den bestehenden Projektordner kopieren und ausführen: + +```bash +docker compose down +docker compose up -d --build +docker compose logs --tail=100 -f +``` + +## Öffentlichen Quellstand erzeugen + +Der Exporter erstellt ein bereinigtes ZIP mit genau einem Hauptordner `assetmanager/`: + +```bash +python tools/create_github_source.py +``` + +Vor dem Export prüft das Skript unter anderem Versionen, Lizenzangaben, vertrauliche Laufzeitdateien und unerwünschte Produktvergleiche. + +## Prüfungen + +```bash +python tools/check_public_release.py +python tools/check_translations.py +python tools/validate_release.py +``` + +## Drittkomponenten + +Die direkten Abhängigkeiten und ihre Lizenztypen sind in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) aufgeführt. Beim Docker-Build werden zusätzlich die Lizenzdateien der tatsächlich installierten Python- und Node.js-Pakete unter `/app/THIRD_PARTY_LICENSES/` gesammelt. + +## Mitwirken + +Beiträge und Forks sind willkommen. Hinweise zur Einreichung stehen in [CONTRIBUTING.md](CONTRIBUTING.md). Eine Einreichung ist ein Vorschlag; es besteht kein Anspruch auf Aufnahme in das offizielle Projekt. + +## Sicherheit + +Sicherheitsprobleme bitte nicht mit vertraulichen Details in einer öffentlichen Issue melden. Siehe [SECURITY.md](SECURITY.md). + +## Lizenz + +Copyright © 2026 Roland Reich + +Lizenziert unter der Apache License, Version 2.0. Der vollständige Lizenztext befindet sich in [LICENSE.txt](LICENSE.txt). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dd93381 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Sicherheitsrichtlinie + +## Unterstützte Version + +Sicherheitskorrekturen werden grundsätzlich für den aktuellen veröffentlichten Stand vorgesehen. Ältere Versionen sollten vor einer detaillierten Fehleranalyse zunächst aktualisiert werden. + +## Schwachstellen melden + +Bitte veröffentliche keine Zugangsdaten, Tokens, personenbezogenen Daten, internen Adressen oder ausnutzbaren Details in einer öffentlichen GitHub-Issue. + +Nutze nach Möglichkeit die private Sicherheitsmeldung des GitHub-Repositorys. Falls diese Funktion nicht aktiviert ist, verwende den im Repository hinterlegten privaten Kontakt und gib nur die zur Reproduktion notwendigen Informationen weiter. + +Eine Meldung sollte enthalten: + +- betroffene Version +- betroffene Funktion +- nachvollziehbare Reproduktionsschritte +- mögliche Auswirkungen +- vorhandene Protokollauszüge ohne Geheimnisse + +Tests dürfen nur an Systemen durchgeführt werden, für die eine ausdrückliche Berechtigung besteht. diff --git a/SOFTWARE-CLIENT-ARCHITECTURE.md b/SOFTWARE-CLIENT-ARCHITECTURE.md new file mode 100644 index 0000000..2920563 --- /dev/null +++ b/SOFTWARE-CLIENT-ARCHITECTURE.md @@ -0,0 +1,182 @@ +# Vorschlag: Client-Kommunikation und spätere Softwareverteilung + +## Zielbild + +Ein kleiner Windows-Client („AssetManager Agent“) läuft auf jedem verwalteten +Rechner. Das Netlogon-/GPO-Startskript installiert oder aktualisiert nur diesen +Agenten. Die eigentlichen Inventur- und Softwareaufträge werden anschließend +über eine abgesicherte HTTPS-API gesteuert. + +## Warum nicht jeden Befehl direkt im Netlogon-Skript ausführen? + +Ein Startskript ist gut für Bootstrap und Reparatur, aber ungeeignet für eine +dauerhafte Auftragssteuerung: + +- Benutzer- und Computerstart verzögern sich. +- Rückmeldungen und Wiederholungen sind schwer nachzuvollziehen. +- Ein Netzlaufwerk oder DC kann beim Start noch nicht erreichbar sein. +- Softwareinstallationen benötigen Status, Timeout, Exitcode und Neustartlogik. +- Passwörter oder globale API-Schlüssel dürfen nicht im Skript stehen. + +## Empfohlene Komponenten + +### 1. Bootstrap per GPO/Netlogon + +Das bestehende Startskript: + +1. legt `C:\ProgramData\AssetManager\agent` an, +2. lädt eine signierte Agent-Version von einer internen HTTPS-Adresse, +3. installiert einen Windows-Dienst, +4. schreibt nur Server-URL und eine einmalige Registrierungskennung, +5. startet den Dienst. + +### 2. Geräteidentität + +Beim ersten Start registriert sich der Agent mit: + +- Computername, +- AD-Domäne, +- BIOS-Seriennummer, +- Windows MachineGuid, +- optional MeshCentral Node-ID. + +Der Server erstellt danach ein gerätespezifisches Token. Dieses Token wird +lokal mit Windows-DPAPI geschützt. Kein gemeinsamer API-Schlüssel für alle +Clients. + +### 3. Agent-Polling + +Der Client fragt beispielsweise alle 5 Minuten per HTTPS ab: + +`GET /api/agent/v1/jobs/next` + +Antwort bei keinem Auftrag: + +```json +{"job": null, "next_poll_seconds": 300} +``` + +Antwort bei einem Auftrag: + +```json +{ + "job": { + "id": 4711, + "type": "software_inventory", + "expires_at": "2026-07-22T23:00:00Z", + "payload": {} + } +} +``` + +Der Client bestätigt zuerst die Übernahme und meldet später Ergebnis, +Exitcode, Logauszug und Zeitstempel zurück. + +### 4. Softwareinventur + +Erste Funktion des Agenten: + +- installierte MSI-Programme aus 32- und 64-Bit-Registry auslesen, +- optional AppX-Pakete, +- Name, Version, Hersteller, Installationsdatum und UninstallString erfassen, +- Ergebnis komprimiert an AssetManager senden, +- serverseitig pro Asset versioniert speichern. + +Nicht `Win32_Product` verwenden, da diese WMI-Klasse MSI-Reparaturen auslösen +und sehr langsam sein kann. + +### 5. Softwarekatalog für spätere Verteilung + +Serverseitige Tabellen: + +- `software_packages` +- `software_package_versions` +- `software_jobs` +- `software_job_results` +- `agent_devices` +- `agent_tokens` +- `agent_inventory_runs` + +Ein Softwarepaket enthält: + +- Name und Version, +- Installationsquelle, +- SHA-256-Hash, +- Installationsbefehl, +- Deinstallationsbefehl, +- Erkennungsregel, +- erlaubte Exitcodes, +- Neustartverhalten, +- Timeout, +- Zielarchitektur. + +### 6. Installationsquelle + +Pakete können zunächst weiter auf dem DC liegen, zum Beispiel: + +`\\fileserver\software\packages\ExampleApp\1.0\` + +Der Auftrag enthält aber nicht beliebigen PowerShell-Code, sondern verweist +auf eine freigegebene Paketdefinition. Der Agent prüft: + +1. Paket ist serverseitig freigegeben, +2. Quelle liegt in einem erlaubten UNC-Basispfad, +3. Datei-Hash stimmt, +4. Installationsbefehl entspricht der Paketdefinition. + +Langfristig ist ein interner HTTPS-Paketdownload robuster als UNC, weil er +ohne Maschinenzugriff auf die Freigabe funktioniert und leichter protokolliert +werden kann. + +### 7. Sicherheitsregeln + +- ausschließlich HTTPS mit intern vertrauenswürdigem Zertifikat, +- pro Gerät eigenes widerrufbares Token, +- Jobs serverseitig signieren oder über authentisierte TLS-Verbindung liefern, +- keine freie Shell-/PowerShell-Eingabe über die Weboberfläche, +- Befehle nur aus fest definierten Jobtypen, +- Paketdateien mit SHA-256 prüfen, +- vollständiges Auditlog, +- Rollen/Berechtigungen für Freigabe und Ausführung, +- maximale Joblaufzeit und Wiederholungsgrenze, +- Agent nur als SYSTEM, wenn der konkrete Job dies benötigt. + +## Sinnvolle Ausbaustufen + +### Phase 1 – Inventur + +- Agent registrieren +- Heartbeat und letzter Kontakt +- Betriebssystem- und Softwareinventur +- Anzeige im Assetdetail +- manueller Inventurauftrag + +### Phase 2 – Auftragsmodell + +- generische Jobwarteschlange +- Status: pending, claimed, running, success, failed, expired +- Exitcodes, Logs, Timeout und Wiederholung +- Live-Anzeige im AssetManager + +### Phase 3 – Softwarekatalog + +- Paketdefinitionen +- Erkennungsregeln +- Installation und Deinstallation +- Einzelgerät und Gerätegruppe +- Wartungsfenster + +### Phase 4 – Rollout-Sicherheit + +- Pilotgruppen +- Genehmigungsworkflow +- gestaffelte Verteilung +- Abbruch bei Fehlerquote +- Neustartkoordination + +## Empfehlung für den nächsten Entwicklungsschritt + +Zuerst nur Phase 1 umsetzen. Der Agent sollte noch keine beliebigen Befehle +ausführen. Wenn Registrierung, Heartbeat und Softwareinventur stabil laufen, +kann dieselbe gesicherte Kommunikation für die Jobwarteschlange und später +die Softwareverteilung verwendet werden. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..6328cff --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Drittkomponenten und Lizenzhinweise + +AssetManager wird unter der Apache License 2.0 veröffentlicht. Die folgenden Komponenten sind eigenständige Drittprojekte und unterliegen ihren jeweiligen Lizenzen. + +## Direkt verwendete Komponenten + +| Komponente | Verwendung | Lizenz laut Upstream | +|---|---|---| +| FastAPI | Webframework | MIT | +| Uvicorn | ASGI-Server | BSD-3-Clause | +| SQLAlchemy | Datenbankabstraktion | MIT | +| Psycopg / psycopg-binary | PostgreSQL-Treiber | LGPL-3.0-only | +| Jinja | HTML-Templates | BSD-3-Clause | +| python-multipart | Formular- und Upload-Verarbeitung | Apache-2.0 | +| pydantic-settings | Konfiguration aus Umgebungsvariablen | MIT | +| itsdangerous | Signierte Sitzungsdaten | BSD-3-Clause | +| ldap3 | optionale LDAP-Anbindung | LGPL-3.0-or-later | +| Passlib | Passwort-Hashing | BSD-artige Lizenz; enthält zusätzliche Hinweise für eingebundene Bestandteile | +| bcrypt | durch `passlib[bcrypt]` verwendete Hashing-Bibliothek | Apache-2.0 | +| openpyxl | Excel-Import und -Export | MIT | +| MeshCentral / MeshCtrl | optionale Integration und Remote-Aufträge | Apache-2.0 | +| PostgreSQL Container-Image | Datenbankdienst | enthält PostgreSQL und weitere Betriebssystempakete mit eigenen Lizenzen | +| Python Container-Image | Laufzeitbasis | enthält Python und Betriebssystempakete mit eigenen Lizenzen | + +## Transitive Abhängigkeiten + +Extras wie `uvicorn[standard]`, `psycopg[binary]` und `passlib[bcrypt]` installieren zusätzliche Pakete. Auch Node.js-Pakete können weitere Abhängigkeiten mitbringen. Maßgeblich sind immer die Lizenztexte der tatsächlich gebauten und ausgelieferten Versionen. + +Der Docker-Build führt deshalb `tools/export_dependency_licenses.py` aus. Die gefundenen Lizenz-, NOTICE-, COPYING- und Copyright-Dateien werden im Image unter `/app/THIRD_PARTY_LICENSES/` abgelegt. Dieser Ordner sollte bei einer Weitergabe eines gebauten Images oder Offline-Pakets erhalten bleiben. + +## Keine Rechteübertragung an Drittprojekten + +Produktnamen und Marken der Drittprojekte gehören ihren jeweiligen Inhabern. Die Nennung beschreibt ausschließlich die technische Verwendung oder Integration und bedeutet keine Zugehörigkeit, Unterstützung oder Zertifizierung durch diese Projekte. + +## Prüfung bei Änderungen + +Bei jeder neuen oder aktualisierten Abhängigkeit müssen Lizenz und erforderliche Hinweise erneut geprüft werden. Automatisch gesammelte Metadaten ersetzen keine Prüfung der Original-Lizenztexte. diff --git a/UPDATE-0.2.6.1.txt b/UPDATE-0.2.6.1.txt new file mode 100644 index 0000000..5b2f51d --- /dev/null +++ b/UPDATE-0.2.6.1.txt @@ -0,0 +1,14 @@ +AssetManager 0.2.6.1 – MeshCentral Detailimport korrigiert + +Änderung: +- ListDevices --details liefert Datensätze in der Form {"node": {...}, "sys": {...}, "net": {...}}. +- Die Node-ID wird nun korrekt aus node._id gelesen. +- Hardware-, Netzwerk- und Systemfelder werden aus der Detailstruktur übernommen. +- Getestet gegen eine reale MeshCtrl-Datei mit 60 Geräten: 60/60 Node-IDs erkannt. + +Nicht enthalten und daher nicht überschrieben: +- .env +- data/config/config.json +- Datenbankdaten +- Uploads +- Logs diff --git a/UPDATE-0.2.7.1.txt b/UPDATE-0.2.7.1.txt new file mode 100644 index 0000000..9989ce9 --- /dev/null +++ b/UPDATE-0.2.7.1.txt @@ -0,0 +1,6 @@ +AssetManager 0.2.7.1 + +- Dashboard-Tabelle „Zuletzt geändert“ verwendet jetzt die unter „In Liste“ aktivierten Kategoriefelder. +- Die ungefilterte Assetliste verwendet ebenfalls die konfigurierten Listenspalten statt vier fest codierter Spalten. +- Gefilterte Assetlisten behalten die konfigurierte Reihenfolge der Kategoriefelder bei. +- .env und data/config/config.json sind nicht Bestandteil dieses Updates. diff --git a/UPDATE-0.2.7.2.txt b/UPDATE-0.2.7.2.txt new file mode 100644 index 0000000..22f1a39 --- /dev/null +++ b/UPDATE-0.2.7.2.txt @@ -0,0 +1,6 @@ +AssetManager 0.2.7.2 + +- Assetname bleibt in jeder Assetliste sichtbar und ist immer mit der Detailansicht verlinkt. +- Falls das Feld Bezeichnung in der Kategorie nicht als Listenfeld aktiviert ist, wird es automatisch als erste Link-Spalte ergänzt. +- Der Hauptinhalt nutzt die gesamte verfügbare Browserbreite. +- .env und data/config/config.json sind nicht Bestandteil dieses Updates. diff --git a/UPDATE-0.2.7.txt b/UPDATE-0.2.7.txt new file mode 100644 index 0000000..81b8624 --- /dev/null +++ b/UPDATE-0.2.7.txt @@ -0,0 +1,22 @@ +AssetManager Update 0.2.7 + +Enthalten: +- neue MeshCentral-Felder: Datentraeger, GPU, TPM, RAM-Module, Virenschutz, Gruppe +- MAC-Adresse aus dem aktiven IPv4-Netzwerkeintrag +- robuste Kategorie-Feldspeicherung ohne UniqueConstraint-Fehler +- verständliche Fehlermeldungen bei doppelten eindeutigen Asset-Werten +- konfigurierbare Listenspalten über Kategorie -> In Liste +- Spaltenreihenfolge per Drag-and-drop, Speicherung im Browser + +Nicht enthalten und wird nicht überschrieben: +- .env +- data/config/config.json +- docker-compose.yml +- Datenbank, Uploads und Logs + +Installation: +1. Inhalt über das bestehende Projekt kopieren. +2. docker compose down +3. docker compose build --no-cache app +4. docker compose up -d +5. MeshCentral-Synchronisierung erneut starten. diff --git a/UPDATE-0.2.8.txt b/UPDATE-0.2.8.txt new file mode 100644 index 0000000..c9455b0 --- /dev/null +++ b/UPDATE-0.2.8.txt @@ -0,0 +1,18 @@ +AssetManager 0.2.8 + +Enthalten: +- Beim Anlegen und Bearbeiten werden nur aktive Felder der gewählten Kategorie angezeigt. +- Kategorie-Wechsel in der Bearbeitungsansicht lädt die Feldkonfiguration der Zielkategorie. +- Separate Tabelle asset_history für manuelle Änderungen und MeshCentral-Synchronisierungen. +- Historienansicht je Asset mit Feld, altem Wert und neuem Wert. +- Enthält-Filter über jeder Spalte in Tabellen. +- Sortierung durch Klick auf Tabellenüberschriften. +- Excel-Export der Assetliste und der Asset-Historie. + +Nicht enthalten: +- .env +- data/config/config.json +- docker-compose.yml +- Datenbank, Uploads oder Logs + +Benutzerverwaltung/LDAP wird bewusst in einem separaten Update umgesetzt. diff --git a/UPDATE-0.2.9.1.txt b/UPDATE-0.2.9.1.txt new file mode 100644 index 0000000..4869264 --- /dev/null +++ b/UPDATE-0.2.9.1.txt @@ -0,0 +1,15 @@ +AssetManager v0.2.9.1 +===================== + +Korrekturen: +- Speichern eines duplizierten Assets sendet nun ausdrücklich an POST /assets/new. +- Browserfehler werden nicht mehr als rohe JSON-Seite angezeigt. +- HTTP- und Anwendungsfehler werden nach data/logs/errors.log geschrieben. +- Fehlermeldungen erscheinen als automatisch ausblendender Hinweis oben rechts. +- API-/JSON-Anfragen behalten weiterhin JSON-Antworten. + +Nicht enthalten und nicht überschrieben: +- .env +- data/config/config.json +- docker-compose.yml +- Datenbankdaten, Uploads oder Logs diff --git a/UPDATE-0.2.9.txt b/UPDATE-0.2.9.txt new file mode 100644 index 0000000..4f24bc2 --- /dev/null +++ b/UPDATE-0.2.9.txt @@ -0,0 +1,11 @@ +AssetManager 0.2.9 + +- Sortierung korrigiert: Es wird jetzt zuverlässig nach der angeklickten Spalte sortiert. +- Tabellenköpfe und Filterzeile bleiben beim Scrollen sichtbar. +- Aktionsspalte mit Ausgeben/Zurücknehmen, Duplizieren und Löschen. +- Duplizieren öffnet einen neuen Datensatz mit übernommenen Werten; eindeutige IDs werden geleert. +- Status ist ein pflegbares Dropdown mit Standardwerten Aktiv, Defekt, im Lager, Ausgegeben und Verschrottet. +- Neues Datumsfeld assigned_on / Ausgegeben am. +- Ausgeben setzt Status und aktuelles Datum; Zurücknehmen setzt Status auf im Lager und leert Zuordnung/Ausgabedatum. +- Aktionen werden in der Asset-Historie protokolliert. +- .env und data/config/config.json sind nicht enthalten. diff --git a/UPDATE-0.3.0.txt b/UPDATE-0.3.0.txt new file mode 100644 index 0000000..6eea81d --- /dev/null +++ b/UPDATE-0.3.0.txt @@ -0,0 +1,11 @@ +AssetManager v0.3.0 + +- Sortierung nach der tatsächlich angeklickten Spalte korrigiert. +- Robuste Sortierung für Text, reine Zahlen sowie ISO- und deutsche Datumswerte. +- Breite Tabellen bleiben responsiv und erhalten bei Bedarf horizontale Scrollbalken. +- Einstellungen-Seite für Anwendungstitel, Firmenname und Firmenlogo. +- Branding wird in Titelzeile und Browser-Titel verwendet. +- Benutzer-Dropdown rechts in der Navigation vorbereitet. +- Authentifizierungsmodus in der Config vorbereitet (Standard: none). + +Die vorhandene config.json wird nicht im ZIP ausgeliefert. Änderungen über die GUI werden gezielt in den Abschnitten general/authentication gespeichert; andere Config-Bereiche bleiben erhalten. diff --git a/UPDATE-0.3.1.txt b/UPDATE-0.3.1.txt new file mode 100644 index 0000000..9d3b764 --- /dev/null +++ b/UPDATE-0.3.1.txt @@ -0,0 +1,26 @@ +AssetManager v0.3.1 + +Enthalten: +- Firmenname wird neben dem Anwendungstitel angezeigt. +- IPv4-Adressen werden oktettweise sortiert. +- Benutzerverwaltung mit lokalen Konten. +- Anmeldung/Abmeldung und Profilseite. +- LDAP-/Active-Directory-Anmeldung. +- LDAP-Benutzer werden nach erfolgreicher erster Anmeldung lokal angelegt/aktualisiert. +- Benutzername wird bei manuellen Asset-Änderungen in der Historie gespeichert. +- Noch keine Berechtigungsprüfung: Die Anmeldung schränkt die Anwendung in dieser Version nicht ein. + +Konfiguration: +- Anmeldemodus und LDAP-Daten werden unter Einstellungen gepflegt. +- LDAP-Bind-Passwort bleibt in der Umgebung, standardmäßig: + LDAP_BIND_PASSWORD=... +- Für produktive Sitzungen empfohlen: + SESSION_SECRET= + +Nicht enthalten: +- .env +- data/config/config.json +- config.json +- Datenbankdaten +- Uploads +- Logs diff --git a/UPDATE-0.3.10.0.txt b/UPDATE-0.3.10.0.txt new file mode 100644 index 0000000..c751c2a --- /dev/null +++ b/UPDATE-0.3.10.0.txt @@ -0,0 +1,29 @@ +# AssetManager v0.3.10.0 + +## Übersetzungen +- Fehlende deutsche und englische Schlüssel für Assetliste, Kategorien, + Excel-Import, Favicon, Spaltensteuerung, Datensatzzähler und LDAP ergänzt. +- LDAP-Suchseite vollständig zweisprachig neu bereitgestellt. +- Dynamische Zähler und Schaltflächen verwenden weiterhin die gewählte Sprache. + +## Info-Dialog und Versionsnummer +- Fragezeichen-Schaltfläche im Kopfbereich. +- Dialog zeigt Version, Autor, Organisation, Kontakt, Webseite, Repository, + Lizenz und Copyright. +- Alle Angaben außer der Version werden unter Einstellungen gepflegt. +- Die Version wird ausschließlich aus der Datei `VERSION` gelesen. +- Aktuelle Version: 0.3.10.0. + +## Scrollleiste +- Horizontale Scrollleisten der Assetliste auf 20 Pixel vergrößert. +- Größerer, kontrastreicher Scrollgriff. + +## Softwareverteilung +- `SOFTWARE-CLIENT-ARCHITECTURE.md` beschreibt den vorbereitenden Aufbau für + Agent, Inventur, Jobwarteschlange und spätere Softwareverteilung. + +## Validierung +- Python-Syntaxprüfung durchgeführt. +- Jinja2-Templates syntaktisch geprüft. +- VERSION-Datei und ZIP-Wurzel geprüft. +- Kein produktiver Docker-/PostgreSQL-Lauf durchgeführt. diff --git a/UPDATE-0.3.11.0.txt b/UPDATE-0.3.11.0.txt new file mode 100644 index 0000000..06c95d1 --- /dev/null +++ b/UPDATE-0.3.11.0.txt @@ -0,0 +1,14 @@ +# AssetManager v0.3.11.0 + +Erste MeshCentral-basierte Softwareinventur: +- kein zusätzlicher Agent +- MeshCtrl RunCommand über vorhandenen MeshCentral-Agenten +- HTTPS-Callback mit pro Job zufälligem, gehasht gespeichertem und zeitlich begrenztem Token +- Inline-Befehle; keine Inventurskripte werden lokal abgelegt +- Windows-, Linux- und macOS-Inventur +- Android wird erkannt, aber mangels vergleichbarem RunCommand-Kanal sicher abgelehnt +- lokale fortgeschriebene Client-Logdatei und Übertragung des Job-Logabschnitts per Callback +- separate Tabelle installed_software und Anzeige je Asset +- Grundtabellen für Softwarepakete, gerätebezogene Parameter und Jobs + +Nur Syntax und Paketstruktur geprüft. Nicht mit echtem MeshCentral, Clients oder PostgreSQL produktiv getestet. diff --git a/UPDATE-0.3.12.0.txt b/UPDATE-0.3.12.0.txt new file mode 100644 index 0000000..e801f52 --- /dev/null +++ b/UPDATE-0.3.12.0.txt @@ -0,0 +1,32 @@ +# AssetManager v0.3.12.0 + +## Softwareinventur starten +- Der vorhandene Ablauf bleibt: + Asset öffnen → Software → Software-Inventur starten. +- Jobs sind zusätzlich unter Software in der Hauptnavigation sichtbar. + +## Excel-Testimport +- Zweiter Button „Test-Import“. +- Verwendet dieselbe Import-, Validierungs- und Zeilenausgabelogik. +- Nach der Verarbeitung wird die vollständige Datenbanktransaktion zurückgerollt. +- Ergebnis zeigt dieselben Zähler, Fehler und Meldungen wie ein echter Import. + +## Versionsanzeige +- Zusätzliche eingebettete Version in `app/version.py`. +- `VERSION` wird weiterhin bevorzugt. +- Fehlt die Root-Datei im Docker-Image, erscheint nicht mehr „unknown“. + +## Duplikatsuche in der Assetliste +- Neuer Button „Duplikate suchen“. +- Auswahl eines aktuell sichtbaren Tabellenfeldes. +- Auswertung berücksichtigt die aktuell gesetzten Tabellenfilter. +- Leere Werte und Groß-/Kleinschreibung sind konfigurierbar. +- Nur die gefundenen Dubletten bleiben in der Liste sichtbar. +- Dublettenfilter kann wieder aufgehoben werden. + +## Validierung +- Python-Syntaxprüfung durchgeführt. +- Jinja2-Templates syntaktisch geprüft. +- JavaScript-Datei strukturell geprüft. +- ZIP-Wurzel `assetmanager/` geprüft. +- Kein produktiver Docker-/PostgreSQL-/MeshCentral-Test durchgeführt. diff --git a/UPDATE-0.3.12.1.txt b/UPDATE-0.3.12.1.txt new file mode 100644 index 0000000..84b9af9 --- /dev/null +++ b/UPDATE-0.3.12.1.txt @@ -0,0 +1,26 @@ +# AssetManager v0.3.12.1 + +## Duplikatsuche +- JavaScript-Fehler `table is not defined` behoben. +- Asset-Auswahl, Tabellenfilter und Duplikatsuche können wieder initialisiert werden. + +## MeshCentral-Feldregeln +- Datenbankwerte sind jetzt die dauerhafte Quelle der Wahrheit. +- Startup-Seeding legt nur fehlende Standard-Mappings an. +- Bestehende Werte für Quelle, Transform, Aktivierung, Priorität und + `update_rule` („wer gewinnt“) werden nicht mehr überschrieben. +- Legacy-Werte aus config.json werden nur bei fehlender Datenbankkonfiguration übernommen. + +## Softwareinventur-Diagnose +- Öffentlicher, schreibgeschützter Health-Endpunkt: + `/api/software-callback/health` +- Softwarejob zeigt den lokal erwarteten Logpfad. +- Der tatsächlich an MeshCtrl übergebene Inline-Befehl kann für einen + manuellen Test kopiert werden. +- Windows-Clientlog protokolliert den Callback-Versuch und Callback-Fehler ausführlicher. + +## Validierung +- Python-Syntaxprüfung durchgeführt. +- Jinja2-Templates syntaktisch geprüft. +- ZIP-Struktur geprüft. +- Kein realer MeshCentral-/Callback-/PostgreSQL-Test durchgeführt. diff --git a/UPDATE-0.3.13.0.txt b/UPDATE-0.3.13.0.txt new file mode 100644 index 0000000..455096b --- /dev/null +++ b/UPDATE-0.3.13.0.txt @@ -0,0 +1,16 @@ +# AssetManager v0.3.13.0 + +- Markierte Duplikate können zusammengeführt werden. +- Reihenfolge per Drag-and-drop. +- Oberstes Asset bleibt bestehen. +- Leere Werte werden aus nachfolgenden Assets ergänzt. +- Vorschau vor dem endgültigen Speichern. +- Custom Fields, Historie, Softwarejobs, installierte Software und Kind-Assets werden übernommen. +- Technische Bulk-Formulare sind ausgeblendet und erzeugen keinen Leerraum mehr. +- v0.4.0.0 bleibt für das neue Software-Subsystem reserviert. + +Validierung: +- Python-Syntax geprüft +- Jinja2-Templates geprüft +- ZIP-Struktur geprüft +- nicht produktiv gegen PostgreSQL/Docker getestet diff --git a/UPDATE-0.3.13.1.txt b/UPDATE-0.3.13.1.txt new file mode 100644 index 0000000..9b58229 --- /dev/null +++ b/UPDATE-0.3.13.1.txt @@ -0,0 +1,13 @@ +# AssetManager v0.3.13.1 + +## Korrekturen +- Der Button „Duplikate zusammenführen“ wird ab zwei sichtbaren, markierten Assets aktiviert. +- Die Anzahl der markierten Assets wird im Button angezeigt. +- Kategorie-Filter können mehrzeilig umbrechen, ohne aus dem Button herauszuragen. +- Kategorie-Karten auf der Übersicht begrenzen und umbrechen lange Bezeichnungen innerhalb der Karte. + +## Validierung +- Python-Syntax geprüft. +- Jinja2-Templates syntaktisch geladen. +- ZIP-Wurzel assetmanager/ geprüft. +- Kein Docker-/Browser-/PostgreSQL-Laufzeittest durchgeführt. diff --git a/UPDATE-0.3.14.0.txt b/UPDATE-0.3.14.0.txt new file mode 100644 index 0000000..8304d02 --- /dev/null +++ b/UPDATE-0.3.14.0.txt @@ -0,0 +1,33 @@ +# AssetManager v0.3.14.0 – Bedienung & Oberfläche + +## Navigation +- Neue linke Hauptnavigation. +- Einklappbar auf eine schmale Symbolleiste. +- Zustand wird im Browser gespeichert. +- Einstellungen öffnen beim Mouseover ein alphabetisch sortiertes Untermenü. +- Einstellungsseiten besitzen zusätzlich eine eigene linke Unternavigation. + +## Systeminformationen +- Neue Datei APPINFO.json im Projektstamm. +- VERSION bleibt separat. +- Autor, Organisation, Kontakt, Website, Repository, Lizenz und Copyright sind nur lesbar. +- Info-Dialog und Systeminformationsseite lesen dieselbe Datei. +- LICENSE.txt dokumentiert den aktuellen Lizenzstatus. + +## Einstellungsbereiche +- Anzeige +- Asset-Felder +- Authentifizierung +- Benutzer +- Kategorien +- MeshCentral +- Statuswerte +- Systeminformationen +- Übersetzungen + +## Validierung +- Python-Syntax geprüft. +- Jinja2-Templates syntaktisch geladen. +- APPINFO.json als JSON geprüft. +- ZIP-Wurzel assetmanager/ geprüft. +- Kein Docker-, Browser- oder PostgreSQL-Laufzeittest durchgeführt. diff --git a/UPDATE-0.3.14.1.txt b/UPDATE-0.3.14.1.txt new file mode 100644 index 0000000..ee593b5 --- /dev/null +++ b/UPDATE-0.3.14.1.txt @@ -0,0 +1,21 @@ +# AssetManager v0.3.14.1 – Korrekturen Navigation und Einstellungen + +## Behoben +- Kein zweites Einstellungsmenü mehr innerhalb der Einstellungsseite. +- `/settings/fields` wird nicht mehr vom dynamischen Einstellungsrouting abgefangen. +- „Felder“ öffnet wieder die vollständige Feldverwaltung. +- „MeshCentral“ öffnet wieder die vollständige MeshCentral-Seite unter `/sync/meshcentral`. +- „Benutzeranmeldung“ wurde im Menü in „LDAP“ umbenannt. +- Import / Export ist als eigener Einstellungsbereich vorhanden. +- Sicherung ist als eigener Einstellungsbereich sichtbar. + +## Hinweis Sicherung +Die vollständige Backup-Funktion bleibt wie geplant für v0.3.15.0 reserviert. +In v0.3.14.1 wird der Menüpunkt bereits angezeigt und beschreibt den vorgesehenen Umfang. + +## Validierung +- Python-Syntax geprüft. +- Alle geänderten Jinja2-Templates syntaktisch geladen. +- Relevante Routen statisch geprüft. +- ZIP-Wurzel `assetmanager/` geprüft. +- Kein Docker-, Browser- oder PostgreSQL-Laufzeittest durchgeführt. diff --git a/UPDATE-0.3.14.2.txt b/UPDATE-0.3.14.2.txt new file mode 100644 index 0000000..c37c670 --- /dev/null +++ b/UPDATE-0.3.14.2.txt @@ -0,0 +1,24 @@ +# AssetManager v0.3.14.2 + +## Benutzerverwaltung +- Der Button „Anmeldeeinstellungen“ öffnet jetzt `/settings/authentication` (LDAP). +- Kompatibilitätskorrektur funktioniert auch mit einer älteren, lokal vorhandenen `users.html`. + +## Systeminformationen +- `APPINFO.json` wird nicht mehr im Update-ZIP ausgeliefert. +- Neue Vorlage: `APPINFO.example.json`. +- Produktive Datei: `/app/data/config/APPINFO.json`. +- Optional kann `APPINFO_PATH` einen anderen Pfad festlegen. +- Eine vorhandene alte Datei unter `/app/APPINFO.json`, `/app/app/APPINFO.json` oder `/APPINFO.json` wird einmalig in den persistenten Pfad kopiert, sofern dort noch keine Datei besteht. +- Updates überschreiben die persistente Datei nicht. + +## Aktivierung von Änderungen +- APPINFO wird bei jeder Seitenanforderung neu gelesen. +- Nach dem Speichern genügt normalerweise ein Neuladen der Seite; ein Container-Neustart ist nicht erforderlich. + +## Validierung +- Python-Syntax geprüft. +- Jinja2-Basistemplate syntaktisch geladen. +- ZIP enthält keine `APPINFO.json`. +- ZIP-Wurzel `assetmanager/` geprüft. +- Kein Docker-, Browser- oder PostgreSQL-Laufzeittest durchgeführt. diff --git a/UPDATE-0.3.14.3.txt b/UPDATE-0.3.14.3.txt new file mode 100644 index 0000000..a5d7076 --- /dev/null +++ b/UPDATE-0.3.14.3.txt @@ -0,0 +1,29 @@ +# AssetManager v0.3.14.3 + +## APPINFO – tatsächliche Ursache behoben +- Docker mountet `./data/config` nach `/app/config`. +- v0.3.14.2 suchte irrtümlich unter `/app/data/config/APPINFO.json`. +- Die produktive Datei wird jetzt unter `/app/config/APPINFO.json` gelesen. +- `APPINFO_PATH=/app/config/APPINFO.json` ist im Docker Compose explizit gesetzt. +- Der bisherige falsche Pfad bleibt als einmalige Migrationsquelle erhalten. +- Die Einstellungsseite zeigt den tatsächlich verwendeten Pfad an. +- Das Update enthält keine produktive `APPINFO.json`, nur `APPINFO.example.json`. + +## Benutzerverwaltung +- Der Quelllink „Anmeldeeinstellungen“ wurde direkt in `users.html` auf + `/settings/authentication` geändert. + +## Update-Schutz +Nicht im ZIP enthalten: +- `.env` +- `APPINFO.json` +- `data/config/config.json` +- PostgreSQL-Daten +- Backups, Uploads und Logs + +## Validierung +- Python-Syntax geprüft. +- Geänderte Jinja2-Templates syntaktisch geladen. +- Docker-Compose-YAML textuell auf APPINFO_PATH geprüft. +- ZIP-Wurzel `assetmanager/` geprüft. +- Kein echter Docker-/Browser-Laufzeittest durchgeführt. diff --git a/UPDATE-0.3.14.4.txt b/UPDATE-0.3.14.4.txt new file mode 100644 index 0000000..ae50c7c --- /dev/null +++ b/UPDATE-0.3.14.4.txt @@ -0,0 +1,11 @@ +# AssetManager v0.3.14.4 + +- Sidebar state is applied before first paint; no expand/collapse flash on navigation. +- Asset table stays hidden only during its first initialization and appears in its final column layout. +- Removed the global DOM MutationObserver from list-tools and costly per-row computed-style checks. +- Language and user menus are right aligned again. +- Asset page has one Import / Export button and links to the combined page. +- Duplicate workflow, Import/Export, system information and the main MeshCentral configuration/mapping texts use i18n keys. +- Existing persistent files and data are not included in this update ZIP. + +Static validation only; no Docker/browser runtime test. diff --git a/UPDATE-0.3.14.5.txt b/UPDATE-0.3.14.5.txt new file mode 100644 index 0000000..ef9cb02 --- /dev/null +++ b/UPDATE-0.3.14.5.txt @@ -0,0 +1,39 @@ +# AssetManager v0.3.14.5 + +## Ruhiger Aufbau der Assetseite +- Die Assetseite wird während der Initialisierung vollständig verborgen. +- Statt sichtbarer Zwischenzustände erscheint ein dezenter Ladehinweis. +- Erst nach Tabellenaufbau, Spaltenreihenfolge, Spaltensichtbarkeit, + Filterwiederherstellung und Datensatzzählung wird die fertige Seite angezeigt. +- Eine Zeitüberschreitung von fünf Sekunden verhindert, dass die Seite bei einem + unerwarteten JavaScript-Fehler dauerhaft verborgen bleibt. + +## Performance +- Der globale MutationObserver für gespeicherte Tabellenzustände wurde entfernt. +- Gespeicherte Filter werden gesammelt eingesetzt. +- Danach erfolgt nur noch ein einziger Filterdurchlauf statt je eines + vollständigen Durchlaufs pro gespeicherter Spalte. +- Tabellenmodule melden ihren Abschluss explizit an einen zentralen + Initialisierungskoordinator. +- Layout- und Scrollbalkenberechnungen laufen vor dem sichtbaren Einblenden ab. + +## Übersetzungen +- Der Ladehinweis nutzt Übersetzungsschlüssel. +- Deutsche und englische Standardtexte wurden ergänzt. + +## Update-Schutz +Nicht im Update enthalten: +- .env +- APPINFO.json +- data/config/config.json +- PostgreSQL-Daten +- Uploads +- Backups +- Logs + +## Validierung +- Python-Syntax geprüft. +- Jinja2-Templates syntaktisch geladen. +- JavaScript-Dateien mit Node.js syntaktisch geprüft, sofern Node.js verfügbar. +- ZIP-Wurzel assetmanager/ geprüft. +- Kein vollständiger Docker-/Browser-Laufzeittest durchgeführt. diff --git a/UPDATE-0.3.14.6.txt b/UPDATE-0.3.14.6.txt new file mode 100644 index 0000000..fd76d11 --- /dev/null +++ b/UPDATE-0.3.14.6.txt @@ -0,0 +1,38 @@ +# AssetManager v0.3.14.6 + +## Diagnose der Assetseiten-Initialisierung + +Die Browser-Debugkonsole protokolliert jetzt: + +- Startzeit der Assetseiten-Initialisierung +- Anzahl der bereits serverseitig gelieferten Datensätze +- jedes empfangene Fertig-Ereignis +- Status aller fünf Initialisierungsmodule +- fehlende Fertig-Marker +- Zeitpunkt und Grund der Seiteneinblendung +- Wartezeit auf Webfonts +- Dauer der abschließenden Renderframes +- Navigation Timing einschließlich TTFB und HTML-Empfang +- langsame JavaScript-, CSS- und sonstige Ressourcen ab 50 ms +- lange Aufgaben auf dem Browser-Hauptthread +- deutliche Warnung, falls der 5-Sekunden-Fallback ausgelöst wird + +## Verwendung + +1. Browser-Entwicklertools mit F12 öffnen. +2. Registerkarte „Konsole“ öffnen. +3. Assetseite neu laden. +4. Nach Meldungen mit `[AssetManager][Assets]` filtern. +5. Den vollständigen Konsolenblock kopieren. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- PostgreSQL-Daten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.3.14.7.txt b/UPDATE-0.3.14.7.txt new file mode 100644 index 0000000..f6fa50e --- /dev/null +++ b/UPDATE-0.3.14.7.txt @@ -0,0 +1,46 @@ +# AssetManager v0.3.14.7 + +## Erweiterte Browser-Performance-Diagnose + +Die Assetseite protokolliert nun zusätzlich die Laufzeiten einzelner Module und +Callbacks in der Browserkonsole: + +- browser-state: Wiederherstellung gespeicherter Filter +- table-tools: Modulinitialisierung, Filtern und Sortieren +- list-tools: Datensatzzähler und MutationObserver +- asset-columns: Initialisierung, Spaltensichtbarkeit, Scrollbalkensynchronisierung, + ResizeObserver und MutationObserver +- Zusammenfassung der langsamsten Vorgänge nach dem Laden + +Meldungen lassen sich nach folgendem Präfix filtern: + + [AssetManager][Performance] + +Vorgänge ab 50 ms erscheinen als Warnung. Layoutnahe Vorgänge wie +Scrollbalken- und Observer-Callbacks werden bereits ab 20 ms hervorgehoben. + +## Korrektur + +Der 5-Sekunden-Sicherheitstimer wird nun sofort beendet, sobald die Assetseite +erfolgreich eingeblendet wurde. Dadurch erscheint nach fünf Sekunden keine +irreführende Fallback-Meldung mehr. + +## Test + +1. F12 öffnen. +2. Konsole auswählen. +3. Nach `AssetManager` filtern. +4. Assetseite mit Strg+F5 neu laden. +5. Den Block `Zusammenfassung – langsamste Vorgänge` sowie Warnungen kopieren. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- PostgreSQL-Daten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.3.14.8.txt b/UPDATE-0.3.14.8.txt new file mode 100644 index 0000000..8bf77a1 --- /dev/null +++ b/UPDATE-0.3.14.8.txt @@ -0,0 +1,51 @@ +# AssetManager v0.3.14.8 + +## Erweiterte Layout- und Reflow-Diagnose + +Die Assetseite misst nun zusätzlich: + +- einzelne Layout-Lesezugriffe auf `table.scrollWidth` +- einzelne Layout-Lesezugriffe auf `scroll.clientWidth` +- Schreibzugriffe für Breite und Sichtbarkeit der oberen Scrollleiste +- Herkunft jedes Scrollleisten-Abgleichs: + - Initialisierung + - ResizeObserver + - MutationObserver +- vollständige Dauer von `applyHidden` +- vollständige Dauer von `syncTopScrollbar` +- vollständige Dauer der Observer-Callbacks +- Chrome-Performance-Messungen über `performance.mark()` und + `performance.measure()` +- Paint-Ereignisse +- Long Animation Frames inklusive Script-Zuordnung und + `forcedStyleAndLayoutDuration`, sofern der Browser diese API unterstützt + +Alle Messungen von `asset-columns.js` werden jetzt auch in der abschließenden +Tabelle „Zusammenfassung – langsamste Vorgänge“ berücksichtigt. + +## Konsolenfilter + + [AssetManager][Performance] + [AssetManager][Assets] + +Besonders relevant sind: + +- `asset-columns:layout-read:table.scrollWidth` +- `asset-columns:layout-read:scroll.clientWidth` +- `asset-columns:syncTopScrollbar` +- `asset-columns:applyHidden` +- `Long Animation Frame` +- `forced_style_layout_ms` +- `Chrome Performance-Messungen` + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- PostgreSQL-Daten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.3.15.0.txt b/UPDATE-0.3.15.0.txt new file mode 100644 index 0000000..2737bee --- /dev/null +++ b/UPDATE-0.3.15.0.txt @@ -0,0 +1,70 @@ +# AssetManager v0.3.15.0 + +## Performance-Refactoring der Assetliste + +Die Diagnose aus v0.3.14.8 zeigte zwei konkrete Ursachen: + +- `innerText` in den Tabellenwerkzeugen erzwang Layoutberechnungen. +- `table.scrollWidth` löste bei der breiten Assettabelle einen teuren Reflow aus. + +Diese Bereiche wurden grundlegend überarbeitet. + +### Tabellenlayout + +- Die Assettabelle verwendet jetzt `table-layout: fixed`. +- Jede Datenspalte erhält eine feste Breite. +- Ohne konfigurierte Listenbreite gelten sinnvolle Standardbreiten: + - Bezeichnung: 220 px + - andere Datenfelder: 180 px +- Lange Inhalte vergrößern die Tabelle nicht mehr, sondern werden abgeschnitten + und mit Auslassungspunkten dargestellt. + +### Spaltenverwaltung + +- Ausgeblendete Spalten werden über eine einzige dynamische CSS-Regel gesteuert. +- Es werden nicht mehr tausende einzelne Tabellenzellen per JavaScript verändert. +- Die Tabellenbreite wird aus den bekannten Spaltenbreiten berechnet. +- `table.scrollWidth` wird nicht mehr verwendet. +- `ResizeObserver` und `MutationObserver` der Spaltenverwaltung wurden entfernt. +- Die obere Scrollleiste wird nur noch initial, nach einer Spaltenänderung und + bei einer echten Änderung der Fenstergröße aktualisiert. + +### Filter und Sortierung + +- `innerText` wurde durch `textContent` ersetzt, damit Filter und Sortierung + keine erzwungene Layoutberechnung auslösen. +- Filterwerte werden einmalig zwischengespeichert. +- Ein Filterdurchlauf durchsucht den vorbereiteten Cache statt jedes Mal alle + Zellen erneut über DOM-Abfragen zu lesen. +- Der DOM-Status einer Zeile wird nur geändert, wenn sich ihre Sichtbarkeit + tatsächlich ändert. +- Sortierte Zeilen werden gesammelt über ein `DocumentFragment` eingesetzt. + +### Spaltenreihenfolge + +- Gespeicherte Spaltenreihenfolgen werden zeilenweise gesammelt angewendet. +- Dadurch entstehen deutlich weniger einzelne DOM-Operationen. + +### Diagnosecode + +- Die ausführliche Performance-Diagnose aus v0.3.14.8 wurde wieder entfernt. +- Der koordinierte, flackerfreie Seitenaufbau bleibt erhalten. + +## Hinweis + +Virtuelles Rendering wurde bewusst noch nicht aktiviert, da es Auswahl, +Sortierung, Filter, Duplikatsuche und den dynamischen Inhaltsreload tiefgreifend +verändern würde. Die jetzt umgesetzten Optimierungen beseitigen zunächst die +nachgewiesenen Reflow-Ursachen ohne Funktionsverlust. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- PostgreSQL-Daten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.3.15.1.txt b/UPDATE-0.3.15.1.txt new file mode 100644 index 0000000..b1564f1 --- /dev/null +++ b/UPDATE-0.3.15.1.txt @@ -0,0 +1,51 @@ +# AssetManager v0.3.15.1 + +## Behoben: vertikales Scrollen + +- Die problematischen CSS-Containment-Regeln aus v0.3.15.0 wurden entfernt. +- Der Tabellenbereich verwendet wieder normales `overflow: auto`. +- `min-height: 0` verhindert, dass ein übergeordneter Flex- oder Grid-Container + den vertikalen Scrollbereich blockiert. +- Ein schlanker `ResizeObserver` aktualisiert ausschließlich die obere + horizontale Scrollleiste bei echten Breitenänderungen. + +## Behoben: Filterfelder waren um eine Spalte verschoben + +In v0.3.15.0 konnten die Filterfelder nach dem Anwenden der gespeicherten +Spaltenreihenfolge nicht mehr eindeutig ihrer Spalte zugeordnet werden. + +Ursache war die Verwendung eines veränderlichen `cellIndex` beim Umsortieren +der Filterzeile. Während die Zellen verschoben wurden, änderten sich diese +Indizes bereits. + +Die Korrektur: + +- Jedes Filterfeld besitzt jetzt eine dauerhafte interne Zuordnung. +- Datenfilter werden ausschließlich über `data-field` mit ihrer Datenspalte + verbunden. +- Auswahl- und Aktionsspalten erhalten stabile interne Schlüssel. +- Die Filterzeile wird nach einer Spaltenänderung vollständig anhand dieser + festen Zuordnungen neu aufgebaut. +- Filterwerte lesen niemals mehr versehentlich die benachbarte Spalte. + +## Beibehaltene Optimierungen + +- `table-layout: fixed` +- feste Spaltenbreiten +- `textContent` statt `innerText` +- zwischengespeicherte Filterwerte +- CSS-basierte Spaltensichtbarkeit +- `DocumentFragment` für Sortierung und Spaltenreihenfolge +- keine Verwendung von `table.scrollWidth` + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- PostgreSQL-Daten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.3.15.2.txt b/UPDATE-0.3.15.2.txt new file mode 100644 index 0000000..da16e47 --- /dev/null +++ b/UPDATE-0.3.15.2.txt @@ -0,0 +1,44 @@ +# AssetManager v0.3.15.2 + +## Behoben: Assetliste lässt sich wieder vertikal scrollen + +Die Korrektur aus v0.3.15.1 war unvollständig. Der eigentliche Grund war der +in v0.3.14 eingeführte Sidebar-Aufbau: + + body + -> app-shell + -> app-content + -> asset-list-page + -> asset-table-scroll + +Die ältere Scroll-Konfiguration stammte noch aus der Zeit vor `app-shell`. +Dadurch fehlte eine durchgängige Kombination aus `min-height: 0`, Flex-Höhe +und `overflow`, sodass der Tabellencontainer keine nutzbare Scrollhöhe erhielt. + +v0.3.15.2 behebt dies vollständig: + +- Die Assetseite erhält eine eigene Body-Klasse. +- `body`, `app-shell`, `app-content` und `asset-list-page` bilden jetzt eine + durchgängige Flex-/Höhenkette. +- Nur `asset-table-scroll` übernimmt auf Desktop-Systemen das vertikale + Scrollen. +- Der Tabellenbereich erhält eine echte Resthöhe über `flex: 1`. +- Die alte Containment-Regel wird auf der Assetseite ausdrücklich deaktiviert. +- Auf schmalen Bildschirmen bleibt normales Seiten-Scrolling erhalten. + +## Ebenfalls enthalten + +- Die in v0.3.15.1 korrigierte Zuordnung der Filterfelder zu ihren Spalten. +- Alle Tabellen-Performanceoptimierungen aus v0.3.15.0. + +## Update-Schutz + +Nicht überschrieben werden: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.3.3.txt b/UPDATE-0.3.3.txt new file mode 100644 index 0000000..f1e06c4 --- /dev/null +++ b/UPDATE-0.3.3.txt @@ -0,0 +1,14 @@ +AssetManager v0.3.3 + +Neu: +- Rollenmodell: Nur Administratoren dürfen Assets, Kategorien, Statuswerte, Benutzer, Einstellungen und MeshCentral-Synchronisation ändern. +- Nicht-Administratoren erhalten reinen Lesezugriff und sehen nur Assets aus den ihnen zugewiesenen MeshCentral-Gruppen. +- Gruppenzuweisung in der Benutzerverwaltung; vorhandene Gruppen werden aus der Asset-Datenbank angeboten. +- Statuswerte für „Ausgeben“ und „Zurücknehmen“ frei konfigurierbar. +- Beim Ausgeben werden Name und Datum in einem Dialog abgefragt; aktuelles Datum ist vorbelegt. +- LDAP-Benutzersuche in den LDAP-Einstellungen. +- Datenbankmigrationen ergänzen die neuen Felder automatisch. + +Hinweise: +- Nach einer Änderung der Benutzerrechte muss sich der Benutzer neu anmelden. +- Dieses Update enthält keine .env, keine config.json, keine docker-compose.yml, keine Uploads, Logs oder Datenbankdaten. diff --git a/UPDATE-0.3.4.7.txt b/UPDATE-0.3.4.7.txt new file mode 100644 index 0000000..aaa0aa4 --- /dev/null +++ b/UPDATE-0.3.4.7.txt @@ -0,0 +1,11 @@ +AssetManager v0.3.4.7 + +- MeshCentral-Kategoriezuordnung verwendet stabile Kategorie-IDs statt Kategorienamen. +- Konfigurierbare Fallback-Kategorie, Standard-ID 1. +- Konfigurierbares Mapping der MeshCentral-Gerätetypen 1 bis 8 auf AssetManager-Kategorien. +- Bestehende Assets behalten ihre Kategorie. +- Neue Geräte verwenden Mapping oder Fallback. +- Ungültige Mapping-Kategorien brechen die Synchronisierung nicht mehr ab. +- Einstellungen und Synchronisierungsseite zeigen die Zuordnung an. + +Keine .env, config.json, docker-compose.yml, Datenbankdaten, Uploads oder Logs enthalten. diff --git a/UPDATE-0.3.4.txt b/UPDATE-0.3.4.txt new file mode 100644 index 0000000..d884497 --- /dev/null +++ b/UPDATE-0.3.4.txt @@ -0,0 +1,10 @@ +AssetManager v0.3.4 + +- Hierarchische Gerätezuordnung über übergeordnetes Gerät +- Grafische Baustruktur mit Bild, Name, IP, Standort und Raum +- Filter nach Wurzelgerät, Kategorie und Namensbestandteil +- Neues Feld Raumnummer +- Import-Dubletten: Inventarnummer global eindeutig, leere Werte erlaubt; Seriennummer nur zusammen mit Hersteller +- Schutz vor Kreisbeziehungen in der Hierarchie + +Nicht enthalten: .env, docker-compose.yml, config.json, Daten, Uploads oder Logs. diff --git a/UPDATE-0.3.5.1.txt b/UPDATE-0.3.5.1.txt new file mode 100644 index 0000000..1a1cc89 --- /dev/null +++ b/UPDATE-0.3.5.1.txt @@ -0,0 +1,13 @@ +AssetManager 0.3.5.1 - Hotfix Diagramme nach Anmeldung + +Behoben: +- Jinja2-Fehler "builtin_function_or_method object is not iterable" +- Ursache war die Kollision des Dictionary-Schlüssels "items" mit der Python-Methode dict.items + +Installation: +1. ZIP im Verzeichnis oberhalb von assetmanager entpacken. +2. Container neu starten bzw. neu bauen. +3. Browser mit Strg+F5 aktualisieren. + +Dieses Paket enthält ausschließlich die korrigierte Diagrammvorlage und diese Update-Datei. +Keine Konfiguration, Datenbankdaten, Uploads oder Logs werden überschrieben. diff --git a/UPDATE-0.3.5.txt b/UPDATE-0.3.5.txt new file mode 100644 index 0000000..0728c16 --- /dev/null +++ b/UPDATE-0.3.5.txt @@ -0,0 +1,24 @@ +AssetManager 0.3.5 – konfigurierbare Diagramme + +Neu: +- Menüpunkt „Diagramme“ für alle berechtigten Benutzer +- Admin-Verwaltung zum Erstellen, Bearbeiten und Löschen von Diagrammen +- Diagrammtypen: Donut, Kuchen, Balken, gestapelte Balken +- Gruppierung nach Kategorie, Status, Betriebssystem, Hersteller, Modell, + Standort, Raumnummer, Abteilung, MeshCentral-Gruppe, Antivirus oder TPM-Version +- Optionale zweite Gruppierung für gestapelte Balken +- Anzeige ausgewählter Diagramme auf der Übersicht +- Berechtigungen werden berücksichtigt: Nicht-Admins sehen nur Auswertungen der + MeshCentral-Gruppen, für die sie freigeschaltet sind +- Standarddiagramme werden beim ersten Öffnen automatisch angelegt + +Installation: +1. ZIP im Verzeichnis oberhalb von assetmanager entpacken. +2. Container neu bauen und starten: + cd /srv/docker/assetmanager + docker compose down + docker compose up -d --build +3. Browser mit Strg+F5 aktualisieren. + +Die neue Tabelle chart_definitions wird automatisch angelegt. +Konfigurationsdateien, .env, Datenbankdaten, Uploads und Logs sind nicht im Paket enthalten. diff --git a/UPDATE-0.3.6.2.txt b/UPDATE-0.3.6.2.txt new file mode 100644 index 0000000..9a9c7a1 --- /dev/null +++ b/UPDATE-0.3.6.2.txt @@ -0,0 +1,15 @@ +AssetManager 0.3.6.2 + +Neu: +- Benutzer können in der Benutzerverwaltung gelöscht werden. +- Schutz vor Löschen des aktuell angemeldeten Benutzers. +- Schutz vor Löschen des letzten aktiven Administrators. +- Mehrfachauswahl in der Asset-Tabelle inklusive „Alle markieren“. +- Massenaktion „Daten ändern“ für gemeinsame Felder der ausgewählten Gerätekategorien. +- Massenaktion „Markierte löschen“. +- Jede Massenänderung wird in der Asset-Historie protokolliert. + +Sicherheit: +Inventarnummer, Seriennummer, MeshCentral Node-ID und übergeordnetes Gerät sind von Massenänderungen ausgeschlossen. + +Das Update enthält keine .env, config.json, Datenbankdaten, Uploads oder Logs. diff --git a/UPDATE-0.3.6.3.txt b/UPDATE-0.3.6.3.txt new file mode 100644 index 0000000..c57f4cb --- /dev/null +++ b/UPDATE-0.3.6.3.txt @@ -0,0 +1,12 @@ +AssetManager 0.3.6.3 + +Änderungen: +- Übergeordnetes Gerät (parent_asset_id) ist in der Massenänderung verfügbar. +- Auswahl des übergeordneten Geräts erfolgt über ein Dropdown. +- Schutz vor Selbstzuordnung und zyklischen Baumstrukturen. +- LDAP-Ergebnisliste besitzt Checkboxen und eine Aktion zum Speichern ausgewählter Benutzer. +- Bereits vorhandene Benutzer werden nicht erneut auswählbar angeboten. +- Leere LDAP-Suche zeigt alle aktiven AD-Benutzer. +- LDAP-Suche unterstützt * als Wildcard. + +Dieses Update enthält keine Konfiguration, .env, Datenbankdaten, Uploads oder Logs. diff --git a/UPDATE-0.3.6.txt b/UPDATE-0.3.6.txt new file mode 100644 index 0000000..09d3fa6 --- /dev/null +++ b/UPDATE-0.3.6.txt @@ -0,0 +1,13 @@ +AssetManager 0.3.6 – Diagramm-Werte zusammenfassen + +Neu: +- Diagramme können Rohwerte automatisch zusammenfassen. +- Betriebssysteme werden z. B. zu Windows 11, Windows 10, Windows Server, Ubuntu usw. gruppiert. +- Eigene Zuordnungen sind pro Diagramm möglich: Suchtext => Anzeigename. +- Die Originalwerte der Assets werden nicht verändert. +- Das bestehende Standarddiagramm „Betriebssysteme“ wird automatisch auf Zusammenfassung umgestellt. + +Installation: +1. ZIP im Verzeichnis oberhalb von assetmanager entpacken. +2. Container neu bauen/starten. +3. Browser mit Strg+F5 aktualisieren. diff --git a/UPDATE-0.3.7.10.txt b/UPDATE-0.3.7.10.txt new file mode 100644 index 0000000..e5c6e52 --- /dev/null +++ b/UPDATE-0.3.7.10.txt @@ -0,0 +1,8 @@ +AssetManager 0.3.7.10 + +- Sprachwechsel auf der Login-Seite verwendet die Session-Sprache, solange noch kein Benutzer angemeldet ist. +- /profile/language ist vor der Anmeldung ausdrücklich freigegeben. +- Vor der Anmeldung wird die Sprache nur in der Session gespeichert, nicht in einem Benutzerkonto. +- Korrekte englische und deutsche Login-Texte werden beim Start geprüft und repariert. +- Toastmeldungen erscheinen unten rechts und überdecken damit nicht mehr die Navigations- und Aktionsschaltflächen oben rechts. +- Keine Konfigurations-, Daten-, Upload- oder Logdateien enthalten. diff --git a/UPDATE-0.3.7.3.txt b/UPDATE-0.3.7.3.txt new file mode 100644 index 0000000..711d205 --- /dev/null +++ b/UPDATE-0.3.7.3.txt @@ -0,0 +1,8 @@ +AssetManager 0.3.7.3 + +Korrektur der Reihenfolge im Bereich Einstellungen > Darstellung: +- Firmenlogo auswählen +- vorhandene Logo-Vorschau und Option "Logo entfernen" +- Standardsprache + +Es werden keine Konfigurations-, Datenbank-, Upload- oder Logdateien überschrieben. diff --git a/UPDATE-0.3.7.4.txt b/UPDATE-0.3.7.4.txt new file mode 100644 index 0000000..f9af1f3 --- /dev/null +++ b/UPDATE-0.3.7.4.txt @@ -0,0 +1,18 @@ +AssetManager 0.3.7.4 + +Kumulative Korrektur auf Basis der vom Benutzer bereitgestellten 0.3.7.3-Dateien. + +Änderungen: +- Sprachmenü mit dunkelblauem Hintergrund und gut lesbarer weißer Schrift. +- Schaltfläche "Alle löschen" aus den Assetlisten entfernt. +- Neue Asset-Spalte mesh_mtype (Integer), automatische Migration und Index. +- MeshCentral-Sync speichert node.mtype; nur falls nicht vorhanden, wird für das bestehende Kategorienmapping weiterhin node.icon verwendet. +- mesh_mtype wird im Assetdetail immer schreibgeschützt angezeigt. +- Kategorie-Felddefinitionen besitzen nun die Eigenschaft readonly. +- mesh_node_id und mesh_mtype sind immer readonly. +- Readonly-Felder werden bei manueller Anlage, Bearbeitung, Excel-Import und Massenänderung nicht verändert. +- Neuer Admin-Button "Mesh Debug" im Assetdetail. +- Der Debug-Aufruf startet eine aktuelle MeshCentral-Synchronisierung, gibt das gespeicherte Roh-JSON als JSON-Antwort zurück, schreibt es in die Browserkonsole und speichert eine Datei unter /app/data/logs/mesh-debug/. +- Der Browser stellt das Ergebnis zusätzlich unter window.meshDebugData bereit. + +Enthält keine .env, config.json, Datenbankdaten, Uploads oder sonstige persistente Daten. diff --git a/UPDATE-0.3.7.5.txt b/UPDATE-0.3.7.5.txt new file mode 100644 index 0000000..3e21e82 --- /dev/null +++ b/UPDATE-0.3.7.5.txt @@ -0,0 +1,11 @@ +AssetManager 0.3.7.5 + +- Weitere feste Oberflächentexte auf Übersetzungsschlüssel umgestellt: + Dashboard, Assetliste, Asset tree, Charts, Kategorien, Statuswerte, + MeshCentral-Synchronisierung und Einstellungen. +- Neue englische und deutsche Übersetzungseinträge werden beim Start ergänzt. +- Profilansicht responsiv korrigiert; Bereiche stehen sauber nebeneinander + und auf kleineren Bildschirmen untereinander. +- Sprachdropdown bleibt dunkel und kontrastreich lesbar. + +Keine .env, config.json, Datenbankdaten, Uploads oder Logs enthalten. diff --git a/UPDATE-0.3.7.6.txt b/UPDATE-0.3.7.6.txt new file mode 100644 index 0000000..fee8fe5 --- /dev/null +++ b/UPDATE-0.3.7.6.txt @@ -0,0 +1,7 @@ +AssetManager 0.3.7.6 + +- User management fully translated +- Translation filter layout fixed +- Language switching is allowed before sign-in and stored in the session only +- Asset table and dashboard field headings use field translation keys +- Profile layout made reliably responsive diff --git a/UPDATE-0.3.7.7.txt b/UPDATE-0.3.7.7.txt new file mode 100644 index 0000000..0abe1ac --- /dev/null +++ b/UPDATE-0.3.7.7.txt @@ -0,0 +1,12 @@ +AssetManager 0.3.7.7 + +- Login page translated via i18n keys. +- Language switching remains available before login and is stored only in the session until authentication. +- Translation management can add custom translation keys directly. +- Custom translations can be selected with checkboxes and deleted in bulk. +- Built-in translation keys are protected from deletion because they are required by the application. +- Translation filter layout corrected. +- Table headings in all included table templates use translation keys, including dynamic asset fields. +- Mesh debug requires re-entry of the current user's password. +- Local users are verified against the local password hash; LDAP users are verified against LDAP. +- Mesh debug password is sent only in the POST request and is not stored. diff --git a/UPDATE-0.3.7.8.txt b/UPDATE-0.3.7.8.txt new file mode 100644 index 0000000..baac6f0 --- /dev/null +++ b/UPDATE-0.3.7.8.txt @@ -0,0 +1,16 @@ +AssetManager 0.3.7.8 + +Änderungen: +- Benutzerprofil linksbündig ausgerichtet. +- Benutzerinformationen und Sprache stehen auf großen Bildschirmen nebeneinander. +- Unter 900 px werden die Bereiche responsiv untereinander dargestellt. +- Mehrfachwerte aus MeshCentral werden mit sichtbaren Trennlinien bzw. Zeilenumbrüchen gespeichert. +- Arbeitsspeichermodule und Datenträger erhalten eine deutliche Trennlinie. +- Mehrere GPU- und Antivirus-Einträge werden jeweils in separaten Zeilen gespeichert. +- Mehrzeilige Assetwerte werden in der Detailansicht mit erhaltenen Zeilenumbrüchen angezeigt. + +Enthaltene Dateien: +- app/templates/profile.html +- app/templates/asset_detail.html +- app/static/css/app.css +- app/meshcentral.py diff --git a/UPDATE-0.3.7.txt b/UPDATE-0.3.7.txt new file mode 100644 index 0000000..e6307d1 --- /dev/null +++ b/UPDATE-0.3.7.txt @@ -0,0 +1,15 @@ +AssetManager 0.3.7 - Internationalization foundation + +Stable rollback reference: 0.3.6.3 (German-only UI). + +New: +- English source/fallback language and German translation +- Per-user language setting +- Immediate language switch in navigation and profile +- Language persists in database and session +- Translation management under Settings > Translations +- XLSX translation export/import +- Missing-translation filter and direct editing +- Existing users keep German; new users use configured default language (English by default) + +No .env, config.json, database data, uploads or logs are included. diff --git a/UPDATE-0.3.8.0.txt b/UPDATE-0.3.8.0.txt new file mode 100644 index 0000000..5fbd3d0 --- /dev/null +++ b/UPDATE-0.3.8.0.txt @@ -0,0 +1,19 @@ +AssetManager 0.3.8.0 - Central field catalog and MeshCentral GUI configuration + +IMPORTANT: +- Stable rollback version before this structural migration: 0.3.7.10 +- Create a PostgreSQL/database backup before first start. +- Do not copy .env, config.json, data, uploads or logs from this ZIP. + +New database objects: +- field_definitions +- asset_field_values +- mesh_field_mappings (prepared for future database-backed mappings) +- category_fields.field_definition_id + +Design: +- System fields remain physical columns in assets. +- User-created fields are type-aware values in asset_field_values. No ALTER TABLE assets is needed. +- Categories only control active/required/show-in-list/sort order. +- Labels, descriptions, types and read-only properties are central. +- MeshCentral connection, sync behavior, update rules and source-path mappings are editable in the web UI. diff --git a/UPDATE-0.3.8.1.txt b/UPDATE-0.3.8.1.txt new file mode 100644 index 0000000..636c9e2 --- /dev/null +++ b/UPDATE-0.3.8.1.txt @@ -0,0 +1,15 @@ +AssetManager 0.3.8.1 - MeshCentral field mapping correction + +Changes: +- mesh_field_mappings is now the database-backed source of truth. +- Existing config.json field mappings are imported once into the database. +- Default MeshCentral source paths are seeded automatically when missing. +- Added update_rule column through the lightweight migration. +- Multiple-value mode, separator, enabled state and update rule are persisted. +- Synchronization reads enabled mappings directly from PostgreSQL. +- Browser now reports non-JSON backend responses instead of only JSON.parse errors. + +Important: +- .env, config.json, database data, uploads and logs are not included. +- The rollback version before this structural change remains 0.3.7.10. +- Create a PostgreSQL backup before installation. diff --git a/UPDATE-0.3.8.2.txt b/UPDATE-0.3.8.2.txt new file mode 100644 index 0000000..726639a --- /dev/null +++ b/UPDATE-0.3.8.2.txt @@ -0,0 +1,17 @@ +AssetManager 0.3.8.2 – vollständige MeshCentral-Mapping-Reparatur + +Wesentliche Änderungen: +- Alle bisherigen MeshCentral-Felder werden als aktivierte System-Mappings in PostgreSQL angelegt/repariert. +- IP, MAC, RAM, Datenträger, GPU und Antivirus verwenden konfigurierbare Spezial-Transformationen. +- Die Synchronisierung liest Assetwerte ausschließlich aus mesh_field_mappings; die alte parallele Hardcode-Zuordnung wurde entfernt. +- Alte field_rules aus config.json werden beim ersten Start in die System-Mappings übernommen. +- Eigene Mapping-Regeln können in der MeshCentral-Seite angelegt, bearbeitet, deaktiviert und gelöscht werden. +- System-Mappings können bearbeitet/deaktiviert, aber nicht gelöscht werden. +- Source Paths unterstützen Punktnotation, [] für Listen und | für alternative Pfade. +- MeshCentral-Verbindung, Verhalten und Mappings sind auf der Seite klar getrennt. +- Ein Klartextkennwort aus Altversionen wird beim nächsten Speichern der Verbindung aus config.json entfernt. Das Kennwort bleibt in .env. + +Nicht überschreiben: +.env, config.json, data/config/config.json, Datenbank-/Daten-Volumes, Uploads und Logs. + +Rollback: v0.3.7.10. diff --git a/UPDATE-0.3.8.3.txt b/UPDATE-0.3.8.3.txt new file mode 100644 index 0000000..83a34d4 --- /dev/null +++ b/UPDATE-0.3.8.3.txt @@ -0,0 +1,35 @@ +AssetManager v0.3.8.3 – dynamische MeshCentral-Feldsynchronisierung +=================================================================== + +Korrekturen: +- Feste SYNC_FIELDS-Liste vollständig entfernt. +- Ausschließlich aktivierte Einträge aus mesh_field_mappings bestimmen die Synchronisierung. +- Systemfelder werden dynamisch über field_definitions und die Asset-Spalten geschrieben. +- Custom-Felder werden dynamisch in asset_field_values gespeichert. +- Neue Mappings auf bestehende System- oder Custom-Felder funktionieren ohne Codeänderung. +- Update-Regeln fill_empty, meshcentral_wins und local_wins gelten für beide Feldarten. +- Pro tatsächlich angewendetem Mapping erscheint eine Debug-Zeile im Live-Protokoll. +- Unbekannte/inaktive Zielfelder und fehlende Systemspalten werden verständlich protokolliert. +- mesh_mtype wurde im ORM-Modell ergänzt; die vorhandene Migration bleibt kompatibel. +- Neue Assets werden vor dem Schreiben von Custom-Feldern geflusht, damit asset_id korrekt gesetzt ist. + +Installation: +1. Datenbank sichern. +2. ZIP im bestehenden AssetManager-Verzeichnis entpacken. +3. Vorhandene .env, config.json, Daten, Uploads und Logs nicht ersetzen. +4. docker compose down +5. docker compose up -d --build +6. docker compose logs --tail=200 app + +Prüfung: +- Auf der MeshCentral-Seite nur die gewünschten Mapping-Regeln aktivieren. +- Für Teständerungen Update-Regel "MeshCentral gewinnt" verwenden. +- Im Live-Protokoll nach "Mapping '' angewendet" suchen. + +Getestet: +- Python-Syntax aller App-Module. +- Isolierter SQLite-Lauf mit einem Systemfeld (notes), mesh_mtype und einem Custom-Feld. +- Derselbe RAM-Quellpfad wurde erfolgreich gleichzeitig in notes und in ein Custom-Feld geschrieben. + +Nicht durchgeführt: +- Kein Produktivtest gegen die reale PostgreSQL-/MeshCentral-Installation des Anwenders. diff --git a/UPDATE-0.3.8.4.txt b/UPDATE-0.3.8.4.txt new file mode 100644 index 0000000..c7badfd --- /dev/null +++ b/UPDATE-0.3.8.4.txt @@ -0,0 +1,36 @@ +AssetManager v0.3.8.4 +===================== + +Änderungen +---------- +1. Kategorie in der Massenänderung + - "Daten ändern" bietet jetzt das Feld "Kategorie" an. + - Die ausgewählten Assets können gemeinsam in eine andere Kategorie verschoben werden. + - Die Änderung wird in der Asset-Historie protokolliert. + +2. Kategorie in Excel-Importvorlage und Import + - Die Importvorlage enthält die Spalte "Kategorie". + - Pro Zeile kann der Kategoriename oder die numerische Kategorie-ID angegeben werden. + - Bleibt die Zelle leer, wird die im Importdialog ausgewählte Kategorie verwendet. + - Unbekannte Kategorien werden zeilenbezogen als Fehler gemeldet. + +3. Auswahl bei gefilterten Tabellen + - "Alle markieren" berücksichtigt nur aktuell sichtbare Tabellenzeilen. + - Durch einen Filter ausgeblendete Zeilen werden automatisch abgewählt. + - Datenänderung und Löschen erhalten ausschließlich sichtbare, markierte IDs. + +4. Sprachabhängige Sortierung + - Aktive Statuswerte werden alphabetisch gemäß Benutzersprache sortiert. + - Kategorien und Geräte in den neuen/angepassten Dropdowns werden ebenfalls sprachabhängig sortiert. + - Für Deutsch werden Umlaute bei der Sortierung wie ae/oe/ue behandelt. + +Installation +------------ +Dieses Update setzt v0.3.8.3 voraus. +Vorhandene .env-, config.json-, Datenbank-, Upload- und Logdaten sind nicht im ZIP enthalten. + + unzip -o assetmanager-v0.3.8.4-bulk-category-filter-selection-sorting.zip + cd assetmanager + docker compose up -d --build + +Danach Browser-Cache mit Strg+F5 aktualisieren. diff --git a/UPDATE-0.3.8.5.txt b/UPDATE-0.3.8.5.txt new file mode 100644 index 0000000..b12bb5b --- /dev/null +++ b/UPDATE-0.3.8.5.txt @@ -0,0 +1,22 @@ +AssetManager 0.3.8.5 +==================== + +Änderungen: +- Fehlenden Import `from typing import Any` dauerhaft übernommen. +- Sprach-/Benutzermenü: lesbare Schriftfarbe `color:#222` und kompakte Formulare im Dropdown. +- Strg+S speichert das aktuell sichtbare Formular über dessen Speichern-Schaltfläche. +- Neue Benutzeroption im Profil: dauerhafte Browserspeicherung. +- Tabellenfilter werden je Tabelle und Benutzerbrowser gespeichert. +- Bei aktivierter Option erfolgt die Speicherung in localStorage und bleibt nach dem Schließen des Browsers erhalten. +- Bei deaktivierter Option wird sessionStorage verwendet; der Zustand bleibt innerhalb des geöffneten Browser-Tabs erhalten. +- Die Spaltenreihenfolge der Asset-Tabelle verwendet dieselbe Speichereinstellung. + +Datenbankmigration: +- users.persist_browser_state BOOLEAN DEFAULT FALSE + +Nicht enthalten/überschrieben: +- .env +- config.json und data/config/config.json +- Datenbank-/PostgreSQL-Volumes +- Uploads +- Logs diff --git a/UPDATE-0.3.8.6.txt b/UPDATE-0.3.8.6.txt new file mode 100644 index 0000000..43b5cc1 --- /dev/null +++ b/UPDATE-0.3.8.6.txt @@ -0,0 +1,25 @@ +AssetManager v0.3.8.6 + +Änderungen: +- Toast-Position pro Benutzer einstellbar: links/mitte/rechts, oben/unten. +- Toast-Anzeigedauer pro Benutzer von 1 bis 120 Sekunden einstellbar. +- Der Button und der Endpunkt „Alles löschen“ wurden entfernt. +- Diagramme können über die Verwaltungsseite eingesehen und bearbeitet werden. +- Standarddiagramme sind bearbeitbar, aber gegen Löschen geschützt. +- Diagramme können dupliziert werden. +- Pro Diagramm können mehrere Statuswerte ausgeschlossen werden. +- Der Statusfilter wird sowohl auf der Diagrammseite als auch auf dem Dashboard angewendet. +- Enthält weiterhin die Korrekturen aus v0.3.8.5 (Any-Import, Sprachmenü, Strg+S und Browserzustand). + +Automatische Datenbankergänzungen: +- users.toast_position +- users.toast_duration_seconds +- chart_definitions.excluded_statuses +- chart_definitions.is_system + +Nicht enthalten/überschrieben: +- .env +- config.json +- Datenbank- und Daten-Volumes +- Uploads +- Logs diff --git a/UPDATE-0.3.9.0.txt b/UPDATE-0.3.9.0.txt new file mode 100644 index 0000000..56c3995 --- /dev/null +++ b/UPDATE-0.3.9.0.txt @@ -0,0 +1,16 @@ +AssetManager v0.3.9.0 – MeshCentral Geräteinventar + +Neu: +- Hardwareinventar wird bei jeder MeshCentral-Synchronisierung als JSON-Snapshot gespeichert. +- Softwareinventar wird gespeichert, sofern die MeshCentral-Antwort ein Softwaresegment enthält. +- Vollständige Rohdaten bleiben zusätzlich in mesh_source_data erhalten. +- Neue Geräteansicht mit Tabs Hardware, Software, MeshCentral JSON und Synchronisierung. +- Aufklappbare Baumdarstellung für beliebig verschachtelte Hardwaredaten. +- Filterbare Softwaretabelle. +- Download des vollständigen Geräteinventars als JSON. +- Automatische Datenbankerweiterung um mesh_hardware_data, mesh_software_data und mesh_inventory_updated_at. + +Hinweis: +Die aktuelle MeshCentral ListDevices-details-Ausgabe enthält Hardwaredaten, aber je nach Agent/Server-Version keine installierte Software. In diesem Fall zeigt der Software-Tab einen entsprechenden Hinweis. Sobald MeshCentral ein Softwaresegment liefert, wird es ohne weitere Datenbankänderung übernommen. + +Dieses Update enthält keine .env, config.json, Datenbank-, Upload- oder Logdateien. diff --git a/UPDATE-0.3.9.1.txt b/UPDATE-0.3.9.1.txt new file mode 100644 index 0000000..aa3613e --- /dev/null +++ b/UPDATE-0.3.9.1.txt @@ -0,0 +1,13 @@ +AssetManager v0.3.9.1 +======================= + +Änderungen: +- Schriftfarbe der Geräteinventar-Registerkarten explizit auf Schwarz gesetzt. +- Farbige Symbole für Hardware-Hauptsektionen und Unterbereiche ergänzt. +- Globales Feld „Eigenes CSS“ in den Einstellungen ergänzt. +- Das gespeicherte Custom CSS wird über /custom.css nach app.css geladen und kann Standardregeln überschreiben. +- Custom CSS wird in general.custom_css der bestehenden config.json gespeichert. + +Hinweis: +- .env, config.json, Datenbank-, Upload- und Logdateien sind nicht im Update enthalten. +- Das Paket wurde syntaktisch und über Jinja-Template-Laden geprüft, aber nicht gegen die produktive PostgreSQL-/Docker-Umgebung getestet. diff --git a/UPDATE-0.3.9.2.txt b/UPDATE-0.3.9.2.txt new file mode 100644 index 0000000..222f0e9 --- /dev/null +++ b/UPDATE-0.3.9.2.txt @@ -0,0 +1,17 @@ +AssetManager v0.3.9.2 – erweitertes MeshCentral-Geräteinventar + +Änderungen: +- Hardwareinventar wird aus mehreren MeshCentral-JSON-Bereichen aufgebaut, nicht nur aus sys.hardware. +- Netzwerkadapter aus net.netif2 inklusive IPv4, IPv6, MAC, Gateway, Netzmaske, Status, Typ, DNS-Suffix und Schnittstellenindex. +- Loopback-Adapter werden in der normalen Inventaransicht ausgeblendet. +- Sicherheitsbereich mit deduplizierten Antivirus-Produkten, Windows-Sicherheitscenter und Defender-Status. +- Bereiche für Systemübersicht, BIOS/UEFI, Mainboard, CPU, RAM, Datenträger/Volumes, Grafik, TPM, Betriebssystemdetails sowie MeshCentral-/Laufzeitdaten. +- Unbekannte zusätzliche Hardwarebereiche bleiben unter „Weitere Hardwaredaten“ sichtbar. +- Bereits gespeicherte Rohdaten werden in der Detailansicht als Fallback sofort aufbereitet; eine neue Synchronisierung schreibt den neuen Snapshot dauerhaft. +- Deutsche Abschnittsnamen erhalten passende Symbole im Inventarbaum. + +Hinweise: +- mesh_source_data bleibt unverändert als vollständiges Roh-JSON erhalten. +- Keine .env-, config.json-, Datenbank-, Upload- oder Logdateien enthalten. +- Python-Syntax, Jinja-Templates und die Aufbereitung anhand des bereitgestellten vollständigen MeshCentral-Datensatzes wurden geprüft. +- Kein produktiver Docker-/PostgreSQL-Test durchgeführt. diff --git a/UPDATE-0.3.9.3.txt b/UPDATE-0.3.9.3.txt new file mode 100644 index 0000000..5318a47 --- /dev/null +++ b/UPDATE-0.3.9.3.txt @@ -0,0 +1,16 @@ +AssetManager v0.3.9.3 – übersetzbares Geräteinventar und kompakter Hardwarebaum + +Änderungen: +- Geräteinventar verwendet für alle neu aufbereiteten Hauptsegmente und bekannten Eigenschaften sprachneutrale i18n-Schlüssel. +- Deutsche und englische Übersetzungen für Inventar, Netzwerk, Sicherheit, Laufzeit, Tabs und Meldungen ergänzt. +- Bestehende Rohdaten werden beim Öffnen erneut aufbereitet, damit ältere deutsch beschriftete Snapshots ohne erneute Synchronisierung aktualisiert werden. +- Hardwarebaum ist beim ersten Öffnen vollständig zugeklappt; nur die Hauptsegmente stehen untereinander. +- Auf-/Zuklappzustand wird über das vorhandene Browser-State-System je Asset gespeichert. +- Letzte Erweiterungen in Diagrammverwaltung, Diagrammformular, Custom CSS und relevanten Einstellungen auf i18n-Nutzung umgestellt. +- Netzwerkdaten inklusive IPv4/IPv6, Gateway, Netzmaske und MAC bleiben enthalten. + +Validierung: +- Python-Syntaxprüfung erfolgreich. +- Alle Jinja2-Templates geladen. +- Inventaraufbereitung mit dem bereitgestellten vollständigen MeshCentral-JSON getestet. +- Kein produktiver Docker-/PostgreSQL-Test durchgeführt. diff --git a/UPDATE-0.3.9.4.txt b/UPDATE-0.3.9.4.txt new file mode 100644 index 0000000..14eb843 --- /dev/null +++ b/UPDATE-0.3.9.4.txt @@ -0,0 +1,19 @@ +# AssetManager v0.3.9.4 + +## Excel-Import und -Export über Asset-ID + +- Die interne `Asset.id` wird im Asset-Export als erste Spalte `Asset-ID` ausgegeben. +- Die Excel-Importvorlage enthält ebenfalls als erste Spalte `Asset-ID`. +- Zeilen mit Asset-ID aktualisieren ausschließlich den vorhandenen Datensatz mit dieser ID. +- Zeilen ohne Asset-ID legen immer ein neues Asset an. +- Eine unbekannte oder ungültige Asset-ID erzeugt einen Zeilenfehler; es wird kein neues Asset angelegt. +- Bei Updates werden nur ausgefüllte Excel-Zellen übernommen. +- Leere Excel-Zellen löschen oder überschreiben keine vorhandenen Werte. +- Eine bestehende Kategorie wird nur geändert, wenn in der Zeile ausdrücklich eine Kategorie angegeben ist. +- Der Asset-Detail-Template-Hotfix aus v0.3.9.3.1 ist enthalten. + +## Validierung + +- Python-Syntaxprüfung durchgeführt. +- ZIP-Struktur geprüft. +- Kein produktiver Test gegen die bestehende PostgreSQL-Datenbank durchgeführt. diff --git a/UPDATE-0.3.9.5.txt b/UPDATE-0.3.9.5.txt new file mode 100644 index 0000000..5d0591b --- /dev/null +++ b/UPDATE-0.3.9.5.txt @@ -0,0 +1,21 @@ +# AssetManager v0.3.9.5 + +## Excel-Import unabhängig von Kategoriefeldern + +- Standardmäßig werden alle global bekannten Felder importiert, die im Feldkatalog: + - aktiv sind, + - für Excel-Import freigegeben sind, + - nicht schreibgeschützt sind. +- Die Felder müssen in der Zielkategorie nicht aktiv oder sichtbar sein. +- Neue Option im Importdialog: `Import only active fields` +- Die Option ist standardmäßig deaktiviert. +- Ist sie aktiviert, werden nur die aktiven Felder der jeweiligen Kategorie importiert. +- Systemfelder und dynamische benutzerdefinierte Felder werden unterstützt. +- Leere Excel-Zellen überschreiben weiterhin keine bestehenden Werte. +- Die Importvorlage enthält alle global importierbaren Felder. + +## Validierung + +- Python-Syntaxprüfung erfolgreich. +- ZIP-Struktur geprüft. +- Kein produktiver Import gegen die bestehende PostgreSQL-Datenbank durchgeführt. diff --git a/UPDATE-0.3.9.6.1.txt b/UPDATE-0.3.9.6.1.txt new file mode 100644 index 0000000..17b2424 --- /dev/null +++ b/UPDATE-0.3.9.6.1.txt @@ -0,0 +1,31 @@ +# AssetManager v0.3.9.6.1 + +## Assetliste +- Serverseitige Seitennavigation wieder entfernt. +- Die komplette Assetliste wird wieder auf einer Seite geladen. +- Tabellenfilter durchsuchen dadurch wieder alle Assets. +- Die Query-Optimierungen aus v0.3.9.6 bleiben erhalten: + - selectinload für Kategorienfelder + - Kategorie pro Asset mit joinedload + - Sortierung in PostgreSQL + - keine wiederholte Mitladung der Kategorienfelder je Asset + +## Datumsfelder +- Excel-Datumswerte werden anhand der Felddefinition normalisiert. +- Felder vom Typ date werden als YYYY-MM-DD gespeichert. +- Felder vom Typ datetime werden als YYYY-MM-DDTHH:MM gespeichert. +- Der Bearbeitungsdialog formatiert alte Zeitstempel korrekt für HTML-Datumsfelder. +- Beim Start werden ältere Werte wie YYYY-MM-DD 00:00:00 in den Systemfeldern + purchase_date, warranty_until und assigned_on auf YYYY-MM-DD korrigiert. + +## Ursache +OpenPyXL liest formatierte Excel-Datumszellen häufig als Python-datetime. +Die bisherige Importfunktion speicherte daraus YYYY-MM-DD 00:00:00. +Die Detailansicht konnte diesen Text anzeigen, ein HTML-input type=date akzeptiert +jedoch ausschließlich YYYY-MM-DD und erschien deshalb leer. + +## Validierung +- Python-Syntaxprüfung erfolgreich. +- Jinja2-Templates syntaktisch geprüft. +- ZIP-Struktur geprüft. +- Kein produktiver Lauf gegen die bestehende PostgreSQL-Datenbank durchgeführt. diff --git a/UPDATE-0.3.9.6.2.txt b/UPDATE-0.3.9.6.2.txt new file mode 100644 index 0000000..11de4c3 --- /dev/null +++ b/UPDATE-0.3.9.6.2.txt @@ -0,0 +1,26 @@ +# AssetManager v0.3.9.6.2 + +Hotfix für den Startfehler: + +NameError: name '_html_field_value' is not defined + +Ursache: +Die Jinja2-Hilfsfunktion wurde als Template-Global registriert, bevor ihre +Python-Funktion definiert war. + +Korrektur: +Die Registrierung erfolgt jetzt unmittelbar nach der Funktionsdefinition. + +Alle Änderungen aus v0.3.9.6.1 bleiben enthalten: +- vollständige Assetliste ohne Pagination +- optimierte Datenbankabfragen +- Datumsnormalisierung für Excel-Import +- Anzeige alter Datumswerte im Bearbeitungsformular +- automatische Reparatur älterer Datumsstrings beim Start + +Validierung: +- Reihenfolge von Definition und Registrierung geprüft +- Python-Syntaxprüfung erfolgreich +- Jinja2-Templates syntaktisch geprüft +- ZIP-Struktur geprüft +- kein produktiver Docker-/PostgreSQL-Lauf durchgeführt diff --git a/UPDATE-0.3.9.6.3.txt b/UPDATE-0.3.9.6.3.txt new file mode 100644 index 0000000..5bb8f05 --- /dev/null +++ b/UPDATE-0.3.9.6.3.txt @@ -0,0 +1,6 @@ +v0.3.9.6.3 + +- Standardsortierung der Assetliste immer nach Bezeichnung (alphabetisch). +- Neuer Button 'Inhalt neu laden'. +- Lädt die Asset-Tabelle per AJAX neu, ohne die komplette Seite zu aktualisieren. +- Filter, Scrollposition und Browserzustand bleiben erhalten. diff --git a/UPDATE-0.3.9.6.txt b/UPDATE-0.3.9.6.txt new file mode 100644 index 0000000..c5b06f2 --- /dev/null +++ b/UPDATE-0.3.9.6.txt @@ -0,0 +1,28 @@ +# AssetManager v0.3.9.6 + +## Excel-Importergebnis +- Ausgabe je Excel-Zeile mit Excel-Zeilennummer, Asset-ID, Aktion und Begründung. +- Übersprungene Zeilen zeigen ausdrücklich, dass keine Änderungen festgestellt wurden. +- Fehler und Neuanlagen erscheinen ebenfalls zeilenweise. + +## Performance der Assetliste +- Serverseitige Seitennavigation. +- Standard: 100 Assets pro Seite; zulässig 25 bis 250. +- SQL LIMIT/OFFSET statt Laden aller Assets. +- Kategorienfelder werden mit selectinload geladen. +- Assets laden nur die Kategorie, nicht erneut alle Kategorienfelder. +- Sortierung erfolgt in PostgreSQL ohne Beachtung der Groß-/Kleinschreibung. +- Kategorie-Assetzahlen werden aggregiert statt über N+1-Lazy-Loads ermittelt. + +## Standardfelder +- Neues Merkmal `is_default` an Felddefinitionen. +- In den Feldeinstellungen kann ein Feld als Standardfeld markiert werden. +- Kategorienliste enthält Auswahl-Checkboxen. +- Aktion `Standardfelder aktivieren` aktiviert alle markierten Standardfelder in den ausgewählten Kategorien. +- Die Migration markiert die bisherigen DEFAULT_FIELDS initial als Standardfelder. + +## Validierung +- Python-Syntaxprüfung durchgeführt. +- Jinja2-Templates syntaktisch geprüft. +- ZIP-Struktur geprüft. +- Kein produktiver Lauf gegen die bestehende PostgreSQL-Datenbank durchgeführt. diff --git a/UPDATE-0.3.9.7.txt b/UPDATE-0.3.9.7.txt new file mode 100644 index 0000000..4b16950 --- /dev/null +++ b/UPDATE-0.3.9.7.txt @@ -0,0 +1,30 @@ +# AssetManager v0.3.9.7 + +## Standardfelder +- Die Aktion „Standardfelder aktivieren“ setzt jetzt zusätzlich + `show_in_list = True`. +- Bereits vorhandene Standardfelder werden ebenfalls für die Listenanzeige + aktiviert. +- Neu angelegte Feldzuordnungen sind sofort aktiv und in der Liste sichtbar. + +## Datensatzanzeige +- Jede Tabelle mit der Klasse `data-table` erhält oben rechts eine Anzeige: + „Angezeigt: X von Y Datensätzen“. +- Ausgeblendete Zeilen durch Such- und Spaltenfilter werden berücksichtigt. +- Die Anzeige aktualisiert sich bei Filtereingaben und Tabellenänderungen. + +## Assetliste +- Neuer direkter Bearbeiten-Button ✎ in jeder Zeile. + +## Scrollverhalten +- Seiten mit einer `sticky-table` verwenden auf Desktop nur noch den + Tabellenbereich als vertikale Scrollfläche. +- Die äußere Browserseite scrollt auf diesen Listenseiten nicht zusätzlich. +- Für kleinere Bildschirme bleibt ein responsiver Rückfall erhalten. + +## Validierung +- Python-Syntaxprüfung erfolgreich. +- Jinja2-Templates syntaktisch geprüft. +- JavaScript-Datei strukturell geprüft. +- ZIP-Wurzel `assetmanager/` geprüft. +- Kein produktiver Docker-/PostgreSQL-Lauf durchgeführt. diff --git a/UPDATE-0.3.9.8.txt b/UPDATE-0.3.9.8.txt new file mode 100644 index 0000000..e173bd9 --- /dev/null +++ b/UPDATE-0.3.9.8.txt @@ -0,0 +1,33 @@ +# AssetManager v0.3.9.8 + +## Browser-Tab-Logo / Favicon +- Neues Uploadfeld in Einstellungen → Allgemein. +- Upload funktioniert analog zum vorhandenen Firmenlogo. +- Unterstützte Formate: PNG, JPG, JPEG, WEBP und GIF. +- Vorhandenes Favicon kann angezeigt, ersetzt und entfernt werden. +- Wenn kein eigenes Favicon gesetzt ist, wird das Firmenlogo als Fallback genutzt. + +## Übersetzungen +- Neue und bestehende Texte der Assetliste wieder über `t()` ausgegeben. +- Datensatzanzeige erhält lokalisierte Texte aus dem Template. +- Dynamische Schaltflächen behalten die Sprache auch bei Zähleränderungen. + +## Kategorienliste +- Ungültige verschachtelte Formulare entfernt. +- Gerade Tabellenlinien im Aktionsbereich wiederhergestellt. +- Neue Spalten: Felder gesamt, aktive Felder, in Liste sichtbare Felder. +- Aktive Attribute werden als kompakte Chips dargestellt. +- Blau markierte Chips sind zusätzlich in der Assetliste sichtbar. + +## Assetliste +- Bearbeiten-Symbol mit eigener CSS-Klasse und robustem HTML-Zeichen korrigiert. +- Tabellenzellen verwenden wieder normale Tabellengeometrie. + +## Scrollverhalten +- Single-Scroll-Modus wird ausschließlich auf Seiten mit `#asset-table` aktiviert. + +## Validierung +- Python-Syntaxprüfung erfolgreich. +- Jinja2-Templates syntaktisch geprüft. +- ZIP-Wurzel `assetmanager/` geprüft. +- Kein produktiver Docker-/PostgreSQL-Lauf durchgeführt. diff --git a/UPDATE-0.3.9.9.txt b/UPDATE-0.3.9.9.txt new file mode 100644 index 0000000..e156e06 --- /dev/null +++ b/UPDATE-0.3.9.9.txt @@ -0,0 +1,33 @@ +# AssetManager v0.3.9.9 + +## Horizontales Scrollen +- Oberhalb der Assettabelle gibt es eine zweite, stets erreichbare horizontale + Scrollleiste. +- Obere und untere Scrollleiste werden synchronisiert. +- Die Tabelle kann weiterhin vertikal innerhalb des Tabellenbereichs scrollen. + +## Spalten ein-/ausblenden +- Neuer Button „Spalten“ in der Assetliste. +- Rechtsklick auf eine Tabellenüberschrift öffnet ein Kontextmenü. +- Einzelne Spalten können ausgeblendet werden. +- Im Spaltendialog können ausgeblendete Spalten wieder eingeblendet werden. +- Auswahl wird benutzerspezifisch im Browser gespeichert. +- „Alle einblenden“ und „Zurücksetzen“ sind vorhanden. + +## Spaltenbreite +- Felddefinitionen besitzen die neue optionale Einstellung + „Breite in Listen (Pixel)“. +- Zulässiger Bereich: 40 bis 800 Pixel. +- Leer bedeutet automatische Breite. +- Lange Inhalte in fest begrenzten Spalten werden mit Auslassungspunkten + dargestellt. + +## Datenbank +- Neue nullable Spalte `field_definitions.list_width`. +- Lightweight-Migration ergänzt die Spalte automatisch. + +## Validierung +- Python-Syntaxprüfung durchgeführt. +- Jinja2-Templates syntaktisch geprüft. +- ZIP-Struktur geprüft. +- Kein produktiver Docker-/PostgreSQL-Lauf durchgeführt. diff --git a/UPDATE-0.4.0.0.txt b/UPDATE-0.4.0.0.txt new file mode 100644 index 0000000..e788833 --- /dev/null +++ b/UPDATE-0.4.0.0.txt @@ -0,0 +1,25 @@ +# AssetManager v0.4.0.0 + +## Backup-System + +- Neuer produktiver Bereich unter Einstellungen > Sicherung. +- Manuelle Sicherung per Schaltfläche. +- Automatische Sicherung alle 8 Stunden. +- Automatische Löschung von Sicherungen, die älter als 3 Tage sind. +- Ablage unter `/data/backups`. +- PostgreSQL-Dump im Custom-Format. +- Sicherung von `config.json`, `APPINFO.json` und Upload-Dateien. +- Download, Upload und Löschen vorhandener Sicherungen. +- Wiederherstellung nach Eingabe von `RESTORE`. +- Automatische Sicherheitssicherung unmittelbar vor jeder Wiederherstellung. +- Vollständig deutsch/englisch übersetzbare Oberfläche. + +## Docker + +Das App-Image enthält nun `postgresql-client` für `pg_dump` und `pg_restore`. +Der Hostpfad `/data/backups` wird nach `/data/backups` in den Container eingebunden. + +## Datenschutz und Update-Schutz + +Die Datei `.env` und Umgebungsvariablen werden nicht in Sicherungen aufgenommen. +Das Update-ZIP enthält keine Laufzeitdaten, Backups, Uploads, Logs oder Datenbankdateien. diff --git a/UPDATE-0.4.0.1.txt b/UPDATE-0.4.0.1.txt new file mode 100644 index 0000000..d8965ed --- /dev/null +++ b/UPDATE-0.4.0.1.txt @@ -0,0 +1,15 @@ +# AssetManager v0.4.0.1 + +## Backup +- Fehler `NoneType object has no attribute get` beim manuellen Backup behoben. +- Benutzername wird nun sicher über die bestehende Session-Hilfsfunktion ermittelt. +- Backup-Seite als übersichtliche Karten- und Tabellenansicht überarbeitet. +- Neue Status-, Hilfe- und Erfolgstexte vollständig in Deutsch und Englisch übersetzt. + +## Asset-Dialog +- Raumnummer und übergeordnetes Gerät werden nicht mehr doppelt angezeigt. +- Beide Felder bleiben an ihrer konfigurierten Position und vollständig bearbeitbar. +- Bild-Beschriftung in das Übersetzungssystem aufgenommen. + +## Update-Schutz +Nicht enthalten bzw. nicht überschrieben: .env, APPINFO.json, data/config/config.json, Datenbankdaten, Uploads, Backups und Logs. diff --git a/UPDATE-0.4.0.10.txt b/UPDATE-0.4.0.10.txt new file mode 100644 index 0000000..1b40ab4 --- /dev/null +++ b/UPDATE-0.4.0.10.txt @@ -0,0 +1,61 @@ +# AssetManager v0.4.0.10 + +## Dark-Mode-Nacharbeiten + +### Einstellungen und Bereichsränder + +Weiße Ränder und Flächen in Einstellungsbereichen wurden entfernt. + +Diese Elemente verwenden jetzt: + +- `Inhaltsflächen` für den Hintergrund +- `Rahmenfarbe` für Umrandungen +- `Textfarbe` für Beschriftungen + +Damit werden die betroffenen Bereiche über die vorhandenen +Darstellungseinstellungen gesteuert. + +### Sprachmenü + +Nicht ausgewählte Sprachen waren im Dark Mode erst beim Überfahren mit der +Maus sichtbar. + +Korrigiert wurden: + +- Hintergrund des Menüs +- Textfarbe aller Einträge +- Hover-Zustand +- aktiver Spracheintrag +- Rahmenfarbe + +### Assetbaum + +Korrigiert wurden: + +- Kategorienamen im Dark Mode +- Legenden und Feldbeschriftungen +- Hintergrund und Text des Schalters zum Ausblenden einzelner Geräte +- Hover- und Auswahlzustände der Kategorien + +### Farbzuordnung + +Die betroffenen Elemente verwenden jetzt konsequent: + +- `Inhaltsflächen` +- `Rahmenfarbe` +- `Textfarbe` +- `Primärfarbe` + +Es wurde kein zusätzliches Farbmerkmal eingeführt. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.11.txt b/UPDATE-0.4.0.11.txt new file mode 100644 index 0000000..dcb3b60 --- /dev/null +++ b/UPDATE-0.4.0.11.txt @@ -0,0 +1,61 @@ +# AssetManager v0.4.0.11 + +## Dark Mode auf weiteren Seiten vervollständigt + +Die verbleibenden fest codierten hellen Flächen wurden für den Dark Mode +übersteuert. + +Korrigiert wurden insbesondere: + +- MeshCentral-Synchronisierung + - Statistik-Karten + - Verbindungsformular + - Live-Protokoll + - Statushinweise +- Statuswerte + - Formularhintergrund + - Eingabefelder + - Tabellenbereich +- Sicherungen + - Übersichtskarten + - automatische Sicherung + - Upload-Bereich + - Dateiauswahl + - Backup-Tabelle +- Kategorien + - Tabellenflächen + - Aktionsformulare + - Attribut-Badges +- Übersetzungen + - Filterbereich + - Auswahlbereich + - Textfelder +- Benutzerverwaltung + - Anlageformular + - Benutzerformulare + - Gruppen- und Rechtebereiche + - Beschriftungen und Checkboxen + +## Farbzuordnung + +Die korrigierten Flächen orientieren sich weiterhin an: + +- Inhaltsflächen +- Rahmenfarbe +- Textfarbe +- Primärfarbe + +Spezielle Dark-Mode-Abstufungen werden nur verwendet, um Formulare, +Tabellenköpfe und eingebettete Bereiche visuell voneinander zu trennen. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.12.txt b/UPDATE-0.4.0.12.txt new file mode 100644 index 0000000..e8a4aeb --- /dev/null +++ b/UPDATE-0.4.0.12.txt @@ -0,0 +1,52 @@ +# AssetManager v0.4.0.12 + +## Ladeanzeige an Hell- und Dunkelmodus angepasst + +Der Ladebereich der Assetliste verwendet jetzt ein echtes Bildschirm-Overlay. + +### Heller Modus + +- halbtransparenter heller Hintergrund +- Inhaltsfläche und Rahmenfarbe aus der Darstellungskonfiguration +- Spinner in Primärfarbe + +### Dunkler Modus + +- dunkler halbtransparenter Bildschirmhintergrund +- dunkle Dialogfläche +- helle Beschriftungen +- dunkler Rahmen +- Spinner in Primärfarbe +- stärkere, zum Dark Mode passende Schattenwirkung + +## Weitere Dialoge und Fortschrittsanzeigen + +Zusätzlich wurden vereinheitlicht: + +- native Dialogfenster +- Dialog-Hintergründe +- HTML-Fortschrittsanzeigen +- allgemeine Fortschrittsleisten + +Diese Elemente verwenden nun ebenfalls: + +- Inhaltsflächen +- Rahmenfarbe +- Textfarbe +- Primärfarbe + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs + +Mit dieser Version ist der Theme-/Dark-Mode-Ausbau für die 0.4-Reihe +abgeschlossen. Der nächste Entwicklungsschritt startet mit v0.5.0.0 und der +Softwareinventarisierung. diff --git a/UPDATE-0.4.0.13.txt b/UPDATE-0.4.0.13.txt new file mode 100644 index 0000000..ec8cb32 --- /dev/null +++ b/UPDATE-0.4.0.13.txt @@ -0,0 +1,49 @@ +# AssetManager v0.4.0.13 + +## Scrollbars an Hell- und Dunkelmodus angepasst + +Die Scrollbars wurden systemweit vereinheitlicht. + +Betroffen sind unter anderem: + +- Assettabelle +- obere horizontale Scrollleiste der Assettabelle +- Verwaltungstabellen +- Baumansicht +- Kategorienauswahl im Assetbaum +- Dialoge +- Protokollbereiche +- Textfelder +- Dropdown-Menüs +- sonstige scrollbare Bereiche + +## Dark Mode + +- dunkler Scrollbar-Hintergrund +- kontrastreicher graublauer Scrollgriff +- hellerer Hover-Zustand +- aktiver Zustand in Primärfarbe +- dunkle Scrollbar-Ecken + +## Heller Modus + +- heller Scrollbar-Hintergrund +- mittelgrauer Scrollgriff +- dunklerer Hover-Zustand + +## Browserunterstützung + +- Firefox über `scrollbar-color` und `scrollbar-width` +- Chromium, Edge und Safari über `::-webkit-scrollbar` + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.14.txt b/UPDATE-0.4.0.14.txt new file mode 100644 index 0000000..5b2ea3a --- /dev/null +++ b/UPDATE-0.4.0.14.txt @@ -0,0 +1,39 @@ +# AssetManager v0.4.0.14 + +## Gespeicherte Tabellenfilter beim Zurücksetzen bereinigt + +Der Button „Filter zurücksetzen“ im gelben Filterhinweis leert jetzt nicht +nur die sichtbaren Filterfelder, sondern entfernt zusätzlich den dazugehörigen +gespeicherten Browserzustand. + +Damit gilt jetzt für beide Wege dasselbe Verhalten: + +- Filter manuell im Filterfeld löschen +- Filter über den gelben Hinweis zurücksetzen + +Nach dem Zurücksetzen: + +- sind alle Filterfelder leer +- verschwindet die gelbe Markierung +- verschwindet der Filterhinweis +- werden wieder alle Datensätze angezeigt +- bleibt die Tabelle auch nach einem Refresh ungefiltert + +## Technische Änderung + +Programmgesteuerte Änderungen an Eingabefeldern lösen im Browser keine +`input`- oder `change`-Ereignisse aus. Deshalb sendet der zentrale +Filter-Reset jetzt ein eigenes Ereignis. Die Browser-State-Verwaltung entfernt +daraufhin gezielt den gespeicherten Filterzustand der betroffenen Tabelle. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.2.txt b/UPDATE-0.4.0.2.txt new file mode 100644 index 0000000..9a15254 --- /dev/null +++ b/UPDATE-0.4.0.2.txt @@ -0,0 +1,45 @@ +# AssetManager v0.4.0.2 + +## Behoben: übergeordnetes Gerät speichern + +`parent_asset_id` wurde beim Bearbeiten zunächst durch die allgemeine +Formularverarbeitung als Text gesetzt. Die anschließende Datenbankabfrage +verglich deshalb eine Integer-ID mit einem VARCHAR-Wert. + +- `parent_asset_id` wird nicht mehr durch den allgemeinen Feld-Loop verarbeitet. +- Das Feld wird ausschließlich durch die typisierte Spezialverarbeitung gesetzt. +- Bestehende Werte werden vor Datenbankabfragen defensiv in Integer umgewandelt. +- `room_number` wird ebenfalls nur noch durch seine Spezialverarbeitung gesetzt. + +## Behoben: Filter in den übrigen Tabellen + +Tabellen ohne `data-field`-Attribute konnten seit der Tabellenoptimierung keine +Zellwerte mehr ihrem Filter zuordnen. + +- Normale Tabellen verwenden nun einen stabilen ursprünglichen Spaltenindex. +- Assettabellen verwenden weiterhin die performante `data-field`-Zuordnung. +- Sortierung und Filter bleiben auch nach DOM-Änderungen korrekt verbunden. +- Betroffen sind unter anderem Felder, Kategorien, Übersetzungen und Benutzer. + +## Behoben: vertikales Scrollen in Verwaltungstabellen + +Die großen Verwaltungstabellen besitzen wieder einen eigenen vertikalen und +horizontalen Scrollbereich mit fixierter Kopf- und Filterzeile. + +## Behoben: Raum doppelt in der Asset-Detailansicht + +`room_number` und `parent_asset_id` werden aus der allgemeinen Feldanzeige +ausgeschlossen und nur noch über ihre ausdrücklich übersetzten Spezialzeilen +ausgegeben. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.3.txt b/UPDATE-0.4.0.3.txt new file mode 100644 index 0000000..f5f425a --- /dev/null +++ b/UPDATE-0.4.0.3.txt @@ -0,0 +1,52 @@ +# AssetManager v0.4.0.3 + +## Assetbaum: Kategorienfilter überarbeitet + +- übersichtliche Karten statt zusammengedrängter Standard-Checkboxen +- responsive Spalten +- klarer ausgewählter Zustand +- besserer Abstand und besser lesbare Kategorienamen +- eigener Scrollbereich nur innerhalb der Kategorienauswahl + +Es wurden keine neuen fest codierten sichtbaren Texte eingeführt. + +## Verwaltungstabellen: nur noch ein vertikaler Scrollbereich + +Felder, Kategorien, Übersetzungen und Benutzerverwaltung verwenden jetzt das +Browserfenster als einzigen vertikalen Scrollbereich. Die Tabellencontainer +scrollen nur noch horizontal, falls die Tabelle breiter als die Seite ist. + +Damit entfallen ineinander verschachtelte vertikale Scrollbereiche und +unsichtbare innere Scrollbalken. + +## Filter für editierbare Tabellen + +Die Filterlogik liest jetzt zusätzlich die aktuellen Werte aus: + +- Textfeldern +- Zahlenfeldern +- Textbereichen +- Auswahllisten +- Checkboxen +- Optionsfeldern + +Dadurch funktionieren die Filter nun auch in: + +- MeshCentral-Feld-Mappings +- Status-Werten +- weiteren Tabellen mit direkt bearbeitbaren Zellen + +Nach Änderungen in einem Eingabefeld wird der Filtercache automatisch +aktualisiert. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.4.txt b/UPDATE-0.4.0.4.txt new file mode 100644 index 0000000..47448d5 --- /dev/null +++ b/UPDATE-0.4.0.4.txt @@ -0,0 +1,42 @@ +# AssetManager v0.4.0.4 + +## Tabellenfilter vollständig getrennt + +Die Assetliste behält ihre optimierte Tabellen-Engine. + +Alle übrigen Tabellen verwenden jetzt eine eigene, einfache und robuste +Filter- und Sortierlogik: + +- keine Filter-Caches +- keine Asset-Spaltenereignisse +- keine Zuordnung über verschiebbare Spalten +- Filterung ausschließlich anhand der festen Spaltenposition +- Filterzeilen liegen ausschließlich im Tabellenkopf +- nur Datenzeilen im `tbody` können ausgeblendet werden +- Suchfelder bleiben immer sichtbar und können jederzeit geleert werden +- bearbeitbare Werte in Inputs, Selects, Textareas, Checkboxen und + Radiobuttons werden direkt gelesen + +Damit werden insbesondere MeshCentral-Feld-Mappings, Status-Werte, Felder, +Kategorien, Übersetzungen und Benutzerverwaltung zuverlässig gefiltert. + +## Assetbaum-Filter neu angeordnet + +- Oberster Knoten, Namensfilter, Einzelschalter und Aktionen stehen oben. +- Der Kategorienfilter befindet sich darunter über die gesamte verfügbare Breite. +- Die Option zum Ausblenden einzelner Geräte wird als klarer Schalter dargestellt. +- Responsive Darstellung für kleinere Bildschirmbreiten. +- Keine neuen fest codierten sichtbaren Texte; vorhandene Übersetzungsschlüssel + werden weiterverwendet. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.5.txt b/UPDATE-0.4.0.5.txt new file mode 100644 index 0000000..041a5de --- /dev/null +++ b/UPDATE-0.4.0.5.txt @@ -0,0 +1,46 @@ +# AssetManager v0.4.0.5 + +## Einheitliche Tabellenlogik + +Die getrennte Filter-Engine aus v0.4.0.4 wurde vollständig entfernt. + +Alle Tabellen verwenden jetzt dasselbe stabile Prinzip wie die Assetliste: + +- jede Spalte besitzt eine dauerhafte interne Feldkennung +- Assetspalten verwenden ihre vorhandenen `data-field`-Kennungen +- normale Tabellen erhalten automatisch synthetische Kennungen +- jede Datenzelle wird eindeutig ihrer Kopfspalte zugeordnet +- Filter lesen Zellwerte live und nicht aus einem veralteten Cache +- Eingabefelder, Selects, Textareas, Checkboxen und Radiobuttons werden + gemeinsam mit sichtbarem Zelltext ausgewertet +- nur Zeilen im Tabellenkörper können ausgeblendet werden +- die Filterzeile befindet sich ausschließlich im Tabellenkopf und bleibt + immer sichtbar +- Browser-wiederhergestellte Filterwerte werden nach dem Laden erneut angewendet + +## MeshCentral-Feld-Mappings + +Ein Filter wie `Computer` im Zielfeld findet nun unter anderem +`Computername (hostname) – System`, weil sowohl der aktuelle Select-Wert als +auch der sichtbare Text der gewählten Option durchsucht werden. + +## Tabellenkopf nach dem Filtern + +Die fehlerhaften Sticky-Abstände der Verwaltungstabellen wurden entfernt. +Dadurch werden Kopf- und Filterzeile nach dem Filtern oder Neuladen nicht mehr +abgeschnitten. + +Die Assetliste behält ihre fixierte Kopf- und Filterzeile innerhalb ihres +eigenen Scrollcontainers. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.6.txt b/UPDATE-0.4.0.6.txt new file mode 100644 index 0000000..352a41d --- /dev/null +++ b/UPDATE-0.4.0.6.txt @@ -0,0 +1,44 @@ +# AssetManager v0.4.0.6 + +## MeshCentral-Feld-Mappings: Select-Filter korrigiert + +Die Spalten `Zielfeld` und `Transformation` verwenden Select-Felder. + +Die Filterauswertung wurde erweitert: + +- Selects werden über `tagName` statt über einen JavaScript-Klassentest erkannt. +- Es werden sowohl der technische Select-Wert als auch der sichtbare Text der + ausgewählten Option ausgewertet. +- Die beiden Mapping-Zellen erhalten zusätzlich einen serverseitig erzeugten + `data-filter-value`. +- Ein Filter wie `Computer` findet damit zuverlässig + `Computername (hostname) – System`. +- Transformationswerte wie `string`, `number`, `date` oder weitere definierte + Optionen werden zuverlässig gefiltert. + +## Assetliste: Inhalt neu laden bei geänderter Spaltenreihenfolge + +Beim Nachladen wurde bisher nur der Tabellenkörper ersetzt. Der neue +Tabellenkörper kam in der ursprünglichen Server-Reihenfolge, während der +Tabellenkopf in der benutzerdefinierten Reihenfolge blieb. + +Vor dem Einsetzen des neuen Tabellenkörpers werden jetzt: + +1. die aktuell sichtbaren Kopfspalten in ihrer vorhandenen Reihenfolge gelesen, +2. sämtliche neuen Datenzeilen anhand ihrer `data-field`-Kennung umsortiert, +3. erst danach der bisherige Tabellenkörper ersetzt. + +Damit stimmen Überschriften und Inhalte nach `Inhalt neu laden` weiterhin +überein. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.7.txt b/UPDATE-0.4.0.7.txt new file mode 100644 index 0000000..083dd29 --- /dev/null +++ b/UPDATE-0.4.0.7.txt @@ -0,0 +1,53 @@ +# AssetManager v0.4.0.7 + +## Assetliste: Spaltenverschiebung wiederhergestellt + +Die gemeinsame Tabellenlogik hatte in v0.4.0.5 und v0.4.0.6 auch der +Auswahl- und Aktionsspalte künstliche `data-field`-Kennungen gegeben. + +Dadurch wurden diese festen Spalten irrtümlich wie verschiebbare Assetspalten +behandelt. Das führte zu: + +- verschwindenden Spaltenüberschriften, +- beschädigter Spaltenreihenfolge, +- falscher Darstellung nach `Inhalt neu laden`. + +Korrektur: + +- Nur normale Verwaltungstabellen erhalten künstliche Feldkennungen. +- Die Assetliste verwendet ausschließlich ihre echten Feldkennungen. +- Auswahl- und Aktionsspalte bleiben feste, nicht verschiebbare Spalten. +- Filter für feste Spalten verwenden bei Bedarf nur den ursprünglichen Index. +- Nach dem Nachladen werden Spaltenbreiten und Sichtbarkeit erneut angewendet. + +Bereits beschädigte gespeicherte Spaltenfolgen werden beim nächsten Laden +automatisch bereinigt, weil unbekannte künstliche Kennungen verworfen werden. + +## MeshCentral-Mappings: Select-Filter korrigiert + +Der Zelltext eines HTML-Selects enthält technisch die Texte aller Optionen, +nicht nur die aktuell ausgewählte Option. + +Dadurch galt beispielsweise `Computer` für jede Zeile als Treffer, weil +`Computername` in jedem Zielfeld-Select als mögliche Option enthalten war. + +Die Filterauswertung verwendet jetzt ausschließlich: + +- den tatsächlichen Select-Wert, +- den Text der aktuell ausgewählten Option, +- ausdrücklich gesetzte Filterwerte, +- sichtbaren Text außerhalb des Select-Feldes. + +Nicht ausgewählte Select-Optionen werden nicht mehr durchsucht. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.8.txt b/UPDATE-0.4.0.8.txt new file mode 100644 index 0000000..a9187c3 --- /dev/null +++ b/UPDATE-0.4.0.8.txt @@ -0,0 +1,58 @@ +# AssetManager v0.4.0.8 + +## Aktive Tabellenfilter deutlich sichtbar + +Für alle Tabellen wurde eine gemeinsame visuelle Filteranzeige ergänzt. + +### Filterfelder + +Sobald in einem Spaltenfilter ein Wert steht: + +- erhält das Eingabefeld einen gelben Hintergrund, +- wird der Rahmen farblich hervorgehoben, +- wird auch die zugehörige Filterzelle markiert. + +Dadurch sind aktive und vom Browser wiederhergestellte Filter sofort sichtbar. + +### Filterstatus an der Tabelle + +Bei mindestens einem aktiven Filter erscheint direkt oberhalb der Tabelle eine +Statusleiste mit: + +- Anzahl aktiver Filter, +- Anzahl sichtbarer Datensätze, +- Gesamtzahl der Datensätze, +- Schaltfläche zum Zurücksetzen aller Filter dieser Tabelle. + +Ohne aktive Filter bleibt die Statusleiste ausgeblendet. + +### Übersetzungen + +Alle neuen sichtbaren Texte wurden in das bestehende Übersetzungssystem +aufgenommen: + +- `lists.filter_active` +- `lists.filters_active` +- `lists.filter_status` +- `lists.clear_filters` + +Deutsch und Englisch sind vollständig enthalten. + +## Nächster Entwicklungsschritt + +Als nächster Funktionsblock ist die Software-Inventarisierung vorgesehen. +Sie soll als erster vollständiger Job-/Callback-Workflow dienen und damit die +technische Grundlage für spätere Softwareverteilung und weitere Clientaktionen +bilden. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.4.0.9.txt b/UPDATE-0.4.0.9.txt new file mode 100644 index 0000000..18493ea --- /dev/null +++ b/UPDATE-0.4.0.9.txt @@ -0,0 +1,69 @@ +# AssetManager v0.4.0.9 + +## Konfigurierbare Systemfarben + +Unter `Einstellungen → Darstellung` steht eine neue Sektion +`Systemfarben` zur Verfügung. + +Konfigurierbar sind: + +- Primärfarbe +- Primärfarbe bei Auswahl +- Farbe der Kopfzeile +- Navigationsfarbe +- Seitenhintergrund +- Inhaltsflächen +- Textfarbe +- Rahmenfarbe +- Erfolgsfarbe +- Warnfarbe +- Fehlerfarbe + +Die Eingaben erfolgen über Farbwähler. Eine Vorschau wird beim Ändern sofort +aktualisiert. Über `Farben zurücksetzen` können die Standardwerte wieder in +die Eingabefelder übernommen werden. + +Die Werte werden in `config.json` unter `general.colors` gespeichert. + +## Heller und dunkler Darstellungsmodus + +Jeder angemeldete Benutzer besitzt nun eine eigene Einstellung: + +- Hell +- Dunkel + +Die Einstellung wird in der Benutzerdatenbank gespeichert und bei jeder +Anmeldung in die Sitzung übernommen. + +Umschalten ist möglich: + +- direkt in der oberen Navigationsleiste, +- im Profil über eine grafische Auswahl. + +Unter `Einstellungen → Darstellung` kann zusätzlich der Standardmodus für +nicht angemeldete Benutzer bzw. neue Sitzungen festgelegt werden. + +## Datenbankmigration + +Die Benutzertabelle erhält automatisch die Spalte: + + theme_mode VARCHAR(20) DEFAULT 'light' + +Es ist kein manueller SQL-Befehl notwendig. + +## Übersetzungen + +Alle neuen sichtbaren Texte wurden vollständig in Deutsch und Englisch in das +bestehende Übersetzungssystem aufgenommen. + +## Update-Schutz + +Nicht enthalten bzw. nicht überschrieben: + +- .env +- APPINFO.json +- data/config/config.json +- Datenbankdaten +- Uploads +- Backups +- Logs diff --git a/UPDATE-0.5.0.0.txt b/UPDATE-0.5.0.0.txt new file mode 100644 index 0000000..e0bf5f5 --- /dev/null +++ b/UPDATE-0.5.0.0.txt @@ -0,0 +1,18 @@ +# AssetManager v0.5.0.0 + +- Job- und Callback-Grundlage aktiviert +- Navigation „Software und Aufträge“ +- Softwarepaket- und Auftragsübersicht +- sichere, zeitlich begrenzte Callback-Tokens +- Live-Status, Timeout-Erkennung und Diagnose +- erste Softwareinventur für Windows, Linux und macOS +- alle neuen sichtbaren Texte Deutsch/Englisch übersetzt + +Backup-Fehler: +`unknown` entstand, weil nur feste VERSION-Dateien im Container geprüft wurden. +Jetzt wird auf APP_VERSION zurückgefallen. Neue Backups enthalten zusätzlich +eine VERSION-Datei; alte Archive werden auf VERSION und APPINFO geprüft. +Enthält ein altes Backup keine Versionsinformation, ist diese nicht eindeutig +nachträglich ermittelbar und wird entsprechend angezeigt. + +Geschützte Dateien/Daten werden nicht überschrieben. diff --git a/UPDATE-0.5.0.1.txt b/UPDATE-0.5.0.1.txt new file mode 100644 index 0000000..488e9a7 --- /dev/null +++ b/UPDATE-0.5.0.1.txt @@ -0,0 +1,53 @@ +# AssetManager v0.5.0.1 + +## Software-Einstellungsseite + +Neu unter Einstellungen → Software: + +- Callback-Basisadresse für HTTP und HTTPS +- Vorschau des daraus erzeugten Health-Endpunkts +- Gültigkeitsdauer des Callback-Tokens +- Zeitlimit des Verbindungstests +- optionale TLS-Zertifikatsprüfung beim Test +- aktivierbares Callback-Debugprotokoll +- maximale Anzahl gespeicherter Protokollzeilen +- Live-Protokollfenster mit automatischer Aktualisierung +- Protokoll löschen +- Callback-Verbindung direkt testen + +Der Verbindungstest protokolliert: + +- verwendete Basis- und Health-Adresse +- Schema, Host und Port +- DNS-Auflösung +- Zeitlimit und TLS-Prüfung +- HTTP-Status +- Content-Type +- Antwortinhalt +- detaillierte Verbindungsfehler + +Empfangene Job-Callbacks werden ebenfalls ohne Token und ohne Softwareinhalt +im Diagnoseprotokoll vermerkt. + +## Backup-Darstellung + +Die Backupseite und ihre Tabelle verwenden nun die komplette verfügbare +Breite. Die Tabellenhülle scrollt nur noch horizontal, wenn der Browser +tatsächlich zu schmal ist. Die Backupfunktion selbst wurde nicht verändert. + +## Kompatibilität + +Die bisherige Callback-Adresse aus `general.callback_base_url` wird weiterhin +als Rückfall gelesen. Beim Speichern der neuen Seite wird nur der neue +Konfigurationsabschnitt `software` ergänzt. + +## Schutz bestehender Funktionen + +Bestehende Backup-, MeshCentral-, Tabellen-, Theme- und Jobfunktionen wurden +nicht strukturell verändert. Die neue Funktionalität ist additiv eingebunden. + +Alle neuen sichtbaren Texte sind auf Deutsch und Englisch im bestehenden +Übersetzungssystem enthalten. + +Nicht überschrieben: .env, APPINFO.json, config.json, Datenbank, Uploads, +Backups und Logs. diff --git a/UPDATE-0.5.0.2.txt b/UPDATE-0.5.0.2.txt new file mode 100644 index 0000000..e5ed97d --- /dev/null +++ b/UPDATE-0.5.0.2.txt @@ -0,0 +1,27 @@ +# AssetManager v0.5.0.2 + +## Korrektur der Auftragsstatus-Race-Condition + +Der Client konnte die Softwareinventur erfolgreich abschließen und der +AssetManager konnte den Callback mit HTTP 200 annehmen. Trotzdem blieb der +Auftrag anschließend auf „Gesendet; Callback ausstehend“. + +Ursache: + +MeshCtrl wartet auf das Ende des Befehls auf dem Client. Der Client sendete +den erfolgreichen Callback bereits, während der MeshCtrl-Prozess auf dem +AssetManager noch lief. Nach Rückkehr von MeshCtrl setzte dessen Thread den +bereits erfolgreichen Auftrag erneut pauschal auf „Gesendet“. + +Korrektur: + +- MeshCtrl-Diagnoseausgaben werden getrennt vom Auftragsstatus gespeichert. +- Ein bereits eingegangener oder abgeschlossener Callback wird nicht mehr + überschrieben. +- Die Statusänderung erfolgt als bedingtes Datenbank-Update und schließt + dadurch auch das kleine Zeitfenster zwischen Prüfung und Speicherung. +- Die Statusmeldung spricht neutral von „Callback“ statt fälschlich immer von + „HTTPS-Callback“, da HTTP und HTTPS unterstützt werden. + +Es wurden keine bestehenden Callback-Endpunkte, Inventurskripte, +Einstellungen, Tabellen, Themes oder Backupfunktionen verändert. diff --git a/UPDATE-0.5.0.3.txt b/UPDATE-0.5.0.3.txt new file mode 100644 index 0000000..c7ca3bf --- /dev/null +++ b/UPDATE-0.5.0.3.txt @@ -0,0 +1,34 @@ +# AssetManager v0.5.0.3 + +## Callback-JSON korrigiert + +Die Windows-PowerShell übergab den durch `ConvertTo-Json` erzeugten Text als +PowerShell-String an `Invoke-RestMethod`. Unter Windows PowerShell 5.1 kann +dieser Request-Body als UTF-16 übertragen werden, obwohl nur +`application/json` angegeben ist. Der AssetManager erwartete UTF-8 und meldete +deshalb „Ungültiges JSON“. + +Korrektur auf dem Client: + +- JSON wird ausdrücklich in UTF-8-Bytes umgewandelt. +- Content-Type enthält jetzt `charset=utf-8`. +- Die Protokollmeldung verwendet neutral „Callback“ statt „HTTPS callback“. + +Zusätzliche Absicherung auf dem Server: + +- vollständiger Request-Body wird vor dem Parsen gelesen +- UTF-8-BOM, UTF-16 LE und UTF-16 BE werden erkannt +- der erkannte Zeichensatz wird protokolliert +- vollständiger Callback-Body erscheint im Software-Debugprotokoll +- bei einem Parserfehler erscheint der komplette Body zusätzlich im Job-Log +- Verarbeitungsschritte und Datenbank-Commit werden einzeln protokolliert + +## Diagnoseoberfläche + +- Job-ID als eigene Spalte in der Auftragsübersicht +- Job-ID in der Auftragsdetailansicht +- „Log kopieren“ im Software-Debugprotokoll +- „Log kopieren“ für Client-, MeshCtrl-Standard- und MeshCtrl-Fehlerausgabe + +Bestehende Backup-, MeshCentral-, Theme-, Tabellen- und Einstellungsfunktionen +wurden nicht verändert. diff --git a/UPDATE-0.5.0.4.txt b/UPDATE-0.5.0.4.txt new file mode 100644 index 0000000..8485d40 --- /dev/null +++ b/UPDATE-0.5.0.4.txt @@ -0,0 +1,44 @@ +# AssetManager v0.5.0.4 + +## Softwareliste pro Asset + +Die vom Callback gespeicherten Softwareeinträge werden als Softwareliste des +jeweiligen Assets dargestellt. + +Die Aktion unterscheidet jetzt zwischen: + +- „Softwareliste initialisieren“, wenn noch keine Einträge vorhanden sind +- „Softwareinventur aktualisieren“, sobald eine Inventur gespeichert wurde + +Zusätzlich wird die Anzahl der gespeicherten Einträge sichtbar angezeigt. +Bei leerer Liste erscheint ein eigener Initialisierungshinweis. + +Die Inventur übernimmt weiterhin bewusst alle vom Client gemeldeten Einträge. +Es werden in dieser Version noch keine Windows-, Microsoft- oder sonstigen +Standardkomponenten ausgeschlossen. + +## Ausschlussregeln vorbereitet + +Auf der Software-Einstellungsseite ist der künftige Bereich für Regeln nach: + +- Softwarename +- Hersteller +- Suchmuster bzw. regulärem Ausdruck + +bereits sichtbar gekennzeichnet. Die Regeln sind noch nicht aktiv, damit die +erste vollständige Datengrundlage unverändert aufgebaut werden kann. + +## Log kopieren korrigiert + +`navigator.clipboard` ist bei einer normalen HTTP-Adresse häufig nicht +verfügbar, weil die Clipboard-API einen sicheren Browserkontext verlangt. + +Der Kopiervorgang verwendet nun: + +1. die moderne Clipboard-API bei HTTPS bzw. sicherem Kontext +2. einen kompatiblen Fallback über ein temporäres Textfeld bei HTTP + +Die Schaltfläche zeigt anschließend „Kopiert“ oder „Kopieren fehlgeschlagen“. + +Bestehende Callback-, Datenbank-, MeshCentral-, Backup- und Themefunktionen +wurden nicht verändert. diff --git a/UPDATE-0.5.0.5.txt b/UPDATE-0.5.0.5.txt new file mode 100644 index 0000000..a1fcf6d --- /dev/null +++ b/UPDATE-0.5.0.5.txt @@ -0,0 +1,48 @@ +# AssetManager v0.5.0.5 + +## Softwareliste im tatsächlichen Asset-Inventar + +Die gespeicherte Callback-Inventur wird nun im vorhandenen Bereich +„Geräteinventar → Software“ angezeigt. Dieser Bereich hatte vorher nur das +Softwaresegment der allgemeinen MeshCentral-Detailabfrage verwendet und konnte +die neue Tabelle `installed_software` deshalb nicht sehen. + +Im Software-Tab stehen jetzt: + +- Button „Softwareliste initialisieren“, wenn noch keine Liste vorhanden ist +- danach „Softwareinventur aktualisieren“ +- Anzahl der gespeicherten Einträge +- letzter Auftrag mit Job-ID und Status +- letzte Inventur +- Tabelle mit Name, Version, Hersteller, Architektur, Installationsdatum und + letzter Sichtung +- Filterung und Sortierung über die bestehenden Tabellenwerkzeuge + +Nach dem Start einer Inventur wird wieder das Asset geöffnet und der +Software-Tab automatisch ausgewählt. + +## Zentrale Softwareübersicht + +Der Navigationspunkt „Software und Aufträge“ enthält jetzt: + +1. Alle Softwareinstallationen + - eine Zeile je Software und Asset + - Link zum jeweiligen Asset + - Spaltenfilter und Sortierung + +2. Kumulierte Softwareliste + - Gruppierung nach Softwarename und Hersteller + - vorhandene Versionen + - Anzahl unterschiedlicher Versionen + - Anzahl betroffener Assets + - Anzahl der Einträge + - letzte Sichtung + +3. Letzte Softwareaufträge + - bestehende Jobübersicht mit Job-ID und Status + +Die Inventur übernimmt weiterhin alle Einträge. Ausschlussregeln bleiben +vorbereitet, aber noch deaktiviert. + +Bestehende Callback-, Backup-, MeshCentral-, Theme- und Datenbankfunktionen +wurden nicht verändert. diff --git a/UPDATE-0.5.0.6.txt b/UPDATE-0.5.0.6.txt new file mode 100644 index 0000000..adab545 --- /dev/null +++ b/UPDATE-0.5.0.6.txt @@ -0,0 +1,45 @@ +# AssetManager v0.5.0.6 + +## Softwaretabellen auf eigenen Seiten + +Die zentrale Softwareübersicht ist jetzt kompakt und verlinkt auf: + +- `/software/installations` + Detaillierte Liste aller Softwareinstallationen über alle Assets. + +- `/software/summary` + Kumulierte Softwareliste mit Versionen und Häufigkeiten. + +Die Auftragsübersicht bleibt auf der zentralen Softwareseite. + +## Numerische Filteroperatoren + +Ausschließlich die numerischen Spalten der kumulierten Softwareliste +unterstützen jetzt: + +- `< 2` +- `<= 5` +- `> 10` +- `>= 3` +- `= 1` +- `1..5` +- `1-5` +- `zwischen 1 und 5` +- `between 1 and 5` + +Die Erweiterung ist opt-in über `data-numeric-filter="1"` und verändert das +Verhalten bestehender Tabellen und Textfilter nicht. + +## Darkmode + +Korrigiert wurden: + +- Toastmeldungen einschließlich Erfolgs-, Warn- und Fehlervarianten +- Schließen-Schaltfläche der Toasts +- Überschriften im Geräteinventar +- Inventar-Tabs +- Softwarebereich im Geräteinventar +- leere Inventurzustände + +Bestehende Callback-, Inventur-, Backup-, MeshCentral- und Datenbankfunktionen +wurden nicht verändert. diff --git a/UPDATE-0.5.0.7.txt b/UPDATE-0.5.0.7.txt new file mode 100644 index 0000000..ed176ef --- /dev/null +++ b/UPDATE-0.5.0.7.txt @@ -0,0 +1,57 @@ +# AssetManager v0.5.0.7 + +## MeshCentral Presence Service + +Diese Version ergänzt einen eigenständigen Online-Status für Assets mit +MeshCentral Node-ID. + +Neue Assetfelder: + +- `mesh_online` +- `mesh_online_state` +- `mesh_presence_updated_at` +- `mesh_last_online_at` +- `mesh_presence_source` + +Assets ohne `mesh_node_id` werden nicht bei MeshCentral abgefragt und erhalten +keinen künstlichen Offline-Status. + +## Statusermittlung + +Der Presence-Service verwendet dieselbe MeshCentral-Konfiguration und dieselben +Zugangsdaten wie die bestehende Synchronisierung. + +- MeshCentral-Geräteliste wird standardmäßig alle 30 Sekunden abgefragt. +- `conn > 0` wird als online interpretiert. +- `conn = 0` wird als offline interpretiert. +- nicht eindeutig zuordenbare Zustände bleiben `unknown`. +- nur erfolgreich gelesene Abfragen ändern den gespeicherten Status. + +Die Browseroberfläche liest ausschließlich den gespeicherten Status aus der +AssetManager-Datenbank und aktualisiert sichtbare Anzeigen alle fünf Sekunden. +Dadurch entsteht keine zusätzliche MeshCentral-Abfrage pro Browser oder Asset. + +## Live-Anzeige + +Der Online-Status erscheint: + +- auf der Asset-Detailseite +- im Geräteinventar mit letzter Aktualisierung und letzter Online-Sichtung +- als eigene Spalte in der Assetliste + +## Manuelle Softwareinventur + +Die manuelle Softwareinventur wurde nicht verändert. Der Button bleibt +dauerhaft verfügbar und startet den Auftrag weiterhin unmittelbar. Der neue +Presence-Service blockiert, verzögert oder ersetzt keine manuell gestartete +Inventur. + +## Ausblick + +Der gespeicherte Presence-Status bildet die Grundlage für einen späteren +Dispatcher, der geplante oder wartende Aufträge erst bei einem Online-Signal +versendet. Diese Warteschlangenlogik ist in dieser Version bewusst noch nicht +aktiviert. + +Bestehende Callback-, Software-, Backup-, MeshCentral-Synchronisierungs-, +Tabellen- und Themefunktionen wurden nicht verändert. diff --git a/UPDATE-0.5.0.8.txt b/UPDATE-0.5.0.8.txt new file mode 100644 index 0000000..22fa724 --- /dev/null +++ b/UPDATE-0.5.0.8.txt @@ -0,0 +1,37 @@ +# AssetManager v0.5.0.8 + +## Spaltenverwaltung im Darkmode + +Das Kontextmenü per Rechtsklick auf einen Spaltentitel und der Dialog zur +Spaltenverwaltung verwenden nun die Darkmode-Farben für Hintergrund, Text, +Rahmen, Hoverzustände und Schatten. + +## Job-Aktion + +`software_jobs` besitzt nun das neue Feld `action`. Bestehende Jobs erhalten +durch die Migration den Standardwert `run`. Die Softwareinventur erzeugt +weiterhin direkt ausführbare Jobs mit: + +- Paket/Jobtyp: Softwareinventur +- Aktion: `run` +- Parameter: jobbezogenes JSON + +Die Datenstruktur ist damit für spätere Aktionen wie `install`, `uninstall` +und `update` vorbereitet, ohne die Paketlogik in dieser Version vorwegzunehmen. + +## Softwareinventur für markierte Assets + +Administratoren können in der Assetliste mehrere sichtbare Assets markieren +und über „Softwareinventur starten“ für jedes Asset einen eigenen Job erzeugen. + +- Ein Asset kann beliebig viele Jobs besitzen. +- Es gibt keine Ein-Job-pro-Asset-Beschränkung. +- Assets ohne MeshCentral Node-ID oder ohne unterstützte Plattform werden + übersprungen. +- Nach Abschluss zeigt eine Meldung die Anzahl erzeugter und übersprungener + Jobs. +- Die bestehende manuelle Softwareinventur auf der Assetseite bleibt + unverändert erhalten. + +Noch nicht enthalten: automatisches Warten auf Online-Status, Paketaktionen +install/uninstall/update und Job-Workflows. diff --git a/UPDATE-0.5.0.9.txt b/UPDATE-0.5.0.9.txt new file mode 100644 index 0000000..bbb0f36 --- /dev/null +++ b/UPDATE-0.5.0.9.txt @@ -0,0 +1,46 @@ +# AssetManager v0.5.0.9 + +## Korrektur der Sammelroute + +Die bisherige Route unter `/assets/bulk-jobs/software-inventory` konnte von +FastAPI als dynamische Asset-ID interpretiert werden. Dadurch entstand beim +Aufruf der Fehler, dass `bulk-jobs` nicht als Integer gelesen werden konnte. + +Die Sammelroute lautet nun eindeutig: + + POST /jobs/bulk-create + +Sie kollidiert nicht mehr mit `/assets/{asset_id}`. + +## Allgemeine Job-Auswahl in der Assetliste + +Der feste Button „Softwareinventur starten“ wurde ersetzt durch: + +- Auswahlfeld „Job-Art“ +- Button „Job starten“ + +Aktuell steht als Job-Art zur Verfügung: + +- Softwareinventur + +Die Oberfläche und die Backendroute sind bereits so aufgebaut, dass später +weitere Job-Arten ergänzt werden können, ohne die Sammelbedienung erneut +umzubauen. + +Pro markiertem Asset wird weiterhin ein eigenständiger Job erzeugt. Mehrere +Jobs pro Asset sind weiterhin ausdrücklich erlaubt. + +## Abgrenzung dieses Entwicklungsschritts + +Diese Version ergänzt noch keine neuen Paketaktionen. Die bestehende +Softwareinventur verwendet weiterhin: + +- Job-Art: Softwareinventur +- Aktion: run +- Parameter: {} + +`install`, `uninstall`, `update` und paketabhängige Parameter folgen in +späteren, kleinen Entwicklungsschritten. + +Die direkte manuelle Softwareinventur auf der Asset-Detailseite bleibt +unverändert verfügbar. diff --git a/UPDATE-0.5.1.0.txt b/UPDATE-0.5.1.0.txt new file mode 100644 index 0000000..15ebd34 --- /dev/null +++ b/UPDATE-0.5.1.0.txt @@ -0,0 +1,26 @@ +# AssetManager v0.5.1.0 – Allgemeines Jobmodell + +Diese Version erweitert ausschließlich das bestehende Jobfundament. + +## Neue Jobfelder +- Job-Art (`job_type`) +- Aktion (`action`, bereits vorhanden) +- Priorität (`priority`) +- Parameter als JSON (`parameters`, bereits vorhanden) +- Ergebnisdaten als JSON (`result_data`) +- Anzahl Versuche (`attempt_count`) +- Maximale Versuche (`max_attempts`) +- Geplanter Zeitpunkt (`scheduled_at`) +- Ersteller (`created_by`, bereits vorhanden) +- Start-, Sende- und Endzeitpunkte (bereits vorhanden) + +Bestehende Softwareinventur-Jobs werden automatisch als `software_inventory` mit Priorität 100 eingeordnet. + +## Oberfläche +Die zentrale Jobliste, die Asset-Softwareseite und die Jobdetails zeigen nun Job-Art, Aktion und Priorität. Die Detailseite zeigt zusätzlich Parameter, Ergebnisdaten, Ersteller, Planung und Versuche. + +## Kompatibilität +- Neue primäre Detailroute: `/jobs/{job_id}` +- Alte Route `/software/jobs/{job_id}` bleibt als Weiterleitung erhalten. +- Die Softwareinventur und Sammelausführung bleiben funktional unverändert. +- Noch keine neuen Jobarten, Softwarepakete oder Dispatcher-Funktion. diff --git a/UPDATE-0.5.1.1.txt b/UPDATE-0.5.1.1.txt new file mode 100644 index 0000000..3ed6777 --- /dev/null +++ b/UPDATE-0.5.1.1.txt @@ -0,0 +1,22 @@ +# AssetManager v0.5.1.1 + +## Lokale Zeitanzeige bei UTC-Speicherung + +- Datenbank, APIs und Logdateien bleiben in UTC. +- Sichtbare Zeitstempel werden zentral im Browser in dessen lokale Zeitzone umgerechnet. +- Die Umrechnung berücksichtigt automatisch Sommer- und Winterzeit. +- Tabellen behalten den UTC-Wert für eine korrekte Sortierung. +- Der originale UTC-Wert und die erkannte Browser-Zeitzone stehen als Tooltip zur Verfügung. +- Dynamisch aktualisierte Jobzeiten, Presence-Zeiten und MeshCentral-Live-Logs werden ebenfalls umgerechnet. +- Sichtbare Zeitstempel im Software-Debuglog werden lokalisiert; die Datei auf dem Server bleibt unverändert UTC. + +## Abgedeckte Bereiche + +- Joblisten und Jobdetails +- Softwareinventur und Softwareübersichten +- Assetdetails und Presence-Anzeige +- MeshCentral-Synchronisationsläufe und Live-Log +- Benutzerprofil und Benutzerverwaltung +- Sicherungsübersicht + +Es ist keine Änderung der Server- oder Container-Zeitzone erforderlich. diff --git a/UPDATE-0.5.1.2.txt b/UPDATE-0.5.1.2.txt new file mode 100644 index 0000000..1300ed4 --- /dev/null +++ b/UPDATE-0.5.1.2.txt @@ -0,0 +1,27 @@ +# AssetManager v0.5.1.2 + +## Jobs erneut starten + +- Bestehende Jobs können aus der zentralen Jobliste, der Asset-Softwareseite und der Jobdetailseite erneut gestartet werden. +- Es wird kein neuer Jobdatensatz erzeugt; die Job-ID bleibt erhalten. +- Für jeden Neustart wird ein neuer Callback-Token erzeugt. Alte Callback-Tokens werden dadurch ungültig. +- Laufzeitfelder und Ergebnisse des vorherigen Versuchs werden zurückgesetzt. +- Der Versuchszähler wird beim erneuten Sendeversuch erhöht. +- Aktive Jobs in den Zuständen `sending` oder `running` können nicht parallel erneut gestartet werden. + +## Jobverlauf + +Die neue Tabelle `job_events` dokumentiert unter anderem: + +- Erstellung +- manuellen Neustart +- Sendeversuch +- MeshCtrl-Ergebnis +- Callback +- Fehler + +Der Verlauf wird auf der Jobdetailseite angezeigt. Bestehende ältere Jobs besitzen naturgemäß erst ab diesem Update Verlaufsereignisse. + +## Unverändert + +Konfiguration, Daten, Uploads, Logs, Backups und die Laufzeitdatei APPINFO.json werden nicht ausgeliefert oder überschrieben. diff --git a/UPDATE-0.5.1.3.txt b/UPDATE-0.5.1.3.txt new file mode 100644 index 0000000..850aea4 --- /dev/null +++ b/UPDATE-0.5.1.3.txt @@ -0,0 +1,8 @@ +# AssetManager v0.5.1.3 + +- Plattform wird direkt in jedem Software-Inventareintrag gespeichert. +- Vorhandene Einträge werden beim Update anhand des letzten Softwarejobs nachgepflegt. +- Plattform ist in Asset-Softwareliste, Installationsreport und Zusammenfassung sichtbar und filterbar. +- Einzelne, mehrere oder alle aktuell sichtbaren Inventareinträge können markiert und gelöscht werden. +- Löschen entfernt ausschließlich Inventardaten und deinstalliert keine Software. +- Die nächste Inventur legt tatsächlich vorhandene Einträge wieder an. diff --git a/UPDATE-0.5.1.4.txt b/UPDATE-0.5.1.4.txt new file mode 100644 index 0000000..4da8791 --- /dev/null +++ b/UPDATE-0.5.1.4.txt @@ -0,0 +1,32 @@ +AssetManager 0.5.1.4 +==================== + +Assetliste +---------- +- Der Button „Inhalt neu laden“ ersetzt nicht mehr das komplette tbody-Element. +- Filter, Sortierung und bestehende Tabellen-Eventhandler bleiben dadurch aktiv. +- Markierungen werden nach dem Nachladen korrekt zurückgesetzt. +- Auswahlzähler und Aktionsbuttons werden nach dem Reload neu berechnet. +- „Alle markieren“ funktioniert nach dem Reload wieder für die sichtbaren Zeilen. +- Der Asset-Zähler wird gezielt über die Klasse asset-total aktualisiert. + +Backups und Systeminformationen +------------------------------- +- Docker-Compose verwendet für Backups standardmäßig ./data/backups:/data/backups. +- Beim Start wird das Backupverzeichnis auf Existenz und Schreibbarkeit geprüft. +- Der tatsächlich verwendete Backup-Pfad und der erkannte Mount-Status werden protokolliert. +- Unter Einstellungen → Systeminformationen werden jetzt angezeigt: + - PostgreSQL-Verbindungsstatus + - Konfigurationsverzeichnis + - Uploadverzeichnis + - Logverzeichnis + - Backupverzeichnis + - Schreibbarkeit des Backupverzeichnisses + - Erkennung eines eigenen Docker-Mounts + - letztes Backup + - Anzahl vorhandener Backups + - belegter Backup-Speicher + +Hinweis +------- +Die Anwendung kann nur den Containerpfad und den Mount-Status erkennen. Der konkrete Hostpfad wird weiterhin durch die docker-compose.yml festgelegt. diff --git a/UPDATE-0.5.1.5.txt b/UPDATE-0.5.1.5.txt new file mode 100644 index 0000000..7a0b6ff --- /dev/null +++ b/UPDATE-0.5.1.5.txt @@ -0,0 +1,21 @@ +AssetManager v0.5.1.5 +======================== + +Kategorien: Sammelbearbeitung +- Markierte Kategorien können über „Feld ändern“ gemeinsam bearbeitet werden. +- Änderbar sind Beschreibung und Kategoriebild. +- Der Kategoriename kann bei genau einer markierten Kategorie geändert werden. +- Felder können ausdrücklich geleert werden. +- „Alle markieren“ berücksichtigt nur aktuell sichtbare/gefilterte Kategorien. +- Auswahlzähler und Aktionsbuttons werden konsistent aktualisiert. +- Serverseitige Prüfung von Auswahl, Feld, Pflichtwerten und eindeutigen Kategorienamen. +- Alle neuen sichtbaren Texte wurden in Deutsch und Englisch in das Übersetzungssystem aufgenommen. + +Hinweis zur Tabellenstruktur +- Aktuell bestehen eigenständige Verwaltungslisten für Assets, Kategorien und Benutzer. +- Hersteller, Lieferanten, Standorte, Gruppen und Lizenztypen waren Zukunftsbeispiele und sind in dieser Version nicht als eigene Tabellen eingeführt. +- Die Benutzerverwaltung bleibt wegen ihrer komplexen Berechtigungsformulare vorerst unverändert. + +Update +- Persistente Konfigurations-, Daten-, Upload-, Log- und Backup-Verzeichnisse nicht überschreiben. +- Container nach Austausch der Programmdateien neu erstellen/starten. diff --git a/UPDATE-0.5.1.6.txt b/UPDATE-0.5.1.6.txt new file mode 100644 index 0000000..95433b1 --- /dev/null +++ b/UPDATE-0.5.1.6.txt @@ -0,0 +1,18 @@ +AssetManager v0.5.1.6 +======================== + +Fehlerkorrektur: optionale Zahlenfelder in Formularen +- Leere HTML-Zahlenfelder werden jetzt serverseitig zuverlässig als NULL behandelt. +- Das Feld „Breite in Listen (Pixel)“ kann leer bleiben, ohne dass FastAPI mit einem int_parsing-Fehler abbricht. +- Erstellen und Bearbeiten von Felddefinitionen verwenden dieselbe zentrale Konvertierung. +- Gültige Werte werden weiterhin auf den erlaubten Bereich von 40 bis 800 Pixel begrenzt. +- Ungültige, nichtnumerische Werte führen zu einer kontrollierten HTTP-400-Meldung statt zu einer technischen 422-Pydantic-Antwort. + +Ursache +- Browser übertragen ein leeres als leere Zeichenfolge. +- Der bisherige Parameter int | None wurde von FastAPI bereits vor dem Aufruf der Route validiert; eine leere Zeichenfolge ist dabei weder Integer noch NULL. +- Der Fehler stand nicht in direktem Zusammenhang mit dem Entfernen des Hakens „Standard-Feld“. Beim Speichern wurde lediglich das gesamte Formular einschließlich des leeren Feldes list_width übertragen. + +Update +- Persistente Konfigurations-, Daten-, Upload-, Log- und Backup-Verzeichnisse nicht überschreiben. +- Container nach Austausch der Programmdateien neu erstellen/starten. diff --git a/UPDATE-0.5.1.7.txt b/UPDATE-0.5.1.7.txt new file mode 100644 index 0000000..06a22f2 --- /dev/null +++ b/UPDATE-0.5.1.7.txt @@ -0,0 +1,11 @@ +AssetManager v0.5.1.7 + +- Neue Massenaktion „Felder verwalten“ in der Kategorienliste +- Felder in mehreren Kategorien gleichzeitig hinzufügen/aktivieren +- Felder in mehreren Kategorien gleichzeitig entfernen/deaktivieren +- Pflichtfeld- und Listenanzeige-Einstellungen gemeinsam ändern +- Komplette Feldkonfiguration einer Vorlagenkategorie übernehmen +- Assetdaten bleiben beim Entfernen oder Übernehmen erhalten +- Das Namensfeld bleibt geschützt und kann nicht entfernt werden +- Allgemeine Tabellenlogik wurde nicht verändert +- Alle neuen sichtbaren Texte sind in Deutsch und Englisch übersetzt diff --git a/UPDATE-0.5.2.0.txt b/UPDATE-0.5.2.0.txt new file mode 100644 index 0000000..af3a987 --- /dev/null +++ b/UPDATE-0.5.2.0.txt @@ -0,0 +1,15 @@ +AssetManager v0.5.2.0 – Asset-Sichtbarkeit und Mitarbeiterprofil + +Neu: +- Mehrfach auswählbare Sichtbarkeitsregeln je Benutzer: eigene Geräte, eigene Abteilung, eigener Standort, alle Geräte. +- Bestehende MeshCentral-Gruppen bleiben erhalten und werden mit allen Sichtbarkeitsregeln per ODER verknüpft. +- Mitarbeiterprofil mit Abteilung und Standort in der Benutzerverwaltung. +- Eigene Geräte werden über den Benutzernamen im Assetfeld „Zugeordnet“ erkannt. +- Zentrale Zugriffsfunktion schützt Assetlisten, Detailansichten und darauf aufbauende Softwareansichten. +- Benutzer ohne aktive Regel und ohne MeshCentral-Gruppe sehen keine Assets. +- Administratoren sehen weiterhin alle Assets. +- Neue Datenbankfelder werden automatisch per Lightweight-Migration ergänzt. +- Alle sichtbaren neuen Texte sind in Deutsch und Englisch übersetzt. + +Hinweis: +Nach einer Rechteänderung muss sich der betroffene Benutzer einmal ab- und wieder anmelden, damit die Sitzung die neuen Regeln übernimmt. diff --git a/UPDATE-0.5.2.1.txt b/UPDATE-0.5.2.1.txt new file mode 100644 index 0000000..08d87a7 --- /dev/null +++ b/UPDATE-0.5.2.1.txt @@ -0,0 +1,8 @@ +AssetManager v0.5.2.1 + +Bugfix Einstellungen: +- Darstellung und LDAP/Authentifizierung konnten nicht geöffnet werden. +- Ursache: Das gemeinsame Template settings.html rendert alle Einstellungsbereiche. Die Laufzeitvariable system_storage wurde jedoch nur für die Seite Systeminformationen bereitgestellt. +- system_storage wird nun für alle Einstellungsrouten bereitgestellt. +- Die Systeminformationen bleiben weiterhin nur im Bereich System sichtbar. +- Keine Änderung an Datenbank, Berechtigungsmodell oder Tabellenlogik. diff --git a/UPDATE-0.5.2.2.txt b/UPDATE-0.5.2.2.txt new file mode 100644 index 0000000..a812042 --- /dev/null +++ b/UPDATE-0.5.2.2.txt @@ -0,0 +1,13 @@ +AssetManager 0.5.2.2 +==================== + +Presence-Status korrigiert und vereinheitlicht: +- Online: MeshCentral meldet aktuell eine aktive Verbindung. +- Offline: aktuell nicht verbunden, aber ein historischer Zeitpunkt "Zuletzt gesehen" ist vorhanden. +- Unbekannt: keine aktive Verbindung und noch kein historischer Verbindungszeitpunkt vorhanden. +- MeshCentral-Zeitfelder werden robust aus unterschiedlichen CLI-JSON-Varianten gelesen. +- Der vollständige MeshCentral-Sync setzt "Zuletzt gesehen" nicht mehr pauschal auf die Synczeit. +- Assetliste und Detailansicht zeigen konsistente Live-Statuswerte und "Zuletzt gesehen". +- Erweiterte Debug-Protokollierung für Node-ID, Livezustand, letzte Verbindung, Adresse und abgeleiteten Status. + +Bestehende Konfigurationen, Daten, Uploads, Logs und Sicherungen bleiben erhalten. diff --git a/UPDATE-0.5.3.0.txt b/UPDATE-0.5.3.0.txt new file mode 100644 index 0000000..6d0714c --- /dev/null +++ b/UPDATE-0.5.3.0.txt @@ -0,0 +1,11 @@ +AssetManager 0.5.3.0 + +- Administratorbereich „Jobdefinitionen“ +- Skriptquellen Inline, gemountetes Verzeichnis und UNC-Pfad +- Interpreter PowerShell, CMD/Batch, Bash und Python +- Validierungs-/Testfunktion ohne produktive Ausführung +- automatische Revisionen bei Änderungen +- Softwareinventur als bearbeitbare System-Jobdefinitionen für Windows und Linux +- persistenter Skript-Mount ./data/scripts:/scripts + +Bestehende Konfigurationen, Daten, Uploads, Logs und Sicherungen bleiben erhalten. diff --git a/UPDATE-0.5.3.1.txt b/UPDATE-0.5.3.1.txt new file mode 100644 index 0000000..15bd344 --- /dev/null +++ b/UPDATE-0.5.3.1.txt @@ -0,0 +1,7 @@ +AssetManager 0.5.3.1 + +- Vollständige deutsche und englische Übersetzungen für alle jobdefs.*-Texte. +- Jobdefinitionstest zeigt bei Inline-Skripten eine Vorschau mit ersetzten Beispielvariablen. +- Vorschau kann direkt in die Zwischenablage kopiert werden. +- Generator für einen Ausführungsrahmen mit Logging, Fehlerbehandlung und Callback für PowerShell, CMD, Bash und Python. +- Übersetzungsprüfung als Release-Werkzeug ergänzt. diff --git a/UPDATE-0.5.3.2.txt b/UPDATE-0.5.3.2.txt new file mode 100644 index 0000000..6aa42cc --- /dev/null +++ b/UPDATE-0.5.3.2.txt @@ -0,0 +1,22 @@ +AssetManager 0.5.3.2 +==================== + +Korrektur +--------- +- Behebt den Startabbruch aus 0.5.3.1: app.version stellt APP_VERSION wieder bereit. +- __version__ bleibt als kompatibler Alias erhalten. + +Qualitaetssicherung +------------------- +- Neuer Release-Validator unter tools/validate_release.py. +- Prueft APP_VERSION und __version__, Python-Kompilierung, Template-Laden, + Jobdefinitions-Uebersetzungen sowie den Import der FastAPI-Anwendung. +- Der Validator kann vor dem Packen mit folgendem Befehl ausgefuehrt werden: + + python tools/validate_release.py + +Enthalten bleiben +----------------- +- Vollstaendige deutsche und englische jobdefs.*-Uebersetzungen. +- Inline-Skriptvorschau mit ersetzten Beispielvariablen und Kopierfunktion. +- Generator fuer PowerShell-, Batch-, Bash- und Python-Ausfuehrungsrahmen. diff --git a/UPDATE-0.5.3.3.txt b/UPDATE-0.5.3.3.txt new file mode 100644 index 0000000..664978f --- /dev/null +++ b/UPDATE-0.5.3.3.txt @@ -0,0 +1,8 @@ +AssetManager v0.5.3.3 + +- German terminology standardized from "Auftrag/Aufträge" to "Job/Jobs" throughout software and job pages. +- Dark-mode category initials on the dashboard use a dark foreground on the light placeholder tile. +- Fixed the job-framework generator: unescaped Bash template expressions caused the page JavaScript to stop before event handlers were registered. +- Added a safe "Insert test script" action for PowerShell, CMD, Bash and Python. +- Job framework and test script insertion now trigger an input event and a translated success message. +- All new visible texts are translated in German and English. diff --git a/UPDATE-0.5.3.4.txt b/UPDATE-0.5.3.4.txt new file mode 100644 index 0000000..5cc8d55 --- /dev/null +++ b/UPDATE-0.5.3.4.txt @@ -0,0 +1,8 @@ +AssetManager v0.5.3.4 + +- Jobdefinitionen können direkt aus einer Asset-Detailseite gestartet werden. +- In der Assetliste können Jobdefinitionen für mehrere markierte Assets gestartet werden. +- Bei einheitlicher Auswahlplattform werden nur passende Definitionen angezeigt. +- Bei gemischter Auswahl werden unpassende Assets ohne Benutzerfehler übersprungen. +- MeshCentral RunCommand führt Inline-, gemountete und UNC-basierte Definitionen aus. +- Neue sichtbare Texte vollständig in Deutsch und Englisch. diff --git a/UPDATE-0.5.3.5.txt b/UPDATE-0.5.3.5.txt new file mode 100644 index 0000000..e7bc400 --- /dev/null +++ b/UPDATE-0.5.3.5.txt @@ -0,0 +1,12 @@ +AssetManager 0.5.3.5 + +- Callback-Verarbeitung vereinheitlicht: Jede gültige Callback-Rückmeldung aktualisiert Client-Protokoll, Status, Exit-Code, Meldung, Ergebnisdaten und Historie. +- Callback ohne eigenes log-Feld erzeugt automatisch einen nachvollziehbaren Client-Protokolleintrag. +- STDOUT und STDERR aus Callback-Payloads werden im Client-Protokoll übernommen. +- Das vollständige aufgelöste Skript mit echten Asset-, Job- und Callback-Werten wird pro Job gespeichert. +- Auf der Jobdetailseite kann das lesbare, aufgelöste Skript angezeigt und kopiert werden. +- Der technische MeshCentral-Befehl bleibt separat verfügbar. +- Neue PowerShell-Ausführungsrahmen senden ihr lokales Protokoll im Callback zurück. +- Testskripte verwenden Write-JobLog. +- Datenbankmigration ergänzt software_jobs.resolved_script. +- Deutsche und englische Übersetzungen ergänzt. diff --git a/UPDATE-0.5.3.6.txt b/UPDATE-0.5.3.6.txt new file mode 100644 index 0000000..0dd3465 --- /dev/null +++ b/UPDATE-0.5.3.6.txt @@ -0,0 +1,8 @@ +AssetManager v0.5.3.6 + +- Job-Rahmen und Testskripte werden serverseitig erzeugt. +- Wrapper liegen als getrennte Vorlagendateien unter app/templates/jobs. +- Frontend verwendet nur noch einen kleinen JSON-Aufruf und setzt den gelieferten Text in den Editor. +- Statusanzeige, Toast-Fehler und Browser-/Server-Diagnose ergänzt. +- Generator ist ausschließlich für Administratoren erreichbar. +- Keine Konfigurations-, Datenbank-, Upload-, Log- oder Sicherungsdaten werden überschrieben. diff --git a/UPDATE-0.5.3.7.txt b/UPDATE-0.5.3.7.txt new file mode 100644 index 0000000..553c277 --- /dev/null +++ b/UPDATE-0.5.3.7.txt @@ -0,0 +1,10 @@ +AssetManager 0.5.3.7 + +- Aktiv-Checkbox linksbündig am Anfang der Jobdefinition +- Jobdefinition enthält nur noch eigene Skriptbefehle; Laufzeitrahmen wird automatisch erzeugt +- Parameterdefinitionen mit Schlüssel, Bezeichnung, Beschreibung, Typ und Standardwert +- Asset-spezifische Parameter-Overrides direkt im Asset +- Beschreibungen werden aus der Jobdefinition angezeigt und nicht dupliziert +- Entfernte Parameter und ihre Overrides werden per FK-Cascade bereinigt +- Effektive Parameter werden beim Erzeugen des Jobs als Snapshot gespeichert +- Parameterplatzhalter {{Param.schluessel}} oder {{schluessel}} diff --git a/UPDATE-0.5.3.8.txt b/UPDATE-0.5.3.8.txt new file mode 100644 index 0000000..3928042 --- /dev/null +++ b/UPDATE-0.5.3.8.txt @@ -0,0 +1,11 @@ +AssetManager 0.5.3.8 + +- Jobdefinitionen enthalten nur noch den fachlichen Skriptcode. +- Der technische Wrapper wird immer erst zur Laufzeit aus den zentralen Interpreter-Templates erzeugt. +- Die bisherige Kompatibilitaetserkennung fuer bereits eingerahmte Skripte wurde entfernt. +- Die Systemdefinitionen der Windows- und Linux-Softwareinventur werden auf reine Skriptkoerper normalisiert. +- Wrapper koennen fachliche Ergebnisdaten ueber $JobResult beziehungsweise job_result an den Callback uebergeben. +- Im Asset wird nun eine Jobliste angezeigt, auch wenn eine Jobdefinition keine Parameter besitzt. +- Parameterbeschreibungen werden direkt aus der Jobdefinition angezeigt und nicht doppelt gespeichert. +- Neue Parameter erscheinen automatisch mit ihrem Standardwert; entfernte Parameter verschwinden aus der Bearbeitung. +- Alle neuen sichtbaren Texte wurden in Deutsch und Englisch ergaenzt. diff --git a/UPDATE-0.5.3.9.txt b/UPDATE-0.5.3.9.txt new file mode 100644 index 0000000..43e82e9 --- /dev/null +++ b/UPDATE-0.5.3.9.txt @@ -0,0 +1,14 @@ +AssetManager 0.5.3.9 + +- Dokumentation zu Variablen, Parametern und Hilfsfunktionen direkt in der Jobdefinition. +- Interpreterabhängige Beispiele für PowerShell, Python, Bash und CMD. +- Hinweis auf den Unterschied zwischen Inline/Mounted und UNC-Skripten. +- Eigene unbenutzte Jobdefinitionen können dauerhaft gelöscht werden. +- Systemdefinitionen und bereits von Jobs referenzierte Definitionen bleiben geschützt und können stattdessen deaktiviert werden. +- Deutsche und englische Übersetzungen ergänzt. +- Jobdefinitionsliste zeigt standardmäßig nur aktive Definitionen. +- Filter kann deaktiviert werden, um auch deaktivierte Definitionen einzublenden. +- Suche nach Name oder Beschreibung ergänzt. +- Sortierung: aktive Definitionen zuerst, danach alphabetisch nach Name. +- Eigene Statusspalte mit Aktiv/Deaktiviert und Systemkennzeichnung. +- Löschschaltflächen für geschützte Definitionen sind deaktiviert und erklären den Grund per Tooltip. diff --git a/UPDATE-0.5.4.0.txt b/UPDATE-0.5.4.0.txt new file mode 100644 index 0000000..ddd166a --- /dev/null +++ b/UPDATE-0.5.4.0.txt @@ -0,0 +1,14 @@ +AssetManager 0.5.4.0 + +- Neuer deklarativer Jobtyp Windows-Registry. +- Mehrere Registry-Einträge pro Jobdefinition. +- Aktionen: Wert setzen, Wert löschen, Schlüssel rekursiv löschen. +- Fehlende Schlüssel werden beim Setzen automatisch angelegt. +- Unterstützte Typen: REG_SZ, REG_EXPAND_SZ, REG_MULTI_SZ, REG_DWORD, REG_QWORD und REG_BINARY. +- Registry-Werte unterstützen Jobparameter und Assetvariablen. +- Optionale lokale Registry-Sicherung vor Änderungen. +- Ausführungsergebnis je Eintrag im JobResult und Joblog. +- Presence-Hinweise verwenden bei Online-Assets den Zeitstempel der letzten erfolgreichen Presence-Abfrage. +- Datenbankmigration ergänzt job_kind und configuration ohne bestehende Definitionen zu verändern. + +Hinweis: HKCU bezieht sich bei Ausführung über den MeshCentral-Agenten auf dessen Ausführungskonto, normalerweise SYSTEM, und nicht automatisch auf den angemeldeten Benutzer. diff --git a/UPDATE-0.5.4.1.txt b/UPDATE-0.5.4.1.txt new file mode 100644 index 0000000..c1c94ef --- /dev/null +++ b/UPDATE-0.5.4.1.txt @@ -0,0 +1,9 @@ +AssetManager 0.5.4.1 + +- Fehler beim Öffnen einer neuen Jobdefinition behoben: Platzhalter in der Hilfe werden als Text dargestellt und nicht mehr von Jinja ausgewertet. +- Registry-Jobs unterstützen nun die Aktion „Schlüsselberechtigungen setzen“. +- Benutzer/Gruppe/SID, RegistryRights, Allow/Deny, Vererbung und Ersetzen/Hinzufügen der Regel sind konfigurierbar. +- Registry-Pfad, Identität und Berechtigung können Jobparameter enthalten. +- Fehlende Registry-Schlüssel werden auch vor einer Berechtigungsänderung automatisch angelegt. +- Berechtigungsänderungen und die anschließend gefundenen Regeln werden im Joblog und im Ergebnis protokolliert. +- Deutsche und englische Übersetzungen ergänzt. diff --git a/UPDATE-0.5.4.2.txt b/UPDATE-0.5.4.2.txt new file mode 100644 index 0000000..27c200b --- /dev/null +++ b/UPDATE-0.5.4.2.txt @@ -0,0 +1,9 @@ +AssetManager 0.5.4.2 + +- Registry-Berechtigungen werden nun als optionale Erweiterung eines Registry-Eintrags gespeichert. +- Fehler behoben: Berechtigungs-Einträge wurden durch die Formularvalidierung verworfen und erschienen nach dem Speichern nicht mehr. +- Ein Registry-Eintrag kann einen Wert setzen und anschließend Berechtigungen vergeben. +- Für reine Berechtigungsänderungen kann "Registry-Wert setzen" deaktiviert werden; der Schlüssel wird bei Bedarf angelegt. +- Vorhandene Berechtigungs-Einträge aus 0.5.4.1 werden beim Bearbeiten kompatibel übernommen. +- Hauptmenübezeichnung lautet konsistent "Software und Jobs". +- Deutsche und englische Übersetzungen ergänzt. diff --git a/UPDATE-0.5.4.3.txt b/UPDATE-0.5.4.3.txt new file mode 100644 index 0000000..4ed51ef --- /dev/null +++ b/UPDATE-0.5.4.3.txt @@ -0,0 +1,8 @@ +AssetManager 0.5.4.3 + +- Registry editor redesigned around one explicit operation per entry. +- Radio-card selection for setting values, changing permissions, deleting values, or deleting keys. +- Only fields required for the selected operation are displayed. +- Common Windows principals can be selected without entering SIDs. +- Registry permissions provide safe presets for Read, Change, and Full control, while custom RegistryRights remain supported. +- Existing Registry definitions from 0.5.4.2 remain compatible. diff --git a/UPDATE-0.5.4.4.txt b/UPDATE-0.5.4.4.txt new file mode 100644 index 0000000..789a4af --- /dev/null +++ b/UPDATE-0.5.4.4.txt @@ -0,0 +1,6 @@ +AssetManager 0.5.4.4 + +- Registry-Editor: Ein- und Ausblenden der aktionsabhängigen Felder korrigiert. +- Inaktive Eingabebereiche werden zuverlässig verborgen und deaktiviert. +- Wert-, Berechtigungs- und Löschfelder können nicht mehr gleichzeitig sichtbar oder versehentlich aktiv sein. +- Bestehende Registry-Jobdefinitionen bleiben kompatibel. diff --git a/UPDATE-0.5.4.5.txt b/UPDATE-0.5.4.5.txt new file mode 100644 index 0000000..5ae2607 --- /dev/null +++ b/UPDATE-0.5.4.5.txt @@ -0,0 +1,9 @@ +AssetManager 0.5.4.5 + +- Registry principal choices now display their well-known SID in square brackets. +- Registry permission and value option checkboxes are left-aligned and stacked vertically. +- Checkbox layout in the complete job-definition form was normalized. +- Script source, interpreter, arguments and success-code fields are hidden for Registry jobs because they are generated internally. +- Registry parameter help now explains where technical keys are defined and lists the currently available keys. +- Registry parameters remain job-definition-wide and can be reused in multiple Registry entries. +- German and English translations were added. diff --git a/UPDATE-0.5.4.6.txt b/UPDATE-0.5.4.6.txt new file mode 100644 index 0000000..9d4c84f --- /dev/null +++ b/UPDATE-0.5.4.6.txt @@ -0,0 +1,6 @@ +AssetManager 0.5.4.6 + +- Registry-Berechtigungen verwenden jetzt kanonische PowerShell-Providerpfade im Format Registry::HKEY_LOCAL_MACHINE\... +- Get-Acl und Set-Acl verwenden denselben Pfadtyp wie das bekannte funktionierende Administrationsskript. +- Vor ACL-Änderungen wird das tatsächliche Ziel und das Ergebnis von Test-Path in das Joblog geschrieben. +- Normale Registry-Wertaktionen, Löschaktionen und Sicherungen bleiben kompatibel. diff --git a/UPDATE-0.5.4.7.txt b/UPDATE-0.5.4.7.txt new file mode 100644 index 0000000..fb28b3d --- /dev/null +++ b/UPDATE-0.5.4.7.txt @@ -0,0 +1,7 @@ +AssetManager 0.5.4.7 + +- PowerShell jobs use compressed transport to avoid Windows/MeshCtrl command-line truncation. +- Immediate running callback proves that execution started on the client. +- MeshCtrl dispatch diagnostics include node id, command length, script length, SHA-256, timestamps and return code. +- German navigation consistently uses “Software und Jobs”. +- Job-definition checkboxes are left-aligned and stacked cleanly. diff --git a/UPDATE-0.5.4.8.txt b/UPDATE-0.5.4.8.txt new file mode 100644 index 0000000..79ee21c --- /dev/null +++ b/UPDATE-0.5.4.8.txt @@ -0,0 +1,7 @@ +AssetManager 0.5.4.8 + +- Fixes the common Windows job dispatcher used by software inventory, registry jobs and PowerShell job definitions. +- Sends PowerShell source through MeshCtrl's native `--powershell` channel instead of nesting `cmd.exe` and `powershell.exe -EncodedCommand`. +- Uses `--reply` so MeshCtrl waits for the remote agent response rather than returning only the server acknowledgement `OK`. +- Keeps GZip/Base64 compression, but the compact bootstrap is now valid PowerShell source executed directly by the agent. +- Dispatch diagnostics show whether native PowerShell with reply was used. diff --git a/UPDATE-0.5.5.0.txt b/UPDATE-0.5.5.0.txt new file mode 100644 index 0000000..5277251 --- /dev/null +++ b/UPDATE-0.5.5.0.txt @@ -0,0 +1,10 @@ +AssetManager 0.5.5.0 + +- Neuer dateibasierter MeshCentral-Dispatcher für lokale Jobs. +- Skripte werden mit MeshCtrl Upload in ein geschütztes Jobverzeichnis auf dem Client übertragen. +- Windows-Zielpfad: C:\ProgramData\AssetManager\Jobs\-. +- Das Jobverzeichnis wird auf SYSTEM und lokale Administratoren beschränkt. +- Der anschließende RunCommand enthält nur noch den kurzen lokalen Dateiaufruf. +- Erfolgreich per Callback bestätigte Jobs werden automatisch vom Client entfernt. +- Bei fehlendem oder fehlerhaftem Callback bleiben die Dateien zur Diagnose erhalten. +- Dieselbe Transportbasis ist später für Softwarepakete und zusätzliche Paketdateien verwendbar. diff --git a/UPDATE-0.5.5.10.txt b/UPDATE-0.5.5.10.txt new file mode 100644 index 0000000..24203f7 --- /dev/null +++ b/UPDATE-0.5.5.10.txt @@ -0,0 +1,8 @@ +AssetManager 0.5.5.10 + +- Dritte Gruppierung der kumulierten Softwareliste: Name und Plattform. +- Unterschiedliche Herstellerbezeichnungen und Versionen werden dabei innerhalb einer Zeile zusammengefasst. +- Die Statusfelder der sichtbaren letzten Jobs werden automatisch aktualisiert. +- Die Jobliste fragt nur aktive Job-IDs gesammelt in einer schlanken Datenbankabfrage ab. +- Auswahl-Checkboxen und Neustartaktion werden nach Statusänderungen passend aktualisiert. +- Deutsche und englische Übersetzungen ergänzt. diff --git a/UPDATE-0.5.5.11.txt b/UPDATE-0.5.5.11.txt new file mode 100644 index 0000000..cbe0e0b --- /dev/null +++ b/UPDATE-0.5.5.11.txt @@ -0,0 +1,17 @@ +AssetManager 0.5.5.11 +===================== + +Neu: +- Automatische Wiederholung fehlgeschlagener Softwarejobs. +- Berücksichtigt werden ausschließlich die Status Fehler, Teilweise erfolgreich und Zeitüberschreitung. +- Das maximale Jobalter ist unter Software-Einstellungen konfigurierbar; Standard: 12 Stunden. +- Die Wartezeit vor einer weiteren automatischen Ausführung ist in Sekunden konfigurierbar; Standard: 60 Sekunden. +- Die maximale Anzahl automatischer Wiederholungen je Job ist konfigurierbar; Standard: 3. +- Erfolgreiche, aktive und abgebrochene Jobs werden niemals automatisch neu gestartet. +- Der vorhandene Dispatcher-Abstand zwischen Jobstarts gilt auch für automatische Wiederholungen. +- Die für einen Job verwendete Callback-Basisadresse wird im Job-Snapshot hinterlegt, damit Hintergrundwiederholungen denselben Rückkanal verwenden. +- Automatische Wiederholungen erscheinen im Jobverlauf als eigenes Ereignis. + +Hinweise: +- Es sind keine Datenbankmigrationen erforderlich. +- Bestehende Konfigurationsdateien und Nutzdaten werden nicht überschrieben. diff --git a/UPDATE-0.5.5.12.txt b/UPDATE-0.5.5.12.txt new file mode 100644 index 0000000..055de3a --- /dev/null +++ b/UPDATE-0.5.5.12.txt @@ -0,0 +1,10 @@ +AssetManager 0.5.5.12 + +- Stabiler Rückfallstand bleibt 0.5.5.10. +- Callback-Datenbankverarbeitung läuft in einem eigenen ThreadPool mit separater SQLAlchemy-Session je Worker. +- Standardmäßig vier parallele Callback-Worker; unter Software-Einstellungen konfigurierbar (Neustart erforderlich). +- Job- und Asset-Zeilen werden bei der Callback-Verarbeitung gezielt gesperrt, damit parallele Callbacks sicher bleiben. +- Status „Gesendet; Callback ausstehend“ wird in die automatische Wiederholung einbezogen. +- Das Wiederholungsintervall für gesendete Jobs wird ab sent_at berechnet. +- Zähler-Labels bleiben im Darkmode weiß mit dunkler, lesbarer Schrift. +- VERSION und app/version.py wurden auf 0.5.5.12 aktualisiert. diff --git a/UPDATE-0.5.5.13.txt b/UPDATE-0.5.5.13.txt new file mode 100644 index 0000000..7e456a0 --- /dev/null +++ b/UPDATE-0.5.5.13.txt @@ -0,0 +1,11 @@ +AssetManager 0.5.5.13 + +- Client-Callbacks werden bei einem Übertragungsfehler bis zu drei Mal gesendet. +- Zwischen den Versuchen wird 2 beziehungsweise 4 Sekunden gewartet. +- Der Callback-Timeout beträgt pro Versuch 60 Sekunden. +- Wiederholte Abschluss-Callbacks mit demselben gültigen Token werden idempotent als erfolgreich angenommen. +- Die Wiederholung gilt für PowerShell-, Python-, Bash- und CMD-Runtime-Wrapper sowie die eingebauten Inventur-Fallbacks. +- Das Diagramm-Label `.chart-total` erhält im Darkmode ausdrücklich eine dunkle Schriftfarbe. +- Die zentrale CSS-Datei wird mit der Versionsnummer geladen, damit Browser nach Updates nicht die alte Regel aus dem Cache verwenden. +- VERSION und app/version.py wurden auf 0.5.5.13 aktualisiert. +- Stabiler Rückfallstand vor dem Callback-Worker-Umbau bleibt 0.5.5.10. diff --git a/UPDATE-0.5.5.14.txt b/UPDATE-0.5.5.14.txt new file mode 100644 index 0000000..467d0bb --- /dev/null +++ b/UPDATE-0.5.5.14.txt @@ -0,0 +1,9 @@ +AssetManager 0.5.5.14 + +- Aktive Ausschlussliste in den Software-Einstellungen +- Regeln bestehen aus Plattform und Text, der im Softwarenamen enthalten ist +- Vergleich ohne Beachtung der Groß-/Kleinschreibung +- Ausgeschlossene Software wird beim Callback vor dem Datenbankimport entfernt +- Bestehende ausgeschlossene Einträge verschwinden bei der nächsten Inventur des Assets +- Callback-Diagnose protokolliert empfangene, ausgeschlossene und gespeicherte Einträge +- Deutsche und englische Oberflächentexte ergänzt diff --git a/UPDATE-0.5.5.15.txt b/UPDATE-0.5.5.15.txt new file mode 100644 index 0000000..9e48c33 --- /dev/null +++ b/UPDATE-0.5.5.15.txt @@ -0,0 +1,9 @@ +AssetManager 0.5.5.15 + +- Benutzerfreundliche Job-Art für Jobdefinitionen: Skript oder Windows-Registry statt technischem Wert job_definition. +- In der Spalte Paket wird bei Jobdefinitionen der tatsächliche Name der ausgewählten Jobdefinition angezeigt. +- Gilt einheitlich für Sammeljobs, Einzeljobs, Asset-Softwareliste und Jobdetailansicht. +- Neue Jobs speichern zusätzlich Jobart, Interpreter, Quelltyp und Erstellungsart als kleine Metadaten. +- Sammeljobs erhalten eine gemeinsame Batch-ID sowie Position und Gesamtanzahl für die Diagnose. +- Dispatcher-Protokoll zeigt technische und fachliche Jobmetadaten getrennt an. +- Alte Jobdefinitions-Jobs werden anhand ihrer bestehenden definition_id/definition_name weiterhin korrekt angezeigt. diff --git a/UPDATE-0.5.5.16.txt b/UPDATE-0.5.5.16.txt new file mode 100644 index 0000000..fcc34a6 --- /dev/null +++ b/UPDATE-0.5.5.16.txt @@ -0,0 +1,8 @@ +AssetManager 0.5.5.16 + +Korrekturen: +- Excel-Export der Assetliste wird nicht mehr von der dynamischen Asset-Detailroute abgefangen. +- Die Asset-Detailroute akzeptiert nur noch numerische Asset-IDs. +- Der Download-Button verwendet die konfliktfreie Export-Route /export/assets.xlsx. + +Keine Konfigurations- oder Nutzdaten werden durch dieses Update ersetzt. diff --git a/UPDATE-0.5.5.17.txt b/UPDATE-0.5.5.17.txt new file mode 100644 index 0000000..d947e86 --- /dev/null +++ b/UPDATE-0.5.5.17.txt @@ -0,0 +1,17 @@ +AssetManager 0.5.5.17 + +Neue Funktionen: +- Die Assetliste kann bei Bedarf nach dem Ausführungsstand einer Softwareinventur oder Jobdefinition gefiltert werden. +- Filterbedingungen: mindestens einmal erfolgreich, noch nicht erfolgreich, noch nie ausgeführt, letzter Status fehlgeschlagen, teilweise erfolgreich, Zeitüberschreitung, Callback ausstehend, aktiv/eingereiht und letzter Erfolg älter als X Tage. +- Bei Jobdefinitionen kann optional nur die aktuelle Definitionsrevision berücksichtigt werden. +- Jobstatus, letzte Ausführung, letzter Erfolg und Revision werden nur bei aktivem Jobfilter als zusätzliche Spalten geladen. +- Der ausgewählte Job kann direkt für alle aktuell gefilterten Assets gestartet werden. Das vorhandene Dispatcher-Warteintervall gilt unverändert. + +Technische Erweiterungen: +- Neue abgeleitete Tabelle asset_job_states für einen kompakten Jobstatus je Asset und Jobdefinition. +- Die Tabelle wird beim ersten Start aus der vorhandenen Jobhistorie aufgebaut und danach bei Statusänderungen aktualisiert. +- Indizes für schnelle Filter nach Job, Status und letztem Erfolg. +- Beim Zusammenführen von Assets wird der abgeleitete Jobstatus aus der verschobenen Jobhistorie neu aufgebaut. +- Die normale Assetliste greift ohne aktiven Jobfilter nicht auf asset_job_states zu. + +Keine Konfigurations- oder Nutzdaten werden durch dieses Update ersetzt. diff --git a/UPDATE-0.5.5.18.txt b/UPDATE-0.5.5.18.txt new file mode 100644 index 0000000..266f2bb --- /dev/null +++ b/UPDATE-0.5.5.18.txt @@ -0,0 +1,8 @@ +AssetManager 0.5.5.18 + +- Asset-Jobfilter auf frei wählbare Ausschlussstatus umgestellt. +- Alle bekannten Jobstatus einschließlich „Noch nie ausgeführt“ können einzeln ausgeschlossen werden. +- Jobinformationen werden weiterhin nur bei aktivem Jobfilter geladen. +- Neue Administratoransicht „Jobstatus je Asset“ in der Jobübersicht. +- Die neue Ansicht verwendet die bestehenden Tabellenfunktionen für Filterung und Sortierung. +- Start eines Jobs für die gefilterten Assets verwendet weiterhin das konfigurierte Dispatcher-Intervall. diff --git a/UPDATE-0.5.5.19.txt b/UPDATE-0.5.5.19.txt new file mode 100644 index 0000000..b211f37 --- /dev/null +++ b/UPDATE-0.5.5.19.txt @@ -0,0 +1,8 @@ +AssetManager 0.5.5.19 + +- Der Jobstatusfilter der Assetliste unterstützt mehrere gleichzeitig ausgewählte Jobs. +- Mehrere Jobs werden als alternative Ausführungen zusammengefasst; der zuletzt ausgeführte ausgewählte Job bestimmt den angezeigten Status. +- Der letzte erfolgreiche Lauf wird über alle ausgewählten Jobs hinweg berücksichtigt. +- Beim Start für gefilterte Assets wird bei mehreren ausgewählten Jobs der konkret auszuführende Job separat ausgewählt. +- Deaktivierte Jobdefinitionen werden in den Jobauswahlfeldern der Assetliste nicht mehr angezeigt. +- Die normale Assetliste lädt weiterhin keine Jobstatusdaten, solange der Jobfilter nicht aktiv ist. diff --git a/UPDATE-0.5.5.20.txt b/UPDATE-0.5.5.20.txt new file mode 100644 index 0000000..860a5d6 --- /dev/null +++ b/UPDATE-0.5.5.20.txt @@ -0,0 +1,6 @@ +AssetManager 0.5.5.20 + +- MeshCentral-Präsenzabfragen behalten erfolgreiche ListDevices-Rohdateien nicht mehr dauerhaft. +- Fehlerhafte MeshCtrl-Aufrufe bleiben weiterhin als Diagnose erhalten. +- Parsefehler der Präsenzabfrage werden in einer einzigen überschreibbaren Datei gespeichert. +- Das Software-Job-Debugging bleibt davon unabhängig. diff --git a/UPDATE-0.5.5.21.txt b/UPDATE-0.5.5.21.txt new file mode 100644 index 0000000..bb5909a --- /dev/null +++ b/UPDATE-0.5.5.21.txt @@ -0,0 +1,11 @@ +AssetManager 0.5.5.21 + +- Administratoren können die MeshCentral Node-ID direkt in der Assetdetailseite initialisieren oder ändern. +- Die Geräteauswahl wird erst beim Öffnen der Verknüpfungsseite aus MeshCentral geladen und beeinflusst die normale Assetdetailseite nicht. +- Es werden ausschließlich MeshCentral-Geräte angeboten, deren Node-ID noch keinem anderen Asset zugeordnet ist. +- Zur sicheren Auswahl werden Gerätename, Hersteller, Seriennummer, Gerätegruppe, Betriebssystem und Node-ID angezeigt. +- Beim Speichern wird erneut geprüft, ob das Gerät in MeshCentral existiert und die Node-ID noch frei ist. +- Administratoren können eine vorhandene MeshCentral-Verknüpfung lösen, ohne das Asset oder seine gespeicherten Fachdaten zu löschen. +- Änderungen und gelöste Verknüpfungen werden in der Asset-Historie protokolliert. +- Nach einer manuellen Änderung werden nur die verknüpfungsabhängigen Präsenzdaten zurückgesetzt; andere Assetfelder bleiben bis zur nächsten Synchronisierung unverändert. +- Erfolgreiche Geräteabfragen für die Auswahl werden nicht dauerhaft im Diagnoseverzeichnis gespeichert. diff --git a/UPDATE-0.5.5.22.txt b/UPDATE-0.5.5.22.txt new file mode 100644 index 0000000..99322ea --- /dev/null +++ b/UPDATE-0.5.5.22.txt @@ -0,0 +1,9 @@ +AssetManager 0.5.5.22 + +- Administratoren können die MeshCentral Node-ID direkt im Bearbeitungsformular eines Assets editieren. +- Ein leeres Node-ID-Feld löst die Verknüpfung beim Speichern, ohne das Asset oder andere Fachdaten zu löschen. +- Der Button „Node-ID leeren“ leert das Feld; gespeichert wird die Änderung erst mit dem normalen Speichern-Button. +- Über „MeshCentral-Gerät auswählen“ bleibt zusätzlich die abgesicherte Geräteauswahl verfügbar. +- Doppelt vergebene MeshCentral Node-IDs werden vor dem Speichern abgewiesen. +- Änderungen werden in der Asset-Historie protokolliert und setzen nur die verknüpfungsabhängigen Laufzeitdaten zurück. +- Andere Assetdaten werden erst beim nächsten MeshCentral-Sync gemäß den bestehenden Mapping-Regeln aktualisiert. diff --git a/UPDATE-0.5.5.23.txt b/UPDATE-0.5.5.23.txt new file mode 100644 index 0000000..52a2a86 --- /dev/null +++ b/UPDATE-0.5.5.23.txt @@ -0,0 +1,7 @@ +AssetManager 0.5.5.23 + +- Die Spaltenfilter der Liste „Letzte Jobs“ werden serverseitig auf die vollständige Jobhistorie angewendet. +- Erst nach der Filterung wird die Auswahl 25/50/100/500/1000 begrenzt. +- Alte Job-IDs sind damit unabhängig von ihrer Position in der Historie auffindbar. +- Filterwerte bleiben beim Wechsel der Trefferanzahl erhalten. +- Erfolgs-, Status-, Asset-, Paket-, Plattform- und weitere Spaltenfilter durchsuchen ebenfalls den Gesamtbestand. diff --git a/UPDATE-0.5.5.24.txt b/UPDATE-0.5.5.24.txt new file mode 100644 index 0000000..650b8fa --- /dev/null +++ b/UPDATE-0.5.5.24.txt @@ -0,0 +1,12 @@ +AssetManager 0.5.5.24 +===================== + +Docker-Hardening ohne Änderung der Laufzeit-Benutzerrechte: + +- Dockerfile verwendet für die MeshCentral-Installation WORKDIR statt "RUN cd ...". +- Docker-HEALTHCHECK prüft alle 30 Sekunden den vorhandenen Endpunkt + http://127.0.0.1:8000/api/software-callback/health. +- Der Container läuft weiterhin wie bisher als Root. Verzeichnis- und Volume-Rechte + werden durch dieses Update nicht verändert. + +Nach dem Update muss das App-Image neu gebaut und der Container neu erstellt werden. diff --git a/UPDATE-0.5.5.25.txt b/UPDATE-0.5.5.25.txt new file mode 100644 index 0000000..21615e5 --- /dev/null +++ b/UPDATE-0.5.5.25.txt @@ -0,0 +1,25 @@ +AssetManager 0.5.5.25 +===================== + +Geschützter lokaler Notfall-Administrator bei aktivem LDAP: + +- Über LOCAL_ADMIN_USERNAME und LOCAL_ADMIN_PASSWORD kann ein lokales + Break-Glass-Administratorkonto eingerichtet werden. +- Das Konto kann sich auch anmelden, wenn authentication.mode auf "ldap" steht. +- Das lokale Passwort wird vor einem LDAP-Versuch geprüft. Für denselben + Benutzernamen gibt es bei einem falschen lokalen Passwort keinen LDAP-Fallback. +- Das Konto bleibt aktiv, behält Administratorrechte und kann in der GUI weder + deaktiviert noch gelöscht werden. +- Das Initialkennwort wird nur beim erstmaligen Anlegen oder bei einem lokalen + Konto ohne Passwort-Hash übernommen. Ein vorhandenes Passwort wird beim + Containerstart nicht überschrieben. +- Existiert derselbe Benutzername bereits als LDAP-Konto, wird kein lokales + Konto angelegt; der Konflikt wird im Containerlog ausgegeben. + +Einrichtung in /srv/docker/assetmanager/.env: + +LOCAL_ADMIN_USERNAME=emergency-admin +LOCAL_ADMIN_PASSWORD=EinSehrSicheresKennwortMitMindestens12Zeichen + +Danach das App-Image neu bauen und den Container neu erstellen. +Das Kennwort kann später in der Benutzerverwaltung geändert werden. diff --git a/UPDATE-0.5.5.26.txt b/UPDATE-0.5.5.26.txt new file mode 100644 index 0000000..e790a5f --- /dev/null +++ b/UPDATE-0.5.5.26.txt @@ -0,0 +1,7 @@ +AssetManager 0.5.5.26 + +- Festes Herstellerlogo im Info-Popup ergänzt. +- Das Logo ist nicht über die GUI konfigurierbar. +- Automatische Suche nach app/static/img/manufacturer-logo.svg, .png, .jpg, .jpeg oder .webp. +- Das bereitgestellte PNG wird als app/static/img/manufacturer-logo.png ausgeliefert. +- Versionsdateien auf 0.5.5.26 aktualisiert. diff --git a/UPDATE-0.5.5.27.txt b/UPDATE-0.5.5.27.txt new file mode 100644 index 0000000..6b4d099 --- /dev/null +++ b/UPDATE-0.5.5.27.txt @@ -0,0 +1,13 @@ +AssetManager 0.5.5.27 + +- Vollständige Apache-License-2.0-Lizenzdatei ergänzt. +- Lizenzkennung in den Projektinformationen auf den SPDX-Wert Apache-2.0 vereinheitlicht. +- README für den aktuellen Funktionsstand und die Veröffentlichung auf GitHub neu erstellt. +- CONTRIBUTING.md, SECURITY.md, THIRD_PARTY_NOTICES.md, .gitignore, .dockerignore, .gitattributes und .env.example ergänzt. +- Öffentliche Kommentare und ältere Versionshinweise von reinen Produktvergleichen bereinigt. +- Interne Server-, Domänen-, Konto- und Pfadbeispiele durch neutrale Beispieldaten ersetzt. +- GitHub-Exporter erstellt einen bereinigten Quellstand ohne Datenbank, Konfiguration, Zugangsdaten, Logs, Backups oder Uploads. +- Veröffentlichungsprüfung kontrolliert Lizenz, Versionsgleichheit, vertrauliche Dateien und unerwünschte Vergleichsnennungen. +- Docker-Build sammelt Lizenzdateien der tatsächlich installierten Python- und Node.js-Abhängigkeiten. +- MeshCentral- und MeshCtrl-Nennungen bleiben ausschließlich dort erhalten, wo die tatsächliche technische Integration beschrieben wird. +- Keine Änderung an der fachlichen Programmlogik. diff --git a/UPDATE-0.5.5.5.txt b/UPDATE-0.5.5.5.txt new file mode 100644 index 0000000..027f3f4 --- /dev/null +++ b/UPDATE-0.5.5.5.txt @@ -0,0 +1,35 @@ +AssetManager 0.5.5.5 - kumulatives Update ab 0.5.5.0 + +Enthaltene Korrekturen aus 0.5.5.1 bis 0.5.5.5: +- PowerShell-Dateijobs starten über Windows-Eingabeaufforderung mit: + powershell -ExecutionPolicy Bypass -File "C:\ProgramData\AssetManager\Jobs\-\run.ps1" +- Für diesen Start wird MeshCtrl NICHT mit --powershell aufgerufen. +- Diagnoseprotokoll zeigt Shell, exakten Startbefehl und MeshCtrl-PowerShell-Modus. +- Vor dem Upload wird eine eventuell vorhandene Zieldatei entfernt. +- Fehlgeschlagene Uploads werden einmal erneut versucht und vollständig protokolliert. +- Merge von MeshCentral-Asset in ein bestehendes importiertes Asset gibt mesh_node_id zuerst frei. +- Merge-Fehler werden auf /assets als Toast angezeigt; keine Redirect-Schleife mehr. +- VERSION und app/version.py sind beide auf 0.5.5.5 gesetzt. +- Dockerfile kopiert VERSION in das Image. + +Paketstruktur: +Die Dateien liegen direkt im ZIP-Stamm. Es gibt keinen zusätzlichen Ordner +"assetmanager" und keinen Ordner mit Versionsnummer. + +Installation: + cd /srv/docker/assetmanager + unzip -o /pfad/assetmanager-v0.5.5.5-cumulative-root.zip -d /srv/docker/assetmanager + cat VERSION + grep APP_VERSION app/version.py + grep -n "powershell -ExecutionPolicy Bypass -File" app/software_control.py + docker compose build --no-cache app + docker compose up -d --force-recreate app + +Nach dem Start im Container prüfen: + docker compose exec app python -c "from app.version import APP_VERSION; print(APP_VERSION)" + docker compose exec app grep -n "powershell -ExecutionPolicy Bypass -File" /app/app/software_control.py + +Ein neuer Windows-PowerShell-Job muss im Diagnoseprotokoll enthalten: + Remote execution shell: Windows Command Prompt + Remote execution command: powershell -ExecutionPolicy Bypass -File "...\run.ps1" + MeshCtrl PowerShell mode: False diff --git a/UPDATE-0.5.5.6.txt b/UPDATE-0.5.5.6.txt new file mode 100644 index 0000000..de4ca39 --- /dev/null +++ b/UPDATE-0.5.5.6.txt @@ -0,0 +1,19 @@ +AssetManager 0.5.5.6 +==================== + +Änderungen +---------- +- Konfigurierbare Pause zwischen Jobstarts auf der Seite Software-Einstellungen. +- Standardwert: 2 Sekunden; zulässiger Bereich: 0 bis 60 Sekunden. +- Globale Dispatcher-Steuerung verteilt auch große Sammelaktionen zeitlich, ohne die HTTP-Anfrage zu blockieren. +- Wartende Dispatcher-Threads öffnen erst nach Ablauf ihrer Wartezeit eine Datenbanksitzung. +- Abgelaufene Jobs werden alle 30 Sekunden automatisch auf Zeitüberschreitung gesetzt. +- Statusprüfung erfolgt zusätzlich beim Öffnen der Softwareübersicht, der Jobdetails und vor einem Neustart. +- Auswahl für die Anzahl der letzten Jobs: 25, 50, 100, 500 oder 1000. +- Softwareinventur-Einträge aus Callbacks werden gesammelt in die Datenbank geschrieben, um parallele Rückmeldungen schneller zu verarbeiten. +- Client-Abbrüche während eines Callback-Uploads werden protokolliert, ohne einen unkontrollierten ASGI-Traceback zu erzeugen. +- Deutsche und englische Übersetzungen ergänzt. + +Update +------ +Das ZIP beginnt mit dem Hauptordner assetmanager/. Bestehende Konfigurations-, Datenbank-, Upload-, Log- und Sicherungsdaten sind nicht enthalten und werden nicht überschrieben. diff --git a/UPDATE-0.5.5.7.txt b/UPDATE-0.5.5.7.txt new file mode 100644 index 0000000..429be57 --- /dev/null +++ b/UPDATE-0.5.5.7.txt @@ -0,0 +1,17 @@ +AssetManager 0.5.5.7 +==================== + +Änderungen +---------- +- Die Tabelle „Letzte Jobs“ enthält eine Auswahlspalte mit Checkboxen. +- Nur Jobs, die nicht erfolgreich und aktuell nicht im Status „Wird gesendet“ oder „Läuft“ sind, können für einen Sammelneustart markiert werden. +- Eine Checkbox in der Tabellenüberschrift markiert alle sichtbaren und erneut startbaren Jobs. +- Der Button „Markierte Jobs erneut starten“ wird erst nach einer Auswahl aktiv und zeigt die Anzahl der markierten Jobs an. +- Der Sammelneustart verwendet denselben bestehenden Neustartpfad wie der Einzelneustart. +- Alle markierten Jobs werden über den vorhandenen Dispatcher gestartet; das in den Software-Einstellungen konfigurierte Warteintervall gilt unverändert. +- Erfolgreiche, inzwischen aktive, nicht mehr vorhandene oder nicht mit MeshCentral verbundene Jobs werden serverseitig sicher übersprungen. +- Deutsche und englische Übersetzungen ergänzt. + +Update +------ +Das ZIP beginnt mit dem Hauptordner assetmanager/. Bestehende Konfigurations-, Datenbank-, Upload-, Log- und Sicherungsdaten sind nicht enthalten und werden nicht überschrieben. diff --git a/UPDATE-0.5.5.8.txt b/UPDATE-0.5.5.8.txt new file mode 100644 index 0000000..e8dec25 --- /dev/null +++ b/UPDATE-0.5.5.8.txt @@ -0,0 +1,15 @@ +AssetManager 0.5.5.8 +==================== + +Performanceoptimierung der Software-/Jobübersicht: + +- Die Startseite des Softwaremoduls lädt nicht mehr die vollständige Softwareinventur. +- Die drei Kennzahlen werden durch kompakte SQL-Aggregationen und eine schlanke DISTINCT-Auswahl berechnet. +- Die Liste der letzten Jobs lädt nur noch die tatsächlich angezeigten Spalten. +- Große Skript-, Callback-, JSON- und Logfelder bleiben bei der Listenabfrage außen vor. +- Asset und Softwarepaket werden weiterhin in derselben Abfrage geladen; es entstehen keine N+1-Abfragen. +- Neue Indizes beschleunigen die Sortierung der neuesten Jobs, die Timeout-Suche und die Softwarezusammenfassung. +- Template-Übersetzungen öffnen nicht mehr für jede Tabellenzelle eine eigene Datenbanksitzung. +- Bei weiterhin langsamen Aufrufen wird eine aufgeteilte Zeitmessung im Anwendungslog ausgegeben. + +Es werden keine Konfigurationsdateien oder Nutzdaten überschrieben. diff --git a/UPDATE-0.5.5.9.txt b/UPDATE-0.5.5.9.txt new file mode 100644 index 0000000..7a0cc78 --- /dev/null +++ b/UPDATE-0.5.5.9.txt @@ -0,0 +1,16 @@ +AssetManager 0.5.5.9 + +Änderungen: +- Kumulierte Softwareliste wird vollständig in PostgreSQL gruppiert. +- Die Liste lädt nicht mehr alle Softwareinventar-ORM-Objekte in Python. +- Neue Auswahl für die Gruppierung: + - Name, Hersteller und Plattform (Versionen zusammenfassen) + - Name, Hersteller, Plattform und Version (Versionen getrennt) +- Alle Softwareinstallationen laden nur noch die tatsächlich sichtbaren Datenbankspalten. +- Die Asset-Beziehung wird nicht mehr als vollständiges ORM-Objekt pro Installation geladen. +- Zusätzliche Datenbankindizes für Gruppierung und Listenreihenfolge. +- Zeitmessungen für beide Softwarelisten im Containerlog, wenn der Seitenaufbau länger als 0,5 Sekunden dauert. +- Deutsche und englische Übersetzungen ergänzt. + +Hinweis: +Beim ersten Start werden die neuen Indizes automatisch angelegt. Je nach Größe der Tabelle kann der erste Containerstart deshalb einmalig etwas länger dauern. diff --git a/UPDATE-HINWEIS.txt b/UPDATE-HINWEIS.txt new file mode 100644 index 0000000..402b9f1 --- /dev/null +++ b/UPDATE-HINWEIS.txt @@ -0,0 +1,20 @@ +AssetManager 0.5.5.27 – Update- und GitHub-Paket + +Dieses öffentliche Paket enthält absichtlich NICHT: +- `.env` +- `data/config/config.json` und die laufzeitbezogene `data/config/APPINFO.json` +- PostgreSQL-Daten +- Uploads, Logs, Backups und bereitgestellte Laufzeitskripte +- lokale Datenbankdateien und alte Sicherungskopien des Programmcodes + +Vorhandene Konfigurationen, Passwörter und Nutzdaten werden beim Kopieren in ein bestehendes Projekt nicht überschrieben. + +Update einer vorhandenen Installation: +1. `.env` und den gesamten Ordner `data/` sichern. +2. Inhalt des neuen Ordners `assetmanager/` über den bestehenden Projektordner kopieren. +3. `docker compose down` +4. `docker compose up -d --build` +5. `docker compose logs --tail=100 -f` + +Öffentlichen GitHub-Quellstand erneut erzeugen: +`python tools/create_github_source.py` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ce144ad --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.5.5.27 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/backup.py b/app/backup.py new file mode 100644 index 0000000..2b3ba71 --- /dev/null +++ b/app/backup.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import threading +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +from .version import APP_VERSION + +BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups")) +CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config/config.json")) +APPINFO_PATH = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json")) +UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/app/static/uploads")) +BACKUP_INTERVAL_HOURS = max(1, int(os.getenv("BACKUP_INTERVAL_HOURS", "8"))) +BACKUP_RETENTION_DAYS = max(1, int(os.getenv("BACKUP_RETENTION_DAYS", "3"))) +BACKUP_PREFIX = "assetmanager-backup-" +BACKUP_SUFFIX = ".zip" +_LOCK = threading.Lock() + + +def _database_url() -> str: + value = os.getenv("DATABASE_URL", "") + if value.startswith("postgresql+psycopg://"): + value = "postgresql://" + value[len("postgresql+psycopg://"):] + elif value.startswith("postgres+psycopg://"): + value = "postgresql://" + value[len("postgres+psycopg://"):] + return value + + +def _safe_database_url() -> str: + value = _database_url() + try: + parsed = urlsplit(value) + host = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + username = parsed.username or "" + auth = f"{username}@" if username else "" + return urlunsplit((parsed.scheme, f"{auth}{host}{port}", parsed.path, "", "")) + except Exception: + return "postgresql://" + + +def ensure_backup_dir() -> Path: + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + return BACKUP_DIR + + + + +def _is_mount_point(path: Path) -> bool: + """Best-effort check whether path is a distinct container mount.""" + try: + resolved = path.resolve() + if resolved.is_mount(): + return True + mountinfo = Path("/proc/self/mountinfo") + if mountinfo.is_file(): + target = str(resolved) + for line in mountinfo.read_text(encoding="utf-8", errors="replace").splitlines(): + parts = line.split() + if len(parts) > 4 and parts[4].replace("\\040", " ") == target: + return True + except OSError: + pass + return False + + +def _directory_status(path: Path, create: bool = False) -> dict: + error = "" + writable = False + exists = path.exists() + try: + if create: + path.mkdir(parents=True, exist_ok=True) + exists = True + if exists and path.is_dir(): + with tempfile.NamedTemporaryFile(prefix=".assetmanager-write-test-", dir=path, delete=True): + writable = True + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + return { + "path": str(path), + "exists": exists, + "writable": writable, + "is_mount": _is_mount_point(path) if exists else False, + "error": error, + } + + +def system_storage_information(log_dir: Path | None = None) -> dict: + backups = list_backups() + total_size = sum(int(item.get("size") or 0) for item in backups) + latest = backups[0] if backups else None + return { + "database_connected": True, + "config": _directory_status(CONFIG_PATH.parent), + "uploads": _directory_status(UPLOAD_DIR), + "logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", "/app/data/logs"))), + "backups": _directory_status(BACKUP_DIR, create=True), + "backup_count": len(backups), + "backup_total_size": total_size, + "latest_backup": latest, + } + +def validate_backup_name(name: str) -> str: + candidate = Path(name).name + if candidate != name or not candidate.startswith(BACKUP_PREFIX) or not candidate.endswith(BACKUP_SUFFIX): + raise ValueError("invalid backup name") + return candidate + + +def backup_path(name: str) -> Path: + return ensure_backup_dir() / validate_backup_name(name) + + +def _run(command: list[str]) -> None: + process = subprocess.run(command, capture_output=True, text=True) + if process.returncode != 0: + detail = (process.stderr or process.stdout or "command failed").strip() + raise RuntimeError(detail[-2000:]) + + +def create_backup(created_by: str = "system", reason: str = "manual") -> dict: + with _LOCK: + directory = ensure_backup_dir() + now = datetime.now(timezone.utc) + timestamp = now.strftime("%Y%m%d-%H%M%S") + final_path = directory / f"{BACKUP_PREFIX}{timestamp}{BACKUP_SUFFIX}" + sequence = 1 + while final_path.exists(): + final_path = directory / f"{BACKUP_PREFIX}{timestamp}-{sequence}{BACKUP_SUFFIX}" + sequence += 1 + + with tempfile.TemporaryDirectory(prefix="assetmanager-backup-") as temp_name: + temp = Path(temp_name) + dump_path = temp / "database.dump" + database_url = _database_url() + if not database_url: + raise RuntimeError("DATABASE_URL is not configured") + _run(["pg_dump", "--format=custom", "--no-owner", "--no-privileges", "--file", str(dump_path), database_url]) + + included: list[str] = ["database.dump"] + files_dir = temp / "files" + files_dir.mkdir() + if CONFIG_PATH.is_file(): + shutil.copy2(CONFIG_PATH, files_dir / "config.json") + included.append("files/config.json") + if APPINFO_PATH.is_file(): + shutil.copy2(APPINFO_PATH, files_dir / "APPINFO.json") + included.append("files/APPINFO.json") + if UPLOAD_DIR.is_dir(): + target = files_dir / "uploads" + shutil.copytree(UPLOAD_DIR, target) + included.append("files/uploads/") + + metadata = { + "format": 1, + "application": "AssetManager", + "version": _read_version(), + "created_at": now.isoformat(), + "created_by": created_by or "system", + "reason": reason, + "database": _safe_database_url(), + "included": included, + } + (temp / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (temp / "VERSION").write_text(metadata["version"] + "\n", encoding="utf-8") + included.append("VERSION") + + temporary_zip = final_path.with_suffix(".zip.part") + try: + with zipfile.ZipFile(temporary_zip, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + for path in sorted(temp.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(temp)) + temporary_zip.replace(final_path) + finally: + temporary_zip.unlink(missing_ok=True) + + cleanup_backups() + return describe_backup(final_path) + + +def _read_version() -> str: + for candidate in (Path("/app/VERSION"), Path(__file__).resolve().parent.parent / "VERSION", Path(__file__).resolve().parent / "VERSION"): + try: + value = candidate.read_text(encoding="utf-8").strip() + if value and value.casefold() != "unknown": + return value + except OSError: + pass + return APP_VERSION + + +def _archive_version(archive: zipfile.ZipFile, metadata: dict) -> str: + value = str(metadata.get("version") or "").strip() + if value and value.casefold() != "unknown": + return value + names = set(archive.namelist()) + for name in ("VERSION", "files/VERSION", "assetmanager/VERSION"): + if name in names: + try: + value = archive.read(name).decode("utf-8", errors="replace").strip() + if value and value.casefold() != "unknown": + return value + except Exception: + pass + for name in ("files/APPINFO.json", "APPINFO.json"): + if name in names: + try: + value = str(json.loads(archive.read(name).decode("utf-8")).get("version") or "").strip() + if value and value.casefold() != "unknown": + return value + except Exception: + pass + return "" + + +def _metadata(path: Path) -> dict: + try: + with zipfile.ZipFile(path) as archive: + metadata = json.loads(archive.read("metadata.json").decode("utf-8")) + if not isinstance(metadata, dict): + return {} + metadata = dict(metadata) + metadata["version"] = _archive_version(archive, metadata) + return metadata + except Exception: + return {} + + +def describe_backup(path: Path) -> dict: + stat = path.stat() + metadata = _metadata(path) + return { + "name": path.name, + "size": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc), + "created_at": metadata.get("created_at", ""), + "created_by": metadata.get("created_by", ""), + "reason": metadata.get("reason", ""), + "version": metadata.get("version", ""), + "valid": bool(metadata), + } + + +def list_backups() -> list[dict]: + directory = ensure_backup_dir() + items = [describe_backup(path) for path in directory.glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}") if path.is_file()] + return sorted(items, key=lambda item: item["modified_at"], reverse=True) + + +def cleanup_backups() -> int: + threshold = datetime.now(timezone.utc) - timedelta(days=BACKUP_RETENTION_DAYS) + removed = 0 + for item in list_backups(): + if item["modified_at"] < threshold: + backup_path(item["name"]).unlink(missing_ok=True) + removed += 1 + return removed + + +def delete_backup(name: str) -> None: + backup_path(name).unlink() + + +def store_uploaded_backup(filename: str, content: bytes) -> dict: + ensure_backup_dir() + if len(content) < 100: + raise ValueError("backup file is empty") + with tempfile.NamedTemporaryFile(prefix="backup-upload-", suffix=".zip", delete=False, dir=BACKUP_DIR) as handle: + temp_path = Path(handle.name) + handle.write(content) + try: + with zipfile.ZipFile(temp_path) as archive: + names = set(archive.namelist()) + if "metadata.json" not in names or "database.dump" not in names: + raise ValueError("unsupported backup archive") + metadata = json.loads(archive.read("metadata.json").decode("utf-8")) + if metadata.get("application") != "AssetManager": + raise ValueError("unsupported backup archive") + now = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + target = BACKUP_DIR / f"{BACKUP_PREFIX}{now}-upload{BACKUP_SUFFIX}" + counter = 1 + while target.exists(): + target = BACKUP_DIR / f"{BACKUP_PREFIX}{now}-upload-{counter}{BACKUP_SUFFIX}" + counter += 1 + temp_path.replace(target) + return describe_backup(target) + finally: + temp_path.unlink(missing_ok=True) + + +def restore_backup(name: str) -> dict: + path = backup_path(name) + with _LOCK, tempfile.TemporaryDirectory(prefix="assetmanager-restore-") as temp_name: + temp = Path(temp_name) + with zipfile.ZipFile(path) as archive: + archive.extractall(temp) + dump_path = temp / "database.dump" + if not dump_path.is_file(): + raise ValueError("database dump missing") + database_url = _database_url() + _run(["pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", database_url, str(dump_path)]) + + files = temp / "files" + if (files / "config.json").is_file(): + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(files / "config.json", CONFIG_PATH) + if (files / "APPINFO.json").is_file(): + APPINFO_PATH.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(files / "APPINFO.json", APPINFO_PATH) + if (files / "uploads").is_dir(): + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + for child in UPLOAD_DIR.iterdir(): + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + shutil.copytree(files / "uploads", UPLOAD_DIR, dirs_exist_ok=True) + return _metadata(path) + + +def automatic_backup_loop(stop_event: threading.Event) -> None: + # Wait after application start; do not create a backup during every restart. + while not stop_event.wait(BACKUP_INTERVAL_HOURS * 3600): + try: + create_backup(created_by="system", reason="automatic") + except Exception: + # Main application logger records route errors; scheduler must stay alive. + pass diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..4767b09 --- /dev/null +++ b/app/config.py @@ -0,0 +1,114 @@ +import json +import os +from pathlib import Path +from typing import Any + +CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config.json")) + +DEFAULT_CONFIG: dict[str, Any] = { + "general": { + "title": "AssetManager", + "company_name": "", + "logo": "", + "custom_css": "", + }, + "authentication": { + "mode": "none", + "session_secret_env": "SESSION_SECRET", + "ldap": { + "server": "", + "port": 389, + "use_ssl": False, + "start_tls": False, + "bind_dn": "", + "bind_password_env": "LDAP_BIND_PASSWORD", + "user_base_dn": "", + "user_filter": "(sAMAccountName={username})", + "display_name_attribute": "displayName", + "email_attribute": "mail" + } + }, + "software": { + "dispatch_delay_seconds": 2, + "callback_base_url": "", + "callback_timeout_minutes": 30, + "callback_worker_count": 4, + "callback_test_timeout_seconds": 10, + "callback_test_verify_tls": True, + "debug_logging": True, + "debug_log_max_lines": 1000, + "automatic_retry_enabled": True, + "automatic_retry_max_age_hours": 12, + "automatic_retry_interval_seconds": 60, + "automatic_retry_max_retries": 3, + "inventory_exclusion_rules": [] + }, + "meshcentral": { + "enabled": False, + "url": "", + "username": "", + "password": "", + "password_env": "MESHCENTRAL_PASSWORD", + "tenant": "", + "meshctrl_path": "/opt/meshcentral/node_modules/meshcentral/meshctrl.js", + "verify_tls": True, + "fallback_category_id": 1, + "category_mapping": {"1": 1, "2": 1, "3": 1, "4": 1, "5": 1, "6": 1, "7": 1, "8": 1}, + "timeout_seconds": 120, + "presence_enabled": True, + "presence_interval_seconds": 30, + "match_order": ["node_id", "serial_number"], + "create_missing_assets": True, + "mark_missing_devices": True, + "field_rules": {}, + }, +} + + +def _merge(base: dict[str, Any], supplied: dict[str, Any]) -> dict[str, Any]: + for section, values in supplied.items(): + if isinstance(values, dict) and isinstance(base.get(section), dict): + base[section].update(values) + else: + base[section] = values + return base + + +def load_config() -> dict[str, Any]: + config = json.loads(json.dumps(DEFAULT_CONFIG)) + if CONFIG_PATH.exists(): + with CONFIG_PATH.open("r", encoding="utf-8") as handle: + supplied = json.load(handle) + _merge(config, supplied) + mesh = config["meshcentral"] + env_name = mesh.get("password_env") + if env_name and os.getenv(env_name): + mesh["password"] = os.getenv(env_name) + return config + + +def save_config(config: dict[str, Any]) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + current: dict[str, Any] = {} + if CONFIG_PATH.exists(): + with CONFIG_PATH.open("r", encoding="utf-8") as handle: + current = json.load(handle) + _merge(current, config) + temporary = CONFIG_PATH.with_suffix(CONFIG_PATH.suffix + ".tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(current, handle, ensure_ascii=False, indent=2) + handle.write("\n") + temporary.replace(CONFIG_PATH) + + +def public_config() -> dict[str, Any]: + config = load_config() + mesh = dict(config["meshcentral"]) + mesh["password"] = "***" if mesh.get("password") else "" + return { + "config_path": str(CONFIG_PATH), + "general": dict(config.get("general", {})), + "authentication": dict(config.get("authentication", {})), + "software": dict(config.get("software", {})), + "meshcentral": mesh, + } diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..5cd75e9 --- /dev/null +++ b/app/database.py @@ -0,0 +1,17 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./assetmanager.db") +connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +engine = create_engine(DATABASE_URL, pool_pre_ping=True, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/fields.py b/app/fields.py new file mode 100644 index 0000000..6eb16de --- /dev/null +++ b/app/fields.py @@ -0,0 +1,56 @@ +ASSET_FIELDS = { + "asset_tag": "Inventarnummer", + "name": "Bezeichnung", + "manufacturer": "Hersteller", + "model": "Modell", + "serial_number": "Seriennummer", + "status": "Status", + "assigned_to": "Zugeordnet an", + "assigned_on": "Ausgegeben am", + "location": "Standort", + "room_number": "Raumnummer", + "parent_asset_id": "Übergeordnetes Gerät", + "department": "Abteilung", + "purchase_date": "Kaufdatum", + "warranty_until": "Garantie bis", + "notes": "Notizen", + "hostname": "Computername", + "operating_system": "Betriebssystem", + "os_patch_level": "OS-Patchlevel", + "mac_address": "MAC-Adresse", + "ip_address": "IP-Adresse", + "cpu": "CPU", + "memory_gb": "Arbeitsspeicher (GB)", + "storage_gb": "Speicher gesamt (GB)", + "storage_details": "Datenträger", + "gpu_name": "Grafikkarte", + "tpm_version": "TPM-Version", + "memory_details": "Arbeitsspeicher-Module", + "antivirus": "Aktiver Virenschutz", + "mesh_group": "MeshCentral-Gruppe", + "mesh_node_id": "MeshCentral Node-ID", + "mesh_online": "Online", + "mesh_mtype": "MeshCentral device type (mtype)", +} + +DEFAULT_FIELDS = ["asset_tag", "name", "manufacturer", "model", "serial_number", "status", "assigned_to", "assigned_on", "location", "room_number", "notes"] +COMPUTER_FIELDS = DEFAULT_FIELDS + ["hostname", "operating_system", "os_patch_level", "mac_address", "ip_address", "cpu", "memory_gb", "memory_details", "storage_gb", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group", "mesh_node_id", "mesh_online", "mesh_mtype"] + +FIELD_DATA_TYPES = { + "short_text": "Short text", + "long_text": "Long text", + "integer": "Integer", + "decimal": "Decimal number", + "boolean": "Boolean", + "date": "Date", + "datetime": "Date and time", + "url": "URL", + "email": "E-mail address", + "ip": "IP address", + "json": "JSON", + "asset_reference": "Asset reference", + "user_reference": "User reference", + "status": "Status", +} + +SYSTEM_READONLY_FIELDS = {"mesh_node_id", "mesh_online", "mesh_mtype"} diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..56199d9 --- /dev/null +++ b/app/i18n.py @@ -0,0 +1,1193 @@ +from __future__ import annotations +from sqlalchemy.orm import Session +from .models import Language, Translation + +BASE_TRANSLATIONS = { +"app.dashboard": ("Dashboard", "Übersicht"), "app.assets": ("Assets", "Assets"), "app.asset_tree": ("Asset tree", "Baumstruktur"), +"app.charts": ("Charts", "Diagramme"), "app.categories": ("Categories", "Kategorien"), "app.status_values": ("Status values", "Statuswerte"), +"app.settings": ("Settings", "Einstellungen"), "app.users": ("Users", "Benutzer"), "app.profile": ("Profile", "Profil"), +"app.login": ("Sign in", "Anmelden"), "app.logout": ("Sign out", "Ausloggen"), "app.guest": ("Guest", "Gast"), +"common.save": ("Save", "Speichern"), "common.cancel": ("Cancel", "Abbrechen"), "common.delete": ("Delete", "Löschen"), +"common.edit": ("Edit", "Bearbeiten"), "common.search": ("Search", "Suchen"), "common.export": ("Export", "Exportieren"), +"common.import": ("Import", "Importieren"), "common.back": ("Back", "Zurück"), "common.yes": ("Yes", "Ja"), "common.no": ("No", "Nein"), +"common.active": ("Active", "Aktiv"), "common.actions": ("Actions", "Aktionen"), "common.name": ("Name", "Name"), +"common.description": ("Description", "Beschreibung"), "common.language": ("Language", "Sprache"), "common.all": ("All", "Alle"), +"profile.title": ("User profile", "Benutzerprofil"), "profile.username": ("Username", "Benutzername"), +"profile.display_name": ("Display name", "Anzeigename"), "profile.email": ("Email", "E-Mail"), "profile.source": ("Source", "Quelle"), +"profile.last_login": ("Last sign-in", "Letzte Anmeldung"), "profile.change_password": ("Change password", "Passwort ändern"), +"profile.current_password": ("Current password", "Aktuelles Passwort"), "profile.new_password": ("New password", "Neues Passwort"), +"profile.save_language": ("Save language", "Sprache speichern"), +"profile.browser_storage": ("Browser storage", "Browserspeicherung"), +"profile.browser_storage_help": ("Store table filters, column order and future interface preferences in this browser, even after it is closed.", "Tabellenfilter, Spaltenreihenfolgen und künftige Oberflächeneinstellungen in diesem Browser dauerhaft speichern."), +"profile.browser_storage_enable": ("Enable persistent browser storage", "Dauerhafte Browserspeicherung aktivieren"), +"profile.save_browser_storage": ("Save browser storage setting", "Browserspeicherung speichern"), "language.changed": ("Language changed", "Sprache geändert"), +"settings.appearance": ("Appearance", "Darstellung"), "settings.application_title": ("Application title", "Anwendungstitel"), +"settings.company_name": ("Company name", "Firmenname"), "settings.company_logo": ("Company logo", "Firmenlogo"), +"settings.remove_logo": ("Remove logo", "Logo entfernen"), "settings.authentication": ("User authentication", "Benutzeranmeldung"), +"settings.save": ("Save settings", "Einstellungen speichern"), "settings.translations": ("Translations", "Übersetzungen"), +"settings.translation_help": ("Manage interface languages and translations. English is the source and fallback language.", "Oberflächensprachen und Übersetzungen verwalten. Englisch ist Quell- und Rückfallsprache."), +"translations.title": ("Translations", "Übersetzungen"), "translations.export_excel": ("Export Excel", "Excel exportieren"), +"translations.import_excel": ("Import Excel", "Excel importieren"), "translations.only_missing": ("Only missing translations", "Nur fehlende Übersetzungen"), +"translations.key": ("Key", "Schlüssel"), "translations.english": ("English", "Englisch"), "translations.german": ("German", "Deutsch"), +"translations.import_options": ("Import options", "Importoptionen"), "translations.ignore_empty": ("Ignore empty cells", "Leere Zellen ignorieren"), +"translations.updated": ("Translations updated", "Übersetzungen aktualisiert"), "translations.imported": ("Translations imported", "Übersetzungen importiert"), +"users.management": ("User management", "Benutzerverwaltung"), "users.new": ("Create user", "Benutzer anlegen"), +"users.delete": ("Delete user", "Benutzer löschen"), "users.ldap_import": ("Import LDAP users", "LDAP-Benutzer übernehmen"), +"users.protected_local_admin": ("Protected local emergency administrator", "Geschützter lokaler Notfall-Administrator"), +"users.protected_local_admin_help": ("This account remains active as a local administrator even when LDAP authentication is enabled. It cannot be disabled or deleted.", "Dieses Konto bleibt auch bei aktivierter LDAP-Anmeldung als lokaler Administrator verfügbar. Es kann nicht deaktiviert oder gelöscht werden."), +"users.protected_delete_forbidden": ("Protected local administrators cannot be deleted.", "Geschützte lokale Administratoren können nicht gelöscht werden."), +"assets.bulk_edit": ("Edit data", "Daten ändern"), "assets.delete_selected": ("Delete selected", "Markierte löschen"), +"assets.select_all": ("Select all", "Alle markieren"), "assets.category": ("Category", "Kategorie"), "assets.status": ("Status", "Status"), +"assets.operating_system": ("Operating system", "Betriebssystem"), "assets.manufacturer": ("Manufacturer", "Hersteller"), +"assets.model": ("Model", "Modell"), "assets.location": ("Location", "Standort"), "assets.room": ("Room", "Raumnummer"), +"assets.parent": ("Parent asset", "Übergeordnetes Gerät"), "charts.manage": ("Manage charts", "Diagramme verwalten"), +"charts.create": ("Create chart", "Diagramm erstellen"), "charts.no_data": ("No data available", "Keine Daten vorhanden"), +"ldap.title": ("LDAP user search", "LDAP-Benutzersuche"), "ldap.show_all": ("Show all AD users", "Alle AD-Benutzer anzeigen"), +"ldap.save_selected": ("Save selected users", "Ausgewählte Benutzer speichern"), "ldap.query": ("Search term or wildcard", "Suchbegriff oder Wildcard"), +"login.title": ("Sign in", "Anmelden"), "login.password": ("Password", "Passwort"), + +"common.assets": ("Assets", "Assets"), "common.date": ("Date", "Datum"), "common.order": ("Order", "Reihenfolge"), +"common.new": ("New", "Neu"), "common.filter": ("Filter", "Filtern"), "common.reset": ("Reset", "Zurücksetzen"), +"common.duplicate": ("Duplicate", "Duplizieren"), "common.read_only": ("Read only", "Nur lesen"), +"dashboard.overview": ("Overview", "Übersicht"), "dashboard.recently_changed": ("Recently changed", "Zuletzt geändert"), +"dashboard.list_fields_hint": ("The fields enabled as ‘Show in list’ for each category are displayed.", "Angezeigt werden die Felder, die in der jeweiligen Kategorie unter „In Liste“ aktiviert sind."), +"charts.show_all": ("Show all charts", "Alle Diagramme anzeigen"), "charts.visible_assets_hint": ("Current analyses based on the assets visible to you.", "Aktuelle Auswertungen aus den für dich sichtbaren Assets."), +"charts.none": ("No charts have been created yet.", "Noch keine Diagramme vorhanden."), +"assets.create": ("Create asset", "Asset anlegen"), "assets.export_excel": ("Export Excel", "Excel exportieren"), +"assets.import_excel": ("Import Excel", "Excel importieren"), "assets.column_order_hint": ("Columns can be moved by dragging the table header. The order is saved in this browser.", "Spalten können am Tabellenkopf verschoben werden. Die Reihenfolge wird in diesem Browser gespeichert."), +"assets.name": ("Name", "Bezeichnung"), "assets.issue": ("Issue", "Ausgeben"), "assets.return": ("Return", "Zurücknehmen"), +"assets.duplicate": ("Duplicate", "Duplizieren"), "assets.none": ("No assets available.", "Keine Assets vorhanden."), +"assets.issue_asset": ("Issue asset", "Asset ausgeben"), "assets.delete_confirm": ("Really delete asset ‘{name}’?", "Asset „{name}“ wirklich löschen?"), +"assets.no_ip": ("No IP", "keine IP"), "assets.no_location": ("No location", "kein Standort"), +"tree.root_node": ("Root node", "Oberster Knoten"), "tree.all_root_assets": ("All root assets", "Alle Wurzelgeräte"), +"tree.no_category_selection": ("No selection shows all categories.", "Keine Auswahl zeigt alle Kategorien."), +"tree.name_contains": ("Name contains", "Name enthält"), "tree.hide_standalone": ("Hide standalone assets without a parent asset", "Einzelne Geräte ohne übergeordnetes Gerät ausblenden"), +"tree.no_matches": ("No matching assets found.", "Keine passenden Geräte gefunden."), +"categories.create": ("Create category", "Kategorie anlegen"), "categories.active_fields": ("Active fields", "Aktive Felder"), +"status.help": ("Define which status is set when assets are issued and returned.", "Lege hier fest, welcher Status beim Ausgeben und Zurücknehmen gesetzt wird."), +"status.on_issue": ("On issue", "Beim Ausgeben"), "status.on_return": ("On return", "Beim Zurücknehmen"), +"status.delete_confirm": ("Really delete this status value?", "Statuswert wirklich löschen?"), "status.new_value": ("New status value", "Neuer Statuswert"), +"mesh.title": ("MeshCentral synchronization", "MeshCentral-Synchronisierung"), "mesh.start": ("Start synchronization", "Synchronisierung starten"), +"mesh.linked_assets": ("linked assets", "verknüpfte Assets"), "mesh.conflicts": ("Conflicts", "Konflikte"), "mesh.missing": ("no longer found", "nicht mehr gefunden"), +"mesh.live_log": ("Live log", "Live-Protokoll"), "mesh.ready": ("ready", "bereit"), "mesh.ready_message": ("Ready for synchronization.", "Bereit für die Synchronisierung."), +"mesh.configuration": ("Configuration", "Konfiguration"), "mesh.file": ("File", "Datei"), "mesh.user": ("User", "Benutzer"), +"mesh.verify_tls": ("Verify TLS", "TLS prüfen"), "mesh.asset_matching": ("Asset matching", "Asset-Zuordnung"), +"mesh.fallback_category_id": ("Fallback category ID", "Fallback-Kategorie-ID"), "mesh.device_type_mapping": ("Device type mapping", "Gerätetyp-Mapping"), +"mesh.type": ("Type", "Typ"), "mesh.recent_runs": ("Recent runs", "Letzte Läufe"), "mesh.start_time": ("Start", "Start"), +"mesh.found": ("Found", "Gefunden"), "mesh.created": ("Created", "Neu"), "mesh.changed": ("Changed", "Geändert"), +"mesh.errors": ("Errors", "Fehler"), "mesh.message": ("Message", "Meldung"), "mesh.no_runs": ("No synchronization has been run yet.", "Noch keine Synchronisierung ausgeführt."), +"mesh.running": ("Synchronization running …", "Synchronisierung läuft …"), "mesh.starting": ("starting", "startet"), +"mesh.log_load_error": ("The log could not be loaded.", "Protokoll konnte nicht geladen werden."), "mesh.start_error": ("Could not start synchronization.", "Start fehlgeschlagen."), +"mesh.finished_visible": ("Synchronization finished. The log remains visible.", "Synchronisierung beendet. Das Protokoll bleibt sichtbar."), +"settings.mesh_mapping": ("MeshCentral category mapping", "MeshCentral-Kategoriezuordnung"), +"settings.mesh_mapping_help": ("Mappings are stored using stable category IDs. Renaming a category does not interrupt synchronization. Existing assets keep their current category; mappings apply only to newly imported devices.", "Die Zuordnung wird über stabile Kategorie-IDs gespeichert. Umbenennen einer Kategorie unterbricht die Synchronisierung daher nicht mehr. Bestehende Assets behalten ihre bisherige Kategorie; die Zuordnung gilt nur für neu importierte Geräte."), +"settings.fallback_category": ("Fallback category", "Fallback-Kategorie"), "settings.mobile_device": ("Mobile device", "Mobilgerät"), +"settings.device_type": ("Device type", "Gerätetyp"), "settings.other_device": ("other device", "sonstiges Gerät"), +"settings.mesh_type": ("MeshCentral type", "MeshCentral-Typ"), "settings.use_fallback": ("Use fallback", "Fallback verwenden"), +"settings.auth_help": ("Administrators have full access. Non-administrators have read-only access to assigned MeshCentral groups.", "Administratoren haben Vollzugriff. Nicht-Administratoren erhalten ausschließlich Lesezugriff auf zugewiesene MeshCentral-Gruppen."), +"settings.auth_mode": ("Authentication mode", "Anmeldemodus"), "settings.auth_none": ("No authentication", "Keine Anmeldung"), +"settings.auth_local": ("Local users", "Lokale Benutzer"), "settings.ldap_connection": ("LDAP connection", "LDAP-Verbindung"), +"settings.ldap_server": ("LDAP server", "LDAP-Server"), "settings.port": ("Port", "Port"), "settings.use_ssl": ("Use SSL/LDAPS", "SSL/LDAPS verwenden"), +"settings.use_starttls": ("Use StartTLS", "StartTLS verwenden"), "settings.bind_user": ("Bind DN / bind user", "Bind-DN / Bind-Benutzer"), +"settings.bind_password_env": ("Environment variable for bind password", "Umgebungsvariable für Bind-Passwort"), +"settings.user_base_dn": ("User base DN", "Benutzer-Basis-DN"), "settings.user_filter": ("User filter", "Benutzerfilter"), +"settings.display_name_attribute": ("Display-name attribute", "Attribut Anzeigename"), "settings.email_attribute": ("Email attribute", "Attribut E-Mail"), +"settings.bind_password_help": ("The bind password remains outside the configuration, for example in the .env file.", "Das Bind-Passwort bleibt außerhalb der Konfiguration, zum Beispiel in der .env-Datei."), +"settings.query_ldap_users": ("Query LDAP users", "LDAP-Benutzer abfragen"), "settings.current_logo": ("Current company logo", "Aktuelles Firmenlogo"), +"users.permissions_help": ("Configure asset visibility and MeshCentral groups for non-administrators.", "Konfiguriere die Asset-Sichtbarkeit und MeshCentral-Gruppen für Nicht-Administratoren."), +"users.login_settings": ("Sign-in settings", "Anmeldeeinstellungen"), "users.create_local": ("Create local user", "Lokalen Benutzer anlegen"), +"users.administrator": ("Administrator", "Administrator"), "users.user": ("User", "Benutzer"), +"users.permissions_groups": ("Permissions and groups", "Rechte und Gruppen"), "users.visible_mesh_groups": ("Visible MeshCentral groups for non-administrators", "Sichtbare MeshCentral-Gruppen für Nicht-Administratoren"), +"users.no_mesh_groups": ("No MeshCentral groups are stored in the database yet.", "Noch keine MeshCentral-Gruppen in der Datenbank vorhanden."), +"users.save": ("Save user", "Benutzer speichern"), "users.delete_confirm": ("Really delete user ‘{username}’?", "Benutzer „{username}“ wirklich löschen?"), +"users.none": ("No users available yet.", "Noch keine Benutzer vorhanden."), "users.relogin_hint": ("After permissions are changed, the affected user must sign out and sign in again.", "Nach einer Rechteänderung muss sich der betreffende Benutzer einmal ab- und wieder anmelden."), +"users.asset_visibility": ("Asset visibility", "Asset-Sichtbarkeit"), +"users.asset_visibility_help": ("Select one or more visibility rules. All enabled rules and MeshCentral groups are combined with OR.", "Wähle eine oder mehrere Sichtbarkeitsregeln. Alle aktiven Regeln und MeshCentral-Gruppen werden mit ODER verknüpft."), +"users.scope_self": ("Only own assets", "Nur eigene Geräte"), +"users.scope_department": ("All assets in own department", "Alle Geräte der eigenen Abteilung"), +"users.scope_location": ("All assets at own location", "Alle Geräte des eigenen Standorts"), +"users.scope_all": ("All assets", "Alle Geräte"), +"users.scope_or_hint": ("Own assets match when the Assigned to field equals the username. Department and location are compared with the employee profile below.", "Eigene Geräte werden erkannt, wenn das Feld „Zugeordnet“ dem Benutzernamen entspricht. Abteilung und Standort werden mit dem nachfolgenden Mitarbeiterprofil verglichen."), +"users.profile_department": ("Employee department", "Abteilung des Mitarbeiters"), +"users.profile_location": ("Employee location", "Standort des Mitarbeiters"), +"users.profile_department_placeholder": ("e.g. IT", "z. B. IT"), +"users.profile_location_placeholder": ("e.g. Headquarters", "z. B. Hauptverwaltung"), +"field.asset_tag": ("Asset tag", "Inventarnummer"), "field.name": ("Name", "Bezeichnung"), +"field.manufacturer": ("Manufacturer", "Hersteller"), "field.model": ("Model", "Modell"), +"field.serial_number": ("Serial number", "Seriennummer"), "field.status": ("Status", "Status"), +"field.assigned_to": ("Assigned to", "Zugeordnet an"), "field.assigned_on": ("Issued on", "Ausgegeben am"), +"field.location": ("Location", "Standort"), "field.room_number": ("Room number", "Raumnummer"), +"field.parent_asset_id": ("Parent asset", "Übergeordnetes Gerät"), "field.department": ("Department", "Abteilung"), +"field.purchase_date": ("Purchase date", "Kaufdatum"), "field.warranty_until": ("Warranty until", "Garantie bis"), +"field.notes": ("Notes", "Notizen"), "field.hostname": ("Hostname", "Computername"), +"field.operating_system": ("Operating system", "Betriebssystem"), "field.os_patch_level": ("OS patch level", "OS-Patchlevel"), +"field.mac_address": ("MAC address", "MAC-Adresse"), "field.ip_address": ("IP address", "IP-Adresse"), +"field.cpu": ("CPU", "CPU"), "field.memory_gb": ("Memory (GB)", "Arbeitsspeicher (GB)"), +"field.storage_gb": ("Total storage (GB)", "Speicher gesamt (GB)"), "field.storage_details": ("Storage devices", "Datenträger"), +"field.gpu_name": ("Graphics adapter", "Grafikkarte"), "field.tpm_version": ("TPM version", "TPM-Version"), +"field.memory_details": ("Memory modules", "Arbeitsspeicher-Module"), "field.antivirus": ("Active antivirus", "Aktiver Virenschutz"), +"field.mesh_group": ("MeshCentral group", "MeshCentral-Gruppe"), "field.mesh_node_id": ("MeshCentral node ID", "MeshCentral Node-ID"), +"field.mesh_mtype": ("MeshCentral device type (mtype)", "MeshCentral-Gerätetyp (mtype)"), + +"common.close": ("Close", "Schließen"), +"common.loading": ("Loading…", "Lade…"), +"common.category": ("Category", "Kategorie"), +"common.select": ("select", "markieren"), +"common.read_only": ("Read only", "Nur lesen"), +"lists.record_count": ("Showing: {visible} of {total} records", "Angezeigt: {visible} von {total} Datensätzen"), +"lists.record_count_hint": ("The count reflects the currently active table filters.", "Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter."), +"lists.filter_active": ("Filter active", "Filter aktiv"), +"lists.filters_active": ("{count} filters active", "{count} Filter aktiv"), +"lists.filter_status": ("Filtered: {visible} of {total} records", "Gefiltert: {visible} von {total} Datensätzen"), +"lists.clear_filters": ("Clear filters", "Filter zurücksetzen"), +"appearance.colors_title": ("System colors", "Systemfarben"), +"appearance.colors_help": ("Configure the central colors used throughout the application.", "Konfigurieren Sie die zentral im gesamten System verwendeten Farben."), +"appearance.primary_color": ("Primary color", "Primärfarbe"), +"appearance.primary_hover_color": ("Primary hover color", "Primärfarbe bei Auswahl"), +"appearance.header_color": ("Header color", "Farbe der Kopfzeile"), +"appearance.navigation_color": ("Navigation color", "Navigationsfarbe"), +"appearance.page_background": ("Page background", "Seitenhintergrund"), +"appearance.surface_color": ("Content surface", "Inhaltsflächen"), +"appearance.text_color": ("Text color", "Textfarbe"), +"appearance.border_color": ("Border color", "Rahmenfarbe"), +"appearance.success_color": ("Success color", "Erfolgsfarbe"), +"appearance.warning_color": ("Warning color", "Warnfarbe"), +"appearance.danger_color": ("Danger color", "Fehlerfarbe"), +"appearance.reset_colors": ("Reset colors", "Farben zurücksetzen"), +"appearance.color_preview": ("Color preview", "Farbenvorschau"), +"appearance.default_theme": ("Default display mode", "Standard-Darstellungsmodus"), +"appearance.theme": ("Display mode", "Darstellungsmodus"), +"appearance.light": ("Light", "Hell"), +"appearance.dark": ("Dark", "Dunkel"), +"appearance.switch_to_light": ("Switch to light mode", "Zum hellen Modus wechseln"), +"appearance.switch_to_dark": ("Switch to dark mode", "Zum dunklen Modus wechseln"), +"appearance.theme_saved": ("Display mode saved", "Darstellungsmodus gespeichert"), +"appearance.profile_help": ("Your selection is stored in your profile and applies on every device after signing in.", "Ihre Auswahl wird im Profil gespeichert und gilt nach der Anmeldung auf jedem Gerät."), +"appearance.colors_saved": ("Appearance saved", "Darstellung gespeichert"), +"assets.bulk_edit": ("Edit data", "Daten ändern"), +"assets.delete_selected": ("Delete selected", "Markierte löschen"), +"assets.reload": ("Reload content", "Inhalt neu laden"), +"assets.export_excel": ("Export Excel", "Excel exportieren"), +"assets.import_excel": ("Import Excel", "Excel importieren"), +"assets.create": ("Create asset", "Asset anlegen"), +"assets.columns": ("Columns", "Spalten"), +"assets.columns_hint": ("Show or hide columns. The selection is stored in this browser.", "Spalten ein- oder ausblenden. Die Auswahl wird in diesem Browser gespeichert."), +"assets.show_all_columns": ("Show all", "Alle einblenden"), +"assets.reset_columns": ("Reset", "Zurücksetzen"), +"assets.hide_column": ("Hide column", "Spalte ausblenden"), +"assets.manage_columns": ("Manage columns…", "Spalten verwalten…"), +"assets.issue": ("Issue", "Ausgeben"), +"assets.return": ("Return", "Zurücknehmen"), +"assets.none": ("No assets available.", "Keine Assets vorhanden."), +"assets.import_title": ("Excel import", "Excel-Import"), +"assets.import_template": ("Download Excel template", "Excel-Vorlage herunterladen"), +"assets.import_file": ("Excel file", "Excel-Datei"), +"assets.import_only_active": ("Import only active fields", "Nur aktive Felder importieren"), +"assets.import_only_active_help": ("By default, all known fields enabled for Excel import are imported, even if they are not active or visible in the selected category. Enable this option only to restrict the import to active category fields.", "Standardmäßig werden alle bekannten und für den Excel-Import freigegebenen Felder übernommen, auch wenn sie in der jeweiligen Kategorie nicht aktiv oder sichtbar sind. Aktiviere diese Option nur, wenn der Import auf die aktiven Felder der Kategorie begrenzt werden soll."), +"assets.import_start": ("Start import", "Import starten"), +"categories.activate_default_fields": ("Activate default fields", "Standardfelder aktivieren"), +"categories.select_all": ("Select all categories", "Alle Kategorien markieren"), +"categories.fields_total": ("Total fields", "Felder gesamt"), +"categories.list_fields": ("In list", "In Liste"), +"categories.attributes": ("Active attributes", "Aktive Attribute"), +"categories.shown_in_list": ("Visible in list", "In Liste sichtbar"), +"categories.active_only": ("Active but not visible in list", "Aktiv, aber nicht in Liste sichtbar"), +"categories.no_active_fields": ("No active fields", "Keine aktiven Felder"), +"categories.in_use": ("Category is still in use", "Kategorie wird noch verwendet"), +"categories.confirm_delete": ("Delete this category? This is only possible when it is not used by any asset.", "Kategorie wirklich löschen? Das ist nur möglich, wenn sie von keinem Asset verwendet wird."), +"categories.bulk.edit": ("Edit field", "Feld ändern"), +"categories.bulk.dialog_title": ("Edit selected categories", "Markierte Kategorien ändern"), +"categories.bulk.field": ("Field", "Feld"), +"categories.bulk.value": ("New value", "Neuer Inhalt"), +"categories.bulk.selected_count": ("{count} categories selected", "{count} Kategorien ausgewählt"), +"categories.bulk.name_hint": ("The name is only available when exactly one category is selected.", "Der Name kann nur geändert werden, wenn genau eine Kategorie markiert ist."), +"categories.bulk.clear": ("Clear the selected field", "Ausgewähltes Feld leeren"), +"categories.bulk.image": ("Category image", "Kategoriebild"), +"categories.bulk.image_hint": ("The same image is assigned to all selected categories.", "Dasselbe Bild wird allen markierten Kategorien zugewiesen."), +"categories.bulk.no_selection": ("No categories selected", "Keine Kategorien ausgewählt"), +"categories.bulk.name_single_only": ("The name can only be changed for one category at a time", "Der Name kann nur für eine Kategorie gleichzeitig geändert werden"), +"categories.bulk.name_required": ("The category name must not be empty", "Der Kategoriename darf nicht leer sein"), +"categories.bulk.image_required": ("Select an image or choose clear value", "Bitte ein Bild auswählen oder das Feld leeren"), +"categories.bulk.name_duplicate": ("The category name already exists", "Der Kategoriename ist bereits vorhanden"), +"categories.bulk.updated": ("Updated {count} categories", "{count} Kategorien wurden geändert"), +"categories.bulk.unsupported_field": ("Unsupported category field", "Nicht unterstütztes Kategorienfeld"), +"categories.fields.manage": ("Manage fields", "Felder verwalten"), +"categories.fields.add": ("Add", "Hinzufügen"), +"categories.fields.remove": ("Remove", "Entfernen"), +"categories.fields.settings": ("Change settings", "Einstellungen ändern"), +"categories.fields.copy": ("Apply template", "Übernehmen"), +"categories.fields.add_help": ("The selected fields are activated in all selected categories. Existing assignments are retained.", "Die ausgewählten Felder werden in allen markierten Kategorien aktiviert. Bereits vorhandene Zuordnungen bleiben erhalten."), +"categories.fields.remove_help": ("The selected fields are deactivated in all selected categories. Existing asset data is retained. The name field cannot be removed.", "Die ausgewählten Felder werden in allen markierten Kategorien deaktiviert. Vorhandene Assetdaten bleiben erhalten. Das Namensfeld kann nicht entfernt werden."), +"categories.fields.settings_help": ("Settings are changed only for categories in which the field is active.", "Die Einstellungen werden nur für Kategorien geändert, in denen das Feld aktiv ist."), +"categories.fields.copy_help": ("The complete field assignment and settings are copied from an existing category to all selected categories. Fields not active in the template are deactivated in the target categories; asset data is retained.", "Die komplette Feldzuordnung und deren Einstellungen werden aus einer vorhandenen Kategorie auf alle markierten Kategorien übernommen. Nicht in der Vorlage aktive Felder werden in den Zielkategorien deaktiviert; Assetdaten bleiben erhalten."), +"categories.fields.source": ("Template category", "Vorlagenkategorie"), +"categories.fields.copy_warning": ("The field configuration of the selected categories is aligned with the template. Category names, descriptions, images and assets are not changed.", "Die Feldkonfiguration der markierten Kategorien wird an die Vorlage angeglichen. Kategorienamen, Beschreibungen, Bilder und Assets werden nicht verändert."), +"categories.fields.change_required": ("Change required-field setting", "Pflichtfeld-Einstellung ändern"), +"categories.fields.change_list": ("Change list display", "Listenanzeige ändern"), +"categories.fields.no_selection": ("No categories selected", "Keine Kategorien ausgewählt"), +"categories.fields.choose_fields": ("Select at least one field", "Bitte mindestens ein Feld auswählen"), +"categories.fields.choose_field": ("Select a field", "Bitte ein Feld auswählen"), +"categories.fields.choose_setting": ("Select at least one setting to change", "Bitte mindestens eine zu ändernde Einstellung auswählen"), +"categories.fields.choose_source": ("Select a source category", "Bitte eine Vorlagenkategorie auswählen"), +"categories.fields.unsupported_operation": ("Unsupported field operation", "Nicht unterstützte Feldaktion"), +"categories.fields.updated": ("Field configuration updated for {count} categories", "Feldkonfiguration bei {count} Kategorien aktualisiert"), +"settings.favicon": ("Browser tab logo (favicon)", "Browser-Tab-Logo (Favicon)"), +"settings.favicon_hint": ("Recommended: a square PNG, for example 32 × 32 or 64 × 64 pixels.", "Empfohlen: quadratisches PNG, zum Beispiel 32 × 32 oder 64 × 64 Pixel."), +"settings.current_favicon": ("Current browser tab logo", "Aktuelles Browser-Tab-Logo"), +"settings.remove_favicon": ("Remove browser tab logo", "Browser-Tab-Logo entfernen"), +"settings.about_information": ("About information", "Systeminformationen"), +"settings.about_information_help": ("These values are shown in the About dialog. The application version is read from the VERSION file and cannot be edited here.", "Diese Angaben werden im Info-Dialog angezeigt. Die Anwendungsversion wird aus der Datei VERSION gelesen und kann hier nicht bearbeitet werden."), +"settings.about_author": ("Created by / author", "Erstellt von / Autor"), +"settings.about_organization": ("Organization", "Organisation"), +"settings.about_contact": ("Contact", "Kontakt"), +"settings.about_website": ("Website", "Webseite"), +"settings.about_repository": ("Source repository", "Quellcode-Repository"), +"settings.about_license": ("License", "Lizenz"), +"settings.about_copyright": ("Copyright notice", "Copyright-Hinweis"), +"settings.version_from_file": ("Version from VERSION file", "Version aus der Datei VERSION"), +"about.open": ("About this application", "Informationen zu dieser Anwendung"), +"about.version": ("Version", "Version"), +"about.author": ("Created by", "Erstellt von"), +"about.organization": ("Organization", "Organisation"), +"about.contact": ("Contact", "Kontakt"), +"about.website": ("Website", "Webseite"), +"about.repository": ("Repository", "Repository"), +"about.license": ("License", "Lizenz"), +"about.copyright": ("Copyright", "Copyright"), +"ldap.title": ("LDAP users", "LDAP-Benutzer"), +"ldap.description": ("Show active users from Active Directory and create them in AssetManager.", "Aktive Benutzer aus dem Active Directory anzeigen und im AssetManager anlegen."), +"ldap.back_to_users": ("Back to user management", "Zur Benutzerverwaltung"), +"ldap.search_label": ("Username, display name or email", "Benutzername, Anzeigename oder E-Mail"), +"ldap.search_placeholder": ("Empty = all, wildcard: *", "Leer = alle, Wildcard: *"), +"ldap.query": ("Search", "Abfragen"), +"ldap.show_all": ("Show all AD users", "Alle AD-Benutzer anzeigen"), +"ldap.search_help": ("An empty search shows all active users. Examples: schmidt*, *verwaltung*. Disabled AD accounts are excluded.", "Eine leere Eingabe zeigt alle aktiven Benutzer. Beispiele: schmidt*, *verwaltung*. Deaktivierte AD-Konten werden ausgeblendet."), +"ldap.users_found": ("{count} users found", "{count} Benutzer gefunden"), +"ldap.save_selected": ("Save selected users", "Ausgewählte Benutzer speichern"), +"ldap.username": ("Username", "Benutzername"), +"ldap.display_name": ("Display name", "Anzeigename"), +"ldap.email": ("Email", "E-Mail"), +"ldap.status": ("Status", "Status"), +"ldap.dn": ("Distinguished Name", "Distinguished Name"), +"ldap.can_save": ("Can be saved", "Kann gespeichert werden"), +"ldap.exists": ("Already exists", "Bereits vorhanden"), + +} + +BASE_TRANSLATIONS.update({ + "login.title": ("Sign in", "Anmelden"), + "login.username": ("Username", "Benutzername"), + "login.password": ("Password", "Passwort"), + "login.current_password": ("Current password", "Aktuelles Passwort"), + "login.submit": ("Sign in", "Anmelden"), + "translations.add": ("Add translation", "Übersetzung hinzufügen"), + "translations.delete_selected": ("Delete selected", "Markierte löschen"), + "translations.delete_confirm": ("Really delete the selected custom translations?", "Die ausgewählten eigenen Übersetzungen wirklich löschen?"), + "translations.select_all": ("Select all custom translations", "Alle eigenen Übersetzungen markieren"), + "translations.builtin": ("Built-in", "Systemeintrag"), + "translations.builtin_protected": ("Built-in translation keys cannot be deleted.", "System-Übersetzungsschlüssel können nicht gelöscht werden."), + + "profile.toast_settings": ("Toast notifications", "Toast-Meldungen"), + "profile.toast_position": ("Position", "Position"), + "profile.toast_duration": ("Display duration in seconds", "Anzeigedauer in Sekunden"), + "profile.toast_top_left": ("Top left", "Links oben"), + "profile.toast_top_center": ("Top center", "Mitte oben"), + "profile.toast_top_right": ("Top right", "Rechts oben"), + "profile.toast_bottom_left": ("Bottom left", "Links unten"), + "profile.toast_bottom_center": ("Bottom center", "Mitte unten"), + "profile.toast_bottom_right": ("Bottom right", "Rechts unten"), + "common.duplicate": ("Duplicate", "Duplizieren"), + "common.edit": ("Edit", "Bearbeiten"), + "common.delete": ("Delete", "Löschen"), + "common.description": ("Description", "Beschreibung"), + "common.field": ("Field", "Feld"), + "common.unknown": ("unknown", "unbekannt"), + "assets.return": ("Return", "Zurücknehmen"), + "assets.history": ("History", "Historie"), + "assets.child_assets": ("Child assets", "Untergeordnete Geräte"), + "mesh.debug": ("Mesh debug", "Mesh-Debug"), + "mesh.debug_start": ("Read device details", "Gerätedetails auslesen"), + "mesh.debug_loading": ("Reading MeshCentral details…", "MeshCentral-Details werden gelesen…"), + "mesh.debug_failed": ("Mesh debug failed", "Mesh-Debug fehlgeschlagen"), + "mesh.debug_success": ("MeshCentral details were written to the browser console and the server debug log. Open F12 → Console.", "Die MeshCentral-Details wurden in die Browser-Konsole und das Server-Debugprotokoll geschrieben. Öffne F12 → Konsole."), + "mesh.debug_reauth_help": ("For security, enter your current password again before reading the raw MeshCentral device data.", "Gib aus Sicherheitsgründen dein aktuelles Passwort erneut ein, bevor die rohen MeshCentral-Gerätedaten gelesen werden."), + "mesh.node_id": ("Node ID", "Node-ID"), + "mesh.last_sync": ("Last synchronization", "Letzte Synchronisierung"), + "mesh.field_conflicts": ("Field conflicts", "Feldkonflikte"), + "mesh.local_value": ("Local", "Lokal"), + "mesh.rule": ("Rule", "Regel"), + "categories.edit": ("Edit category", "Kategorie bearbeiten"), + "categories.image": ("Category image", "Kategoriebild"), + "categories.fields_title": ("Fields of the central asset table", "Felder der zentralen Asset-Tabelle"), + "categories.database_field": ("Database field", "Datenbankfeld"), + "categories.display_name": ("Display name", "Anzeigename"), + "categories.required": ("Required", "Pflicht"), + "categories.in_list": ("In list", "In Liste"), + "categories.readonly": ("Read only", "Nur Lesen"), +}) + + +BASE_TRANSLATIONS.update({ + "inventory.title": ("Device inventory", "Geräteinventar"), + "inventory.subtitle": ("Hardware and software data from the latest MeshCentral synchronization.", "Hardware- und Softwaredaten aus der letzten MeshCentral-Synchronisierung."), + "inventory.download_json": ("Download JSON", "JSON herunterladen"), + "inventory.snapshot": ("Inventory snapshot", "Inventarstand"), + "inventory.tab.hardware": ("Hardware", "Hardware"), + "inventory.tab.software": ("Software", "Software"), + "inventory.tab.raw_json": ("MeshCentral JSON", "MeshCentral JSON"), + "inventory.tab.sync": ("Synchronization", "Synchronisierung"), + "inventory.no_hardware": ("No hardware details have been stored for this asset yet. Run a MeshCentral synchronization.", "Für dieses Asset wurden noch keine Hardwaredetails gespeichert. Bitte eine MeshCentral-Synchronisierung ausführen."), + "inventory.software_filter": ("Filter software", "Software filtern"), + "inventory.software_filter_placeholder": ("Name, version or publisher", "Name, Version oder Hersteller"), + "inventory.no_software_title": ("No software list available.", "Keine Softwareliste vorhanden."), + "inventory.no_software_help": ("The MeshCentral device details currently contain no installed software for this device. This area is populated automatically as soon as the source provides a software segment.", "Die aktuell von MeshCentral gelieferte Geräte-Detailabfrage enthält bei diesem Gerät keine installierte Software. Der Bereich wird automatisch gefüllt, sobald die Quelle ein Softwaresegment liefert."), + "inventory.no_raw_data": ("No MeshCentral raw data stored.", "Keine MeshCentral-Rohdaten gespeichert."), + "inventory.entry": ("Entry", "Eintrag"), + "inventory.value.unknown": ("Unknown", "Unbekannt"), + "inventory.section.system_overview": ("System overview", "Systemübersicht"), + "inventory.section.bios": ("BIOS / UEFI", "BIOS / UEFI"), + "inventory.section.mainboard": ("Mainboard", "Mainboard"), + "inventory.section.processors": ("Processors", "Prozessoren"), + "inventory.section.memory": ("Memory", "Arbeitsspeicher"), + "inventory.section.storage": ("Storage and volumes", "Datenträger und Volumes"), + "inventory.section.graphics": ("Graphics adapters", "Grafikkarten"), + "inventory.section.network": ("Network", "Netzwerk"), + "inventory.section.tpm": ("TPM and security hardware", "TPM und Sicherheitshardware"), + "inventory.section.os_details": ("Operating system details", "Betriebssystemdetails"), + "inventory.section.security": ("Security", "Sicherheit"), + "inventory.section.mesh_runtime": ("MeshCentral and runtime", "MeshCentral und Laufzeit"), + "inventory.section.other_hardware": ("Other hardware data", "Weitere Hardwaredaten"), + "inventory.section.other_windows_hardware": ("Other Windows hardware data", "Weitere Windows-Hardwaredaten"), + "inventory.group.physical_drives": ("Physical drives", "Physische Datenträger"), + "inventory.group.device_identifiers": ("Device identifiers", "Gerätekennungen"), + "inventory.group.partitions": ("Partitions", "Partitionen"), + "inventory.group.volumes": ("Volumes", "Volumes"), + "inventory.group.adapters": ("Adapters", "Adapter"), + "inventory.group.dns_servers": ("DNS servers", "DNS-Server"), + "inventory.group.antivirus": ("Antivirus", "Antivirus"), + "inventory.group.windows_security_center": ("Windows Security Center", "Windows-Sicherheitscenter"), + "inventory.group.microsoft_defender": ("Microsoft Defender", "Microsoft Defender"), + "inventory.group.mesh_agent": ("MeshAgent", "MeshAgent"), + "inventory.group.agent_build": ("Agent build", "Agent-Build"), + "inventory.group.intel_amt": ("Intel AMT", "Intel AMT"), + "inventory.group.known_users": ("Known users", "Bekannte Benutzer"), + "inventory.group.logged_on_users": ("Logged-on users", "Angemeldete Benutzer"), + "inventory.group.last_connection": ("Last connection", "Letzte Verbindung"), + "inventory.property.name": ("Name", "Name"), + "inventory.property.status": ("Status", "Status"), + "inventory.property.type": ("Type", "Typ"), + "inventory.property.mac_address": ("MAC address", "MAC-Adresse"), + "inventory.property.ipv4_address": ("IPv4 address", "IPv4-Adresse"), + "inventory.property.ipv6_address": ("IPv6 address", "IPv6-Adresse"), + "inventory.property.gateway": ("Gateway", "Gateway"), + "inventory.property.netmask": ("Netmask", "Netzmaske"), + "inventory.property.dns_suffix": ("DNS suffix / FQDN", "DNS-Suffix/FQDN"), + "inventory.property.interface_index": ("Interface index", "Schnittstellenindex"), + "inventory.property.enabled": ("Enabled", "Aktiv"), + "inventory.property.up_to_date": ("Up to date", "Aktuell"), + "inventory.property.device_name": ("Device name", "Gerätename"), + "inventory.property.manufacturer": ("Manufacturer", "Hersteller"), + "inventory.property.model": ("Model", "Modell"), + "inventory.property.product_uuid": ("Product UUID", "Produkt-UUID"), + "inventory.property.serial_number": ("Serial number", "Seriennummer"), + "inventory.property.mesh_group": ("MeshCentral group", "MeshCentral-Gruppe"), + "inventory.property.operating_system": ("Operating system", "Betriebssystem"), + "inventory.property.version": ("Version", "Version"), + "inventory.property.date": ("Date", "Datum"), + "inventory.property.mode": ("Mode", "Modus"), + "inventory.property.last_boot_unix_ms": ("Last system boot (Unix ms)", "Letzter Systemstart (Unix ms)"), +}) + + +BASE_TRANSLATIONS.update({ + "common.none": ("none", "keine"), + "settings.fields": ("Fields", "Felder"), + "settings.current_logo": ("Current company logo", "Aktuelles Firmenlogo"), + "settings.custom_css": ("Custom CSS", "Eigenes CSS"), + "settings.custom_css_help": ("This CSS is loaded after the standard app.css file and can override its appearance. Changes apply to all users and also to the sign-in page.", "Dieser CSS-Code wird nach der Standarddatei app.css geladen und kann deren Darstellung überschreiben. Änderungen gelten für alle Benutzer und bereits auf der Anmeldeseite."), + "settings.fallback_category": ("Fallback category", "Fallback-Kategorie"), + "settings.use_fallback": ("Use fallback", "Fallback verwenden"), + "settings.auth_help": ("Administrators have full access. Non-administrators have read-only access to assigned MeshCentral groups.", "Administratoren haben Vollzugriff. Nicht-Administratoren erhalten ausschließlich Lesezugriff auf zugewiesene MeshCentral-Gruppen."), + "settings.auth_mode": ("Sign-in mode", "Anmeldemodus"), + "settings.auth_none": ("No sign-in", "Keine Anmeldung"), + "settings.auth_local": ("Local users", "Lokale Benutzer"), + "settings.ldap_connection": ("LDAP connection", "LDAP-Verbindung"), + "charts.type": ("Chart type", "Diagrammtyp"), + "charts.grouping": ("Grouping", "Gruppierung"), + "charts.status_filter": ("Status filter", "Statusfilter"), + "charts.dashboard": ("Dashboard", "Dashboard"), + "charts.system_chart": ("Standard chart", "Standarddiagramm"), + "charts.excluded": ("Excluded", "Ausgeschlossen"), + "charts.delete_confirm": ("Really delete this chart?", "Diagramm wirklich löschen?"), + "charts.edit": ("Edit chart", "Diagramm bearbeiten"), + "charts.system_help": ("This is a standard chart. It can be fully viewed and edited, but not deleted.", "Dies ist ein Standarddiagramm. Es kann vollständig eingesehen und bearbeitet, aber nicht gelöscht werden."), + "charts.group_by": ("Group by", "Gruppieren nach"), + "charts.stack_field": ("Subdivision for stacked chart", "Unterteilung bei gestapeltem Diagramm"), + "charts.sorting": ("Sorting", "Sortierung"), + "charts.count_desc": ("Count descending", "Anzahl absteigend"), + "charts.alphabetical": ("Alphabetical", "Alphabetisch"), + "charts.max_items": ("Maximum items", "Maximale Einträge"), + "charts.value_display": ("Value display", "Wertedarstellung"), + "charts.raw_values": ("Raw values", "Rohwerte"), + "charts.auto_group": ("Group automatically", "Automatisch zusammenfassen"), + "charts.custom_mappings": ("Custom mappings", "Eigene Zuordnungen"), + "charts.show_dashboard": ("Show on dashboard", "Auf dem Dashboard anzeigen"), + "charts.include_empty": ("Include empty values", "Leere Werte berücksichtigen"), + "charts.status_filter_help": ("Selected status values are excluded from this chart. Multiple status values can be selected.", "Markierte Statuswerte werden bei der Berechnung dieses Diagramms nicht mitgezählt. Mehrere Statuswerte können gleichzeitig ausgeschlossen werden."), + "charts.no_status_values": ("No status values have been created.", "Keine Statuswerte angelegt."), + "charts.custom_value_mappings": ("Custom value mappings", "Eigene Wertzuordnungen"), + "charts.mapping_help": ("One rule per line in the format: search text => display name", "Eine Regel je Zeile im Format: Suchtext => Anzeigename"), +}) + + +BASE_TRANSLATIONS.update({ + "settings.mesh_type_desktop": ("Desktop / PC", "Desktop / PC"), + "settings.mesh_type_notebook": ("Notebook / laptop", "Notebook / Laptop"), + "settings.mesh_type_mobile": ("Mobile device", "Mobilgerät"), + "settings.mesh_type_server": ("Server", "Server"), + "settings.mesh_type_5": ("Device type 5", "Gerätetyp 5"), + "settings.mesh_type_6": ("Device type 6", "Gerätetyp 6"), + "settings.mesh_type_7": ("Device type 7", "Gerätetyp 7"), + "settings.mesh_type_linux_other": ("Linux / other device", "Linux / sonstiges Gerät"), + "settings.meshcentral_type": ("MeshCentral type", "MeshCentral-Typ"), + "settings.ldap_server": ("LDAP server", "LDAP-Server"), + "settings.port": ("Port", "Port"), + "settings.use_ssl": ("Use SSL/LDAPS", "SSL/LDAPS verwenden"), + "settings.use_starttls": ("Use StartTLS", "StartTLS verwenden"), + "settings.bind_user": ("Bind DN / bind user", "Bind-DN / Bind-Benutzer"), + "settings.bind_password_env": ("Environment variable for bind password", "Umgebungsvariable für Bind-Passwort"), + "settings.user_base_dn": ("User base DN", "Benutzer-Basis-DN"), + "settings.user_filter": ("User filter", "Benutzerfilter"), + "settings.display_name_attribute": ("Display name attribute", "Attribut Anzeigename"), + "settings.email_attribute": ("Email attribute", "Attribut E-Mail"), + "settings.bind_password_help": ("The bind password remains outside the config, for example in .env as LDAP_BIND_PASSWORD=...", "Das Bind-Passwort bleibt außerhalb der Config, z. B. in .env als LDAP_BIND_PASSWORD=..."), +}) + + +_cache: dict[str, dict[str,str]] = {} + +def clear_translation_cache(): _cache.clear() + +def seed_i18n(db: Session) -> None: + for code,name,native,default,order in [("en","English","English",True,0),("de","German","Deutsch",False,1)]: + lang=db.query(Language).filter(Language.code==code).first() + if not lang: + db.add(Language(code=code,name=name,native_name=native,is_active=True,is_default=default,sort_order=order)) + db.flush() + for key,(en,de) in BASE_TRANSLATIONS.items(): + for code,value in (("en",en),("de",de)): + row = db.query(Translation).filter( + Translation.translation_key == key, + Translation.language_code == code, + ).first() + if not row: + db.add(Translation(translation_key=key,language_code=code,text_value=value)) + + # Frühere 0.3.7-Teststände konnten bei den Login-Schlüsseln deutsche + # Texte in der englischen Spalte hinterlassen. Da diese Schlüssel feste, + # geschützte Systemtexte sind, werden sie auf die kanonischen Werte + # zurückgesetzt. Eigene Übersetzungsschlüssel bleiben unangetastet. + for key in ( + "login.title", + "login.username", + "login.password", + "login.submit", + "software.settings.retry_help", + "software.settings.retry_enabled", + "software.settings.retry_enabled_help", + "software.settings.retry_interval_help", + "software.settings.retry_statuses_value", + ): + en_value, de_value = BASE_TRANSLATIONS[key] + for code, value in (("en", en_value), ("de", de_value)): + row = db.query(Translation).filter( + Translation.translation_key == key, + Translation.language_code == code, + ).first() + if row: + row.text_value = value + + db.commit() + clear_translation_cache() + +def languages(db: Session, active_only=True): + q=db.query(Language) + if active_only: q=q.filter(Language.is_active.is_(True)) + return q.order_by(Language.sort_order,Language.code).all() + +def dictionary(db: Session, language_code: str) -> dict[str,str]: + code=(language_code or "en").lower() + if code in _cache: return _cache[code] + data={r.translation_key:r.text_value for r in db.query(Translation).filter(Translation.language_code==code).all()} + _cache[code]=data + return data + +def translate(db: Session, language_code: str, key: str, default: str|None=None, **values) -> str: + own=dictionary(db,language_code).get(key) + english=dictionary(db,"en").get(key) + text=own or english or default or key + try: return text.format(**values) + except (KeyError,ValueError): return text + +BASE_TRANSLATIONS.update({ + "common.important": ("Important", "Wichtig"), + "assets.select_named": ("Select asset {name}", "Asset {name} markieren"), + "assets.bulk_delete_confirm": ("Really delete the selected assets? This action cannot be undone.", "Die markierten Assets wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden."), + "assets.total": ("{count} assets", "{count} Assets"), + "assets.asset_id": ("Asset ID", "Asset-ID"), + "duplicates.merge": ("Merge duplicates", "Duplikate zusammenführen"), + "duplicates.search": ("Find duplicates", "Duplikate suchen"), + "duplicates.search_title": ("Find duplicates in the current list", "Duplikate in der aktuellen Liste suchen"), + "duplicates.visible_only_help": ("Only records currently visible after table filtering are evaluated.", "Es werden nur die aktuell durch Tabellenfilter sichtbaren Datensätze ausgewertet."), + "duplicates.ignore_empty": ("Ignore empty values", "Leere Werte ignorieren"), + "duplicates.ignore_case": ("Ignore letter case", "Groß-/Kleinschreibung ignorieren"), + "duplicates.clear_filter": ("Clear duplicate filter", "Dublettenfilter aufheben"), + "duplicates.show": ("Show duplicates", "Duplikate anzeigen"), + "duplicates.order_help": ("The top record remains. Empty fields are then filled in the selected order.", "Der oberste Datensatz bleibt bestehen. Danach werden in der Reihenfolge nur noch leere Felder ergänzt."), + "duplicates.order_notice": ("The category and name of the top asset remain authoritative. The other assets are deleted only after confirmation.", "Kategorie und Bezeichnung des obersten Assets bleiben maßgeblich. Die übrigen Assets werden erst nach der Bestätigung gelöscht."), + "duplicates.preview": ("View result", "Ergebnis ansehen"), + "duplicates.preview_title": ("Merge preview", "Vorschau der Zusammenführung"), + "duplicates.preview_help": ("Asset {id} remains. Only empty values are filled.", "Asset {id} bleibt bestehen. Nur leere Werte werden ergänzt."), + "duplicates.result": ("Result", "Ergebnis"), "duplicates.source": ("Source", "Quelle"), + "duplicates.survives": ("remains", "bleibt bestehen"), "duplicates.will_delete": ("will be deleted", "wird gelöscht"), + "duplicates.change_order": ("Change order", "Reihenfolge ändern"), + "duplicates.merge_save": ("Merge and save", "Zusammenführen und speichern"), + "duplicates.confirm_apply": ("Save the merge permanently? The following assets will be deleted.", "Zusammenführung endgültig speichern? Die nachfolgenden Assets werden gelöscht."), + "duplicates.merge_unique_error": ("The duplicates could not be merged because a unique value is already assigned to another asset.", "Die Duplikate konnten nicht zusammengeführt werden, weil ein eindeutiger Wert bereits einem anderen Asset zugeordnet ist."), + "settings.system_information": ("System information", "Systeminformationen"), + "settings.system_information_file_help": ("These values are read from /app/config/APPINFO.json and VERSION and cannot be edited here.", "Diese Angaben werden aus /app/config/APPINFO.json und VERSION gelesen und können hier nicht bearbeitet werden."), +"settings.storage_information": ("Storage and runtime", "Speicher und Laufzeit"), +"settings.storage_information_help": ("The following paths and states are detected at runtime inside the container.", "Die folgenden Pfade und Zustände werden zur Laufzeit im Container ermittelt."), +"settings.database": ("Database", "Datenbank"), +"settings.database_connected": ("PostgreSQL connected", "PostgreSQL verbunden"), +"settings.config_directory": ("Configuration directory", "Konfigurationsverzeichnis"), +"settings.upload_directory": ("Upload directory", "Uploadverzeichnis"), +"settings.log_directory": ("Log directory", "Logverzeichnis"), +"settings.backup_directory": ("Backup directory", "Backupverzeichnis"), +"settings.backup_writable": ("Backup directory writable", "Backupverzeichnis beschreibbar"), +"settings.backup_mount_detected": ("Dedicated Docker mount detected", "Eigenes Docker-Mount erkannt"), +"settings.latest_backup": ("Latest backup", "Letztes Backup"), +"settings.backup_count": ("Number of available backups", "Anzahl vorhandener Backups"), +"settings.backup_storage_used": ("Backup storage used", "Belegter Backup-Speicher"), + "settings.appinfo_path": ("APPINFO file", "APPINFO-Datei"), + "mesh.connection": ("Connection", "Verbindung"), "mesh.password_env_help": ("The password is read only from the specified environment variable and is not stored in config.json.", "Das Kennwort wird ausschließlich aus der angegebenen Umgebungsvariable gelesen und nicht in der config.json gespeichert."), + "mesh.username": ("Username", "Benutzername"), "mesh.password_env": ("Password environment variable", "Kennwort-Umgebungsvariable"), + "mesh.timeout_seconds": ("Timeout in seconds", "Zeitlimit in Sekunden"), "mesh.include_details": ("Load device details", "Gerätedetails laden"), + "mesh.create_missing_assets": ("Create missing assets", "Fehlende Assets anlegen"), "mesh.mark_missing_devices": ("Mark devices no longer found", "Nicht mehr gefundene Geräte markieren"), + "mesh.store_source_json": ("Store source JSON on asset", "Quell-JSON am Asset speichern"), "mesh.save_connection": ("Save connection and behavior", "Verbindung und Verhalten speichern"), + "mesh.field_mappings": ("Field mappings", "Feld-Mappings"), "mesh.mappings_help": ("Synchronization uses only the enabled rules in this table, including system and custom fields. A debug line is written for each applied mapping when a value changes. Dot notation reads nested JSON values; [] traverses lists and | separates alternative paths.", "Die Synchronisierung verwendet ausschließlich die aktivierten Regeln dieser Tabelle – auch für System- und eigene Felder. Im Live-Protokoll erscheint bei einer Änderung für jedes angewendete Mapping eine Debug-Zeile. Punktnotation liest verschachtelte JSON-Werte. [] durchläuft Listen; | trennt alternative Pfade."), + "mesh.mappings_examples": ("Examples", "Beispiele"), "mesh.priority": ("Priority", "Prio"), "mesh.target_field": ("Target field", "Zielfeld"), + "mesh.transformation": ("Transformation", "Transformation"), "mesh.multi_values": ("Multiple values", "Mehrfachwerte"), "mesh.separator": ("Separator", "Trenner"), "mesh.update_rule": ("Update rule", "Update-Regel"), + "mesh.system_mapping": ("System mapping", "System-Mapping"), "mesh.custom_mapping": ("Custom rule", "Eigene Regel"), + "mesh.rule_fill_empty": ("Empty fields only", "Nur leere Felder"), "mesh.rule_mesh_wins": ("MeshCentral wins", "MeshCentral gewinnt"), "mesh.rule_local_wins": ("Local value wins", "Lokaler Wert gewinnt"), + "mesh.save_mappings": ("Save mapping rules", "Mapping-Regeln speichern"), "mesh.new_mapping": ("New mapping rule", "Neue Mapping-Regel"), + "mesh.new_mapping_help": ("Create additional rules for existing system or custom fields. Create custom target fields under Fields first.", "Lege hier zusätzliche Regeln für vorhandene Systemfelder oder eigene Felder an. Eigene Zielfelder werden zuvor unter „Felder“ erstellt."), + "mesh.create_mapping": ("Create mapping rule", "Mapping-Regel anlegen"), "mesh.delete_mapping_confirm": ("Really delete this mapping rule?", "Mapping-Regel wirklich löschen?"), + "mesh.empty_response": ("empty response", "leere Antwort"), +}) + + +BASE_TRANSLATIONS.update({ + "backup.title": ("Backups", "Sicherungen"), + "backup.description": ("Create, download, upload and restore complete AssetManager backups.", "Vollständige AssetManager-Sicherungen erstellen, herunterladen, hochladen und wiederherstellen."), + "backup.create_now": ("Create backup now", "Jetzt sichern"), + "backup.automatic_title": ("Automatic backup", "Automatische Sicherung"), + "backup.directory": ("Backup directory", "Sicherungsverzeichnis"), + "backup.interval": ("Interval", "Intervall"), + "backup.every_hours": ("Every {hours} hours", "Alle {hours} Stunden"), + "backup.retention": ("Retention", "Aufbewahrung"), + "backup.days": ("{days} days", "{days} Tage"), + "backup.contents_help": ("Each backup contains a PostgreSQL database dump, configuration, APPINFO and uploaded files. Environment variables and the .env file are not included.", "Jede Sicherung enthält einen PostgreSQL-Datenbankdump, die Konfiguration, APPINFO und hochgeladene Dateien. Umgebungsvariablen und die Datei .env sind nicht enthalten."), + "backup.upload_title": ("Upload backup", "Sicherung hochladen"), + "backup.upload": ("Upload", "Hochladen"), + "backup.available": ("Available backups", "Vorhandene Sicherungen"), + "backup.file": ("File", "Datei"), + "backup.created": ("Created", "Erstellt"), + "backup.version": ("Version", "Version"), + "backup.reason": ("Reason", "Art"), + "backup.size": ("Size", "Größe"), + "backup.download": ("Download", "Herunterladen"), + "backup.restore": ("Restore", "Wiederherstellen"), + "backup.restore_title": ("Restore backup", "Sicherung wiederherstellen"), + "backup.restore_warning": ("The current database, configuration and uploads will be replaced. A safety backup is created immediately before restoration.", "Die aktuelle Datenbank, Konfiguration und Uploads werden ersetzt. Unmittelbar vor der Wiederherstellung wird automatisch eine Sicherheitssicherung erstellt."), + "backup.restore_confirmation": ("Enter RESTORE to confirm", "Zur Bestätigung RESTORE eingeben"), + "backup.restore_now": ("Restore now", "Jetzt wiederherstellen"), + "backup.delete_confirm": ("Delete this backup permanently?", "Diese Sicherung endgültig löschen?"), + "backup.invalid_metadata": ("Metadata could not be read", "Metadaten konnten nicht gelesen werden"), + "backup.none": ("No backups are available yet.", "Es sind noch keine Sicherungen vorhanden."), + "backup.reason_manual": ("Manual", "Manuell"), + "backup.reason_automatic": ("Automatic", "Automatisch"), + "backup.reason_before_restore": ("Before restore", "Vor Wiederherstellung"), + "backup.reason_unknown": ("Unknown", "Unbekannt"), + "backup.automatic_help": ("Backups are created automatically according to the configured interval and deleted after the retention period.", "Sicherungen werden automatisch im eingestellten Intervall erstellt und nach Ablauf der Aufbewahrungszeit gelöscht."), + "backup.active": ("Active", "Aktiv"), + "backup.upload_help": ("Upload an AssetManager backup ZIP to make it available for download or restoration.", "Lade eine AssetManager-Sicherungsdatei im ZIP-Format hoch, um sie herunterzuladen oder wiederherzustellen."), + "backup.available_help": ("Stored backups can be downloaded, restored or deleted here.", "Gespeicherte Sicherungen können hier heruntergeladen, wiederhergestellt oder gelöscht werden."), + "backup.none_title": ("No backups yet", "Noch keine Sicherungen"), + "backup.created_success": ("Backup created", "Sicherung erstellt"), + "backup.deleted_success": ("Backup deleted", "Sicherung gelöscht"), + "backup.uploaded_success": ("Backup uploaded", "Sicherung hochgeladen"), + "backup.restored_success": ("Backup restored", "Sicherung wiederhergestellt"), + "backup.restore_confirmation_missing": ("Restore confirmation is missing", "Bestätigung für die Wiederherstellung fehlt"), + "assets.image": ("Image", "Bild"), + "common.actions": ("Actions", "Aktionen"), + "common.apply": ("Apply", "Übernehmen"), +}) + +BASE_TRANSLATIONS.update({ + "app.software": ("Software and jobs", "Software und Jobs"), + "software.title": ("Software control", "Softwaresteuerung"), + "software.packages": ("Software packages", "Softwarepakete"), + "software.recent_jobs": ("Recent jobs", "Letzte Jobs"), + "software.jobs_count": ("Number of jobs", "Anzahl der Jobs"), + "software.jobs_filter_all_help": ("Column filters search the complete job history. The selected number only limits how many matching rows are displayed.", "Die Spaltenfilter durchsuchen die vollständige Jobhistorie. Die ausgewählte Anzahl begrenzt nur die angezeigten Treffer."), + "software.package": ("Package", "Paket"), + "software.package_type": ("Type", "Typ"), + "software.parameters": ("Parameters", "Parameter"), + "software.platform": ("Platform", "Plattform"), + "software.status": ("Status", "Status"), + "software.details": ("Details", "Details"), + "software.open": ("Open", "Öffnen"), + "software.no_jobs": ("No jobs yet.", "Noch keine Jobs."), + "software.no_packages": ("No software packages available.", "Keine Softwarepakete vorhanden."), + "software.asset_title": ("Software: {name}", "Software: {name}"), + "software.start_inventory": ("Start software inventory", "Software-Inventur starten"), + "software.back_to_asset": ("Back to asset", "Zurück zum Asset"), + "software.installed": ("Installed software", "Installierte Software"), + "software.publisher": ("Publisher", "Hersteller"), + "software.architecture": ("Architecture", "Architektur"), + "software.install_date": ("Installation date", "Installationsdatum"), + "software.last_seen": ("Last seen", "Zuletzt gesehen"), + "software.no_inventory": ("No software inventory is available yet.", "Noch keine Softwareinventur vorhanden."), + "software.jobs": ("Jobs", "Jobs"), + "software.time": ("Time", "Zeit"), + "software.message": ("Message", "Meldung"), + "software.log": ("Log", "Protokoll"), + "software.show": ("Show", "Anzeigen"), + "software.job_title": ("Software job {id}", "Softwareauftrag {id}"), + "software.created": ("Created", "Erstellt"), + "software.sent": ("Sent", "Gesendet"), + "software.finished": ("Finished", "Beendet"), + "software.exit_code": ("Exit code", "Exitcode"), + "software.diagnostics": ("Diagnostics", "Diagnose"), + "software.local_log": ("Local log file", "Lokale Logdatei"), + "software.callback_health": ("Callback endpoint test", "Callback-Endpunkt-Test"), + "software.callback_token_warning": ("The command below contains the time-limited callback token. Run it only on the associated test device and do not share it.", "Der folgende Befehl enthält den zeitlich begrenzten Callback-Token. Nur auf dem zugehörigen Testgerät ausführen und nicht weitergeben."), + "software.manual_command": ("Manual test command", "Manueller Testbefehl"), + "software.copy_command": ("Copy command", "Befehl kopieren"), + "software.client_log": ("Client log", "Client-Protokoll"), + "software.no_callback": ("No callback has been received yet.", "Noch keine Callback-Rückmeldung."), + "software.meshctrl": ("MeshCtrl", "MeshCtrl"), + "software.stdout": ("Standard output", "Standardausgabe"), + "software.stderr": ("Error output", "Fehlerausgabe"), + "software.inventory_queued": ("Software inventory was submitted to MeshCentral.", "Software-Inventur wurde an MeshCentral übergeben."), + "software.status.created": ("Created", "Erstellt"), + "software.status.sending": ("Sending", "Wird gesendet"), + "software.status.sent": ("Sent; callback pending", "Gesendet; Callback ausstehend"), + "software.status.running": ("Running", "Läuft"), + "software.status.success": ("Successful", "Erfolgreich"), + "software.status.failed": ("Failed", "Fehler"), + "software.status.partial": ("Partially successful", "Teilweise erfolgreich"), + "software.status.timeout": ("Timed out", "Zeitüberschreitung"), + "software.status.cancelled": ("Cancelled", "Abgebrochen"), + "backup.version_unknown": ("Not determinable for this older backup", "Bei dieser älteren Sicherung nicht ermittelbar"), +}) + +BASE_TRANSLATIONS.update({ + "software.settings.menu": ("Software", "Software"), + "software.settings.title": ("Software settings", "Software-Einstellungen"), + "software.settings.description": ("Configure job dispatch, callbacks, timeouts and diagnostics without changing the existing MeshCentral configuration.", "Konfiguriert Job-Versand, Callbacks, Zeitlimits und Diagnose, ohne die bestehende MeshCentral-Konfiguration zu verändern."), + "software.settings.back_to_software": ("Back to software", "Zur Softwareübersicht"), + "software.settings.dispatch_title": ("Job dispatch", "Job-Versand"), + "software.settings.dispatch_help": ("Controls how quickly jobs are handed to MeshCentral during bulk operations.", "Steuert, wie schnell Jobs bei Sammelaktionen an MeshCentral übergeben werden."), + "software.settings.dispatch_badge": ("Active queue control", "Aktive Warteschlangensteuerung"), + "software.settings.dispatch_delay": ("Pause between job starts in seconds", "Pause zwischen Jobstarts in Sekunden"), + "software.settings.dispatch_delay_help": ("The first job starts immediately. Each following job waits this interval. Use 0 to disable the pause.", "Der erste Job startet sofort. Jeder weitere Job wartet dieses Intervall. Mit 0 wird die Pause deaktiviert."), + "software.settings.timeout_check": ("Automatic timeout check", "Automatische Zeitüberschreitungsprüfung"), + "software.settings.timeout_check_value": ("Every 30 seconds", "Alle 30 Sekunden"), + "software.settings.retry_title": ("Automatic retries", "Automatische Wiederholungen"), + "software.settings.retry_help": ("Automatically queues recent incomplete or failed jobs again. The normal dispatch interval continues to apply.", "Reiht aktuelle unvollständige oder fehlgeschlagene Jobs automatisch erneut ein. Das normale Warteintervall des Job-Versands gilt weiterhin."), + "software.settings.retry_badge": ("Automatic recovery", "Automatische Wiederherstellung"), + "software.settings.retry_enabled": ("Automatically restart incomplete or failed jobs", "Unvollständige oder fehlgeschlagene Jobs automatisch erneut starten"), + "software.settings.retry_enabled_help": ("Sent jobs waiting for a callback, failed, partially successful and timed-out jobs are eligible. Successful, currently sending, running and cancelled jobs are never restarted automatically.", "Berücksichtigt werden gesendete Jobs mit ausstehendem Callback, fehlerhafte, teilweise erfolgreiche und abgelaufene Jobs. Erfolgreiche, aktuell versendete, laufende und abgebrochene Jobs werden niemals automatisch neu gestartet."), + "software.settings.retry_max_age": ("Maximum job age in hours", "Maximales Jobalter in Stunden"), + "software.settings.retry_max_age_help": ("Jobs older than this value are ignored. The default is 12 hours.", "Ältere Jobs werden ignoriert. Der Standardwert beträgt 12 Stunden."), + "software.settings.retry_interval": ("Delay before another retry in seconds", "Wartezeit vor einer weiteren Ausführung in Sekunden"), + "software.settings.retry_interval_help": ("An eligible status must remain unchanged for at least this period before the job is queued again. For sent jobs, the interval starts at the dispatch time.", "Ein berücksichtigter Status muss mindestens für diese Dauer unverändert bestehen, bevor der Job erneut eingereiht wird. Bei gesendeten Jobs beginnt die Frist mit dem Versandzeitpunkt."), + "software.settings.retry_max_retries": ("Maximum automatic retries per job", "Maximale automatische Wiederholungen je Job"), + "software.settings.retry_max_retries_help": ("Limits automatic retries and prevents endless restart loops. Manual restarts remain possible.", "Begrenzt automatische Wiederholungen und verhindert Endlosschleifen. Manuelle Neustarts bleiben weiterhin möglich."), + "software.settings.retry_statuses": ("Eligible statuses", "Berücksichtigte Status"), + "software.settings.retry_statuses_value": ("Sent; callback pending · Failed · Partially successful · Timed out", "Gesendet; Callback ausstehend · Fehler · Teilweise erfolgreich · Zeitüberschreitung"), + "software.settings.callback_title": ("Callback connection", "Callback-Verbindung"), + "software.settings.callback_help": ("The client must be able to reach this address directly. HTTP and HTTPS addresses are supported.", "Der Client muss diese Adresse direkt erreichen können. HTTP- und HTTPS-Adressen werden unterstützt."), + "software.settings.callback_badge": ("Job return channel", "Rückkanal der Jobs"), + "software.settings.callback_base_url": ("Callback base URL", "Callback-Basisadresse"), + "software.settings.callback_base_url_help": ("Enter only the base URL without an API path, for example https://assetmanager.example.org. If empty, the address of the current browser request is used.", "Nur die Basisadresse ohne API-Pfad eintragen, zum Beispiel https://assetmanager.example.org. Bei leerem Feld wird die Adresse der aktuellen Browseranfrage verwendet."), + "software.settings.callback_timeout": ("Callback token validity in minutes", "Gültigkeit des Callback-Tokens in Minuten"), + "software.settings.callback_timeout_help": ("After this period the job no longer accepts a callback.", "Nach diesem Zeitraum nimmt der Job keinen Callback mehr an."), + "software.settings.callback_workers": ("Parallel callback workers", "Parallele Callback-Worker"), + "software.settings.callback_workers_help": ("Controls how many callback database imports can run at the same time. A container restart is required after changing this value. Recommended: 4.", "Legt fest, wie viele Callback-Datenbankimporte gleichzeitig laufen dürfen. Nach einer Änderung ist ein Container-Neustart erforderlich. Empfehlung: 4."), + "software.settings.test_timeout": ("Connection test timeout in seconds", "Zeitlimit des Verbindungstests in Sekunden"), + "software.settings.test_timeout_help": ("Maximum waiting time for DNS, connection and response.", "Maximale Wartezeit für DNS, Verbindung und Antwort."), + "software.settings.verify_tls": ("Verify TLS certificate during the test", "TLS-Zertifikat beim Test prüfen"), + "software.settings.verify_tls_help": ("Disable only for a temporary test with an internal or self-signed HTTPS certificate.", "Nur für einen vorübergehenden Test mit internem oder selbstsigniertem HTTPS-Zertifikat deaktivieren."), + "software.settings.health_url": ("Resulting health URL", "Ermittelte Testadresse"), + "software.settings.automatic_url": ("Automatic: current request address", "Automatisch: Adresse der aktuellen Anfrage"), + "software.settings.debug_title": ("Debug logging", "Debugprotokoll"), + "software.settings.debug_help": ("Records connection tests and received callbacks in a separate log.", "Zeichnet Verbindungstests und empfangene Callbacks in einem eigenen Protokoll auf."), + "software.settings.debug_logging": ("Record callback events", "Callback-Ereignisse protokollieren"), + "software.settings.debug_logging_help": ("Tokens and software contents are not written to this diagnostic log.", "Tokens und Softwareinhalte werden nicht in dieses Diagnoseprotokoll geschrieben."), + "software.settings.debug_max_lines": ("Maximum log lines", "Maximale Protokollzeilen"), + "software.settings.debug_max_lines_help": ("Older entries are automatically removed.", "Ältere Einträge werden automatisch entfernt."), + "software.settings.debug_log_path": ("Log file", "Protokolldatei"), + "software.settings.test_title": ("Callback test and live debug log", "Callback-Test und Live-Debugprotokoll"), + "software.settings.test_help": ("The AssetManager calls the configured health endpoint and records DNS resolution, connection, HTTP status and response.", "Der AssetManager ruft den konfigurierten Health-Endpunkt auf und protokolliert DNS-Auflösung, Verbindung, HTTP-Status und Antwort."), + "software.settings.run_test": ("Test callback", "Callback testen"), + "software.settings.log_auto_refresh": ("The log is refreshed automatically every three seconds.", "Das Protokoll wird automatisch alle drei Sekunden aktualisiert."), + "software.settings.clear_log": ("Clear log", "Protokoll leeren"), + "software.settings.no_log": ("No diagnostic entries yet.", "Noch keine Diagnoseeinträge vorhanden."), + "software.settings.future_title": ("Planned extension areas", "Vorgesehene Erweiterungsbereiche"), + "software.settings.future_help": ("This page is prepared for the following settings as software management grows. These options are not active yet.", "Diese Seite ist für folgende Einstellungen vorbereitet, wenn die Softwareverwaltung wächst. Die Optionen sind noch nicht aktiv."), + "software.settings.future_queue": ("Queue and concurrency", "Warteschlange und Parallelität"), + "software.settings.future_retries": ("Retries and error handling", "Wiederholungen und Fehlerbehandlung"), + "software.settings.future_inventory": ("Inventory sources", "Inventurquellen"), + "software.settings.future_retention": ("History and retention", "Historie und Aufbewahrung"), + "software.settings.future_packages": ("Package sources and repositories", "Paketquellen und Repositorys"), + "software.settings.future_security": ("Execution security and approvals", "Ausführungssicherheit und Freigaben"), + "software.settings.saved": ("Software settings saved.", "Software-Einstellungen gespeichert."), + "software.settings.invalid_url": ("Enter a valid HTTP or HTTPS base URL.", "Bitte eine gültige HTTP- oder HTTPS-Basisadresse eingeben."), + "software.settings.test_success": ("Callback endpoint is reachable.", "Callback-Endpunkt ist erreichbar."), + "software.settings.test_failed": ("Callback test failed: {error}", "Callback-Test fehlgeschlagen: {error}"), + "software.settings.log_cleared": ("Debug log cleared.", "Debugprotokoll geleert.") +}) + +BASE_TRANSLATIONS.update({ + "software.job_id": ("Job ID", "Job-ID"), + "software.copy_log": ("Copy log", "Log kopieren") +}) + +BASE_TRANSLATIONS.update({ + "software.initialize_inventory": ("Initialize software list", "Softwareliste initialisieren"), + "software.update_inventory": ("Update software inventory", "Softwareinventur aktualisieren"), + "software.inventory_count": ("{count} software entries stored.", "{count} Softwareeinträge gespeichert."), + "software.inventory_not_initialized": ("Software list not initialized yet", "Softwareliste noch nicht initialisiert"), + "software.inventory_initialize_help": ("Start the initial inventory using the button above. All detected software is currently included.", "Die Erstinventur über die Schaltfläche oben starten. Aktuell wird sämtliche erkannte Software übernommen."), + "software.copied": ("Copied", "Kopiert"), + "software.copy_failed": ("Copy failed", "Kopieren fehlgeschlagen"), + "software.settings.exclusions_title": ("Inventory exclusion list", "Ausschlussliste für die Inventur"), + "software.settings.exclusions_help": ("Software whose name contains a listed text is not stored during the next inventory for the selected platform.", "Software, deren Name einen eingetragenen Text enthält, wird bei der nächsten Inventur für die gewählte Plattform nicht gespeichert."), + "software.settings.active": ("Active", "Aktiv"), + "software.settings.exclude_platform": ("Platform", "Plattform"), + "software.settings.exclude_name_contains": ("Software name contains", "Softwarename enthält"), + "software.settings.exclude_name_placeholder": ("e.g. Security Update", "z. B. Security Update"), + "software.settings.exclude_add": ("Add exclusion", "Ausschluss hinzufügen"), + "software.settings.exclude_remove": ("Remove exclusion", "Ausschluss entfernen"), + "software.settings.exclude_match_help": ("Matching is case-insensitive and checks whether the entered text occurs anywhere in the software name. Empty rows are ignored.", "Die Prüfung erfolgt ohne Beachtung der Groß-/Kleinschreibung und sucht den Text an beliebiger Stelle im Softwarenamen. Leere Zeilen werden ignoriert."), + "software.platform.all": ("All platforms", "Alle Plattformen") +}) + +BASE_TRANSLATIONS.update({ + "software.central_description": ("Central overview of all software inventories and software jobs.", "Zentrale Übersicht aller Softwareinventuren und Softwareaufträge."), + "software.total_entries": ("Software installations", "Softwareinstallationen"), + "software.distinct_software": ("Aggregated software entries", "Kumulierte Softwareeinträge"), + "software.initialized_assets": ("Initialized assets", "Initialisierte Assets"), + "software.all_installations": ("All software installations", "Alle Softwareinstallationen"), + "software.all_installations_help": ("One row per software entry and asset. Every column can be filtered and sorted.", "Eine Zeile je Softwareeintrag und Asset. Jede Spalte kann gefiltert und sortiert werden."), + "software.aggregated_list": ("Aggregated software list", "Kumulierte Softwareliste"), + "software.aggregated_list_help": ("Groups identical software names and publishers and shows their distribution across assets.", "Fasst gleiche Softwarenamen und Hersteller zusammen und zeigt deren Verteilung über die Assets."), + "software.versions": ("Versions", "Versionen"), + "software.version_count": ("Number of versions", "Anzahl Versionen"), + "software.asset_count": ("Assets", "Assets"), + "software.entry_count": ("Entries", "Einträge"), + "software.no_global_inventory": ("No central software inventory is available yet.", "Noch keine zentrale Softwareinventur vorhanden."), + "software.last_job": ("Last job", "Letzter Job"), + "software.last_inventory": ("Last inventory", "Letzte Inventur") +}) + +BASE_TRANSLATIONS.update({ + "software.open_detailed_list": ("Open detailed installation list", "Detaillierte Installationsliste öffnen"), + "software.open_aggregated_list": ("Open aggregated software list", "Kumulierte Softwareliste öffnen"), + "software.lists": ("Software lists", "Softwarelisten"), + "software.detailed_list": ("Detailed installations", "Detaillierte Installationen"), + "software.summary_table": ("Aggregated entries", "Kumulierte Einträge"), + "software.table_filter_help": ("Use the filter row below the column headings to narrow the list.", "Die Filterzeile unterhalb der Spaltenüberschriften grenzt die Liste ein."), + "software.numeric_filter_help": ("Numeric columns support operators such as < 2, >= 5, 1..10 or 'between 1 and 10'.", "Numerische Spalten unterstützen Operatoren wie < 2, >= 5, 1..10 oder „zwischen 1 und 10“."), + "software.numeric_filter_placeholder": ("e.g. < 2 or 1..5", "z. B. < 2 oder 1..5") +}) + + +BASE_TRANSLATIONS.update({ + "software.grouping": ("Grouping", "Gruppierung"), + "software.grouping.name_platform": ("Name and platform", "Name und Plattform"), + "software.grouping.software": ("Name, publisher and platform", "Name, Hersteller und Plattform"), + "software.grouping.version": ("Name, publisher, platform and version", "Name, Hersteller, Plattform und Version"), + "software.grouping.help": ("Choose whether publishers and versions are combined or shown as separate rows.", "Lege fest, ob Hersteller und Versionen zusammengefasst oder als eigene Zeilen angezeigt werden."), +}) + +BASE_TRANSLATIONS.update({ + "field.mesh_online": ("Online", "Online"), + "presence.title": ("Online status", "Online-Status"), + "presence.online": ("Online", "Online"), + "presence.offline": ("Offline", "Offline"), + "presence.unknown": ("Unknown", "Unbekannt"), + "presence.updated": ("Updated", "Aktualisiert"), + "presence.last_online": ("Last online", "Zuletzt online"), + "presence.last_seen": ("Last seen", "Zuletzt gesehen"), + "presence.live_help": ("This status is refreshed automatically for assets with a MeshCentral node ID.", "Dieser Status wird für Assets mit MeshCentral Node-ID automatisch aktualisiert.") +}) + +BASE_TRANSLATIONS.update({ + "jobs.action": ("Action", "Aktion"), + "jobs.action.run": ("Run", "Ausführen"), + "jobs.action.install": ("Install", "Installieren"), + "jobs.action.uninstall": ("Uninstall", "Deinstallieren"), + "jobs.action.update": ("Update", "Aktualisieren"), + "jobs.software_inventory": ("Start software inventory", "Softwareinventur starten"), + "jobs.software_inventory_confirm": ("Create one software inventory job for each of the {count} selected assets?", "Für {count} markierte Assets jeweils einen Softwareinventur-Job erstellen?"), + "jobs.bulk.none_selected": ("No assets were selected.", "Es wurden keine Assets markiert."), + "jobs.bulk.inventory_result": ("Created {created} software inventory jobs; skipped {skipped} assets.", "{created} Softwareinventur-Jobs wurden erstellt; {skipped} Assets wurden übersprungen."), + "jobs.bulk.inventory_none_created": ("No software inventory job could be created; skipped {skipped} assets.", "Es konnte kein Softwareinventur-Job erstellt werden; {skipped} Assets wurden übersprungen.") +}) + +BASE_TRANSLATIONS.update({ + "jobs.type": ("Job type", "Job-Art"), + "jobs.select_type": ("Select job type …", "Job-Art auswählen …"), + "jobs.type.software_inventory": ("Software inventory", "Softwareinventur"), + "jobs.type.job_definition": ("Job definition", "Jobdefinition"), + "jobs.start": ("Start job", "Job starten"), + "jobs.bulk.controls_label": ("Jobs for selected assets", "Jobs für markierte Assets"), + "jobs.bulk.confirm": ("Create and start the job “{job}” for each of the {count} selected assets?", "Für {count} markierte Assets jeweils den Job „{job}“ erstellen und starten?"), + "jobs.bulk.invalid_type": ("The selected job type is not available.", "Die ausgewählte Job-Art ist nicht verfügbar."), + "jobs.bulk.result": ("Created {created} jobs; skipped {skipped} assets.", "{created} Jobs wurden erstellt; {skipped} Assets wurden übersprungen."), + "jobs.bulk.none_created": ("No job could be created; skipped {skipped} assets.", "Es konnte kein Job erstellt werden; {skipped} Assets wurden übersprungen.") +}) + + +BASE_TRANSLATIONS.update({ + "jobs.title": ("Job {id}", "Job {id}"), + "jobs.not_found": ("Job not found", "Job nicht gefunden"), + "jobs.priority": ("Priority", "Priorität"), + "jobs.parameters": ("Parameters", "Parameter"), + "jobs.result": ("Result", "Ergebnis"), + "jobs.created_by": ("Created by", "Erstellt von"), + "jobs.scheduled_at": ("Scheduled for", "Geplant für"), + "jobs.attempts": ("Attempts", "Versuche"), + "jobs.max_attempts": ("Maximum attempts", "Maximale Versuche") +}) + + +BASE_TRANSLATIONS.update({ + "jobs.actions": ("Actions", "Aktionen"), + "jobs.restart": ("Restart job", "Job erneut starten"), + "jobs.restart.confirm": ("Restart this existing job? No new job will be created.", "Diesen bestehenden Job erneut starten? Es wird kein neuer Job angelegt."), + "jobs.restart.started": ("Job was queued again.", "Der Job wurde erneut eingereiht."), + "jobs.restart.active": ("An active job cannot be restarted.", "Ein aktiver Job kann nicht erneut gestartet werden."), + "jobs.restart.no_node": ("The asset has no MeshCentral node ID.", "Das Asset besitzt keine MeshCentral Node-ID."), + "jobs.restart_selected": ("Restart selected jobs", "Markierte Jobs erneut starten"), + "jobs.select_all_restartable": ("Select all restartable visible jobs", "Alle erneut startbaren sichtbaren Jobs markieren"), + "jobs.select_for_restart": ("Select job {id} for restart", "Job {id} zum erneuten Start markieren"), + "jobs.bulk_restart.confirm": ("Restart {count} selected jobs using the configured dispatch interval?", "{count} markierte Jobs mit dem eingestellten Warteintervall erneut starten?"), + "jobs.bulk_restart.none_selected": ("No jobs were selected.", "Es wurden keine Jobs markiert."), + "jobs.bulk_restart.none_eligible": ("None of the selected jobs can be restarted.", "Keiner der markierten Jobs kann erneut gestartet werden."), + "jobs.bulk_restart.started": ("{count} jobs were queued again; {skipped} were skipped.", "{count} Jobs wurden erneut eingereiht; {skipped} wurden übersprungen."), + "jobs.history": ("Job history", "Jobverlauf"), + "jobs.history.empty": ("No history events are available yet.", "Es sind noch keine Verlaufsereignisse vorhanden."), + "jobs.attempt": ("Attempt", "Versuch"), + "jobs.event": ("Event", "Ereignis"), + "jobs.event.created": ("Created", "Erstellt"), + "jobs.event.restart_requested": ("Restart requested", "Neustart angefordert"), + "jobs.event.automatic_retry_requested": ("Automatic retry requested", "Automatischer Neustart angefordert"), + "jobs.event.sending": ("Sending", "Wird gesendet"), + "jobs.event.meshctrl_result": ("MeshCtrl result", "MeshCtrl-Ergebnis"), + "jobs.event.callback": ("Callback", "Callback"), + "jobs.resolved_script": ("Resolved script with real values", "Aufgelöstes Skript mit Echtdaten"), + "jobs.resolved_script_help": ("This is the complete readable script with the real asset, job and callback values used for this execution. The callback URL contains a temporary secret token.", "Dies ist das vollständige lesbare Skript mit den echten Asset-, Job- und Callback-Werten dieser Ausführung. Die Callback-URL enthält einen zeitlich begrenzten geheimen Token."), + "jobs.copy_resolved_script": ("Copy resolved script", "Aufgelöstes Skript kopieren"), + "jobs.meshctrl_command": ("Technical MeshCentral command", "Technischer MeshCentral-Befehl"), + "jobs.event.error": ("Error", "Fehler") +}) + +BASE_TRANSLATIONS.update({ + "software.selected_count": ("Selected", "Markiert"), + "software.delete.selected": ("Delete selected inventory entries", "Markierte Inventareinträge löschen"), + "software.delete.confirm": ("Delete {count} selected software inventory entries?", "{count} markierte Software-Inventareinträge wirklich löschen?"), + "software.delete.inventory_only": ("This only deletes stored inventory data. No software will be uninstalled from the device.", "Dadurch werden nur gespeicherte Inventardaten gelöscht. Auf dem Gerät wird keine Software deinstalliert."), + "software.delete.none_selected": ("No software inventory entries were selected.", "Es wurden keine Software-Inventareinträge ausgewählt."), + "software.delete.result": ("Deleted {count} software inventory entries.", "{count} Software-Inventareinträge wurden gelöscht."), + "software.select_all_visible": ("Select all visible software inventory entries", "Alle sichtbaren Software-Inventareinträge markieren"), + "software.select_entry": ("Select {name}", "{name} markieren"), + "software.platform.windows": ("Windows", "Windows"), + "software.platform.linux": ("Linux", "Linux"), + "software.platform.macos": ("macOS", "macOS"), + "software.platform.apple": ("Apple", "Apple"), + "software.platform.android": ("Android", "Android"), + "software.platform.unknown": ("Unknown", "Unbekannt") +}) + + +BASE_TRANSLATIONS.update({ + "jobdefs.title": ("Job definitions", "Jobdefinitionen"), + "jobdefs.description": ("Manage reusable script and command definitions.", "Wiederverwendbare Skript- und Befehlsdefinitionen verwalten."), + "jobdefs.new": ("Create job definition", "Jobdefinition anlegen"), + "jobdefs.edit": ("Edit job definition", "Jobdefinition bearbeiten"), + "jobdefs.admin_only": ("Only administrators can view and edit job definitions.", "Nur Administratoren können Jobdefinitionen anzeigen und bearbeiten."), + "jobdefs.platform": ("Platform", "Plattform"), + "jobdefs.platform.windows": ("Windows", "Windows"), + "jobdefs.platform.linux": ("Linux", "Linux"), + "jobdefs.platform.macos": ("macOS", "macOS"), + "jobdefs.platform.all": ("All platforms", "Alle Plattformen"), + "jobdefs.interpreter": ("Interpreter", "Interpreter"), + "jobdefs.interpreter.powershell": ("PowerShell", "PowerShell"), + "jobdefs.interpreter.cmd": ("CMD / Batch", "CMD / Batch"), + "jobdefs.interpreter.bash": ("Bash", "Bash"), + "jobdefs.interpreter.python": ("Python", "Python"), + "jobdefs.source": ("Script source", "Skriptquelle"), + "jobdefs.source.inline": ("Inline script", "Inline-Skript"), + "jobdefs.source.mounted": ("Mounted file", "Gemountete Datei"), + "jobdefs.source.unc": ("UNC path", "UNC-Pfad"), + "jobdefs.path": ("Script path", "Skriptpfad"), + "jobdefs.path_help": ("Use an absolute container path below /scripts or a UNC path beginning with \\", "Einen absoluten Containerpfad unter /scripts oder einen UNC-Pfad beginnend mit \\ verwenden."), + "jobdefs.inline_script": ("Inline script", "Inline-Skript"), + "jobdefs.variables_help": ("Available placeholders are replaced before execution. Use the preview to inspect the generated script.", "Verfügbare Platzhalter werden vor der Ausführung ersetzt. Über die Vorschau kann das erzeugte Skript geprüft werden."), + "jobdefs.arguments": ("Arguments", "Parameter"), + "jobdefs.timeout": ("Timeout in seconds", "Zeitlimit in Sekunden"), + "jobdefs.success_codes": ("Successful exit codes", "Erfolgreiche Rückgabecodes"), + "jobdefs.revision": ("Revision", "Revision"), + "jobdefs.system": ("System", "System"), + "jobdefs.none": ("No job definitions are available.", "Keine Jobdefinitionen vorhanden."), + "jobdefs.none_filtered": ("No job definitions match the current filter.", "Keine Jobdefinitionen entsprechen dem aktuellen Filter."), + "jobdefs.status": ("Status", "Status"), + "jobdefs.status.active": ("Active", "Aktiv"), + "jobdefs.status.inactive": ("Inactive", "Deaktiviert"), + "jobdefs.filter.search": ("Search", "Suche"), + "jobdefs.filter.search_placeholder": ("Name or description", "Name oder Beschreibung"), + "jobdefs.filter.active_only": ("Show active definitions only", "Nur aktive Jobdefinitionen anzeigen"), + "jobdefs.filter.apply": ("Apply filter", "Filter anwenden"), + "jobdefs.filter.reset": ("Reset", "Zurücksetzen"), + "jobdefs.delete.confirm": ("Permanently delete this unused job definition?", "Diese unbenutzte Jobdefinition dauerhaft löschen?"), + "jobdefs.delete.system_forbidden": ("System job definitions cannot be deleted.", "System-Jobdefinitionen können nicht gelöscht werden."), + "jobdefs.delete.referenced": ("This job definition is referenced by existing jobs and cannot be deleted. Deactivate it instead.", "Diese Jobdefinition wird von vorhandenen Jobs referenziert und kann nicht gelöscht werden. Deaktivieren Sie sie stattdessen."), + "jobdefs.deleted": ("Job definition \"{name}\" was deleted.", "Jobdefinition \"{name}\" wurde gelöscht."), + "jobdefs.delete.behavior_title": ("Deleting and deactivating", "Löschen und Deaktivieren"), + "jobdefs.delete.behavior": ("Unused custom job definitions can be deleted. System definitions and definitions already referenced by a job remain protected; deactivate them to hide them from new executions.", "Unbenutzte eigene Jobdefinitionen können gelöscht werden. Systemdefinitionen und bereits von einem Job referenzierte Definitionen bleiben geschützt; deaktivieren Sie diese, damit sie nicht mehr für neue Ausführungen angeboten werden."), + "jobdefs.script_help.title": ("Variables and helper functions", "Variablen und Hilfsfunktionen"), + "jobdefs.script_help.common": ("The runtime wrapper is added automatically. Placeholders are replaced before execution and may be used anywhere in your own code.", "Der Laufzeitrahmen wird automatisch ergänzt. Platzhalter werden vor der Ausführung ersetzt und können überall im eigenen Code verwendet werden."), + "jobdefs.script_help.variables": ("Asset and system variables:", "Asset- und Systemvariablen:"), + "jobdefs.script_help.parameters": ("Job parameters:", "Jobparameter:"), + "jobdefs.script_help.powershell": ("Write-JobLog accepts any string. PowerShell variables work normally; use double quotes or an expression to include their current values. Add callback result fields through the $JobResult dictionary.", "Write-JobLog übernimmt jede Zeichenkette. PowerShell-Variablen funktionieren normal; verwenden Sie doppelte Anführungszeichen oder einen Ausdruck, um aktuelle Werte einzufügen. Zusätzliche Callback-Ergebnisse werden über das Wörterbuch $JobResult ergänzt."), + "jobdefs.script_help.python": ("Use print() for output. Values may be formatted with normal Python expressions. Add callback result fields through the job_result dictionary.", "Verwenden Sie print() für Ausgaben. Werte können mit normalen Python-Ausdrücken formatiert werden. Zusätzliche Callback-Ergebnisse werden über das Wörterbuch job_result ergänzt."), + "jobdefs.script_help.bash": ("Use echo or printf for output and ordinary shell variables. Asset and job parameters are inserted through placeholders before execution.", "Verwenden Sie echo oder printf für Ausgaben und normale Shell-Variablen. Asset- und Jobparameter werden vor der Ausführung über Platzhalter eingesetzt."), + "jobdefs.script_help.cmd": ("Use echo for output and normal CMD variables such as %NAME%. Asset and job parameters are inserted through placeholders before execution.", "Verwenden Sie echo für Ausgaben und normale CMD-Variablen wie %NAME%. Asset- und Jobparameter werden vor der Ausführung über Platzhalter eingesetzt."), + "jobdefs.script_help.wrapper_sources": ("Helper functions and automatic callbacks are available for inline and mounted scripts. UNC scripts are started as external files and therefore do not receive wrapper helper functions.", "Hilfsfunktionen und automatische Callbacks stehen für Inline- und eingebundene Skripte zur Verfügung. UNC-Skripte werden als externe Dateien gestartet und erhalten deshalb keine Hilfsfunktionen des Laufzeitrahmens."), + "jobdefs.saved": ("Job definition saved.", "Jobdefinition wurde gespeichert."), + "jobdefs.test": ("Validate and preview", "Prüfen und Vorschau"), + "jobdefs.test_result": ("Validation result", "Prüfergebnis"), + "jobdefs.preview": ("Generated script preview", "Vorschau des erzeugten Skripts"), + "jobdefs.preview_help": ("The preview contains sample values for all placeholders and is not executed.", "Die Vorschau enthält Beispielwerte für alle Platzhalter und wird nicht ausgeführt."), + "jobdefs.copy_preview": ("Copy preview", "Vorschau kopieren"), + "jobdefs.copied": ("The generated script was copied to the clipboard.", "Das erzeugte Skript wurde in die Zwischenablage kopiert."), + "jobdefs.copy_failed": ("The script could not be copied automatically.", "Das Skript konnte nicht automatisch kopiert werden."), + "jobdefs.generate_wrapper": ("Generate execution framework", "Job-Rahmen erzeugen"), + "jobdefs.generate_wrapper_confirm": ("Replace the current inline script with a generated framework?", "Das aktuelle Inline-Skript durch einen erzeugten Ausführungsrahmen ersetzen?"), + "jobdefs.wrapper_help": ("Creates logging, error handling and callback code for the selected interpreter. Add your own commands in the marked section.", "Erzeugt Protokollierung, Fehlerbehandlung und Callback-Code für den ausgewählten Interpreter. Eigene Befehle werden im markierten Abschnitt ergänzt."), + "jobdefs.validation.ok": ("Validation completed successfully.", "Prüfung erfolgreich abgeschlossen."), + "jobdefs.validation.inline_empty": ("The inline script is empty.", "Das Inline-Skript ist leer."), + "jobdefs.validation.mounted_absolute": ("The mounted path must be absolute.", "Der gemountete Pfad muss absolut sein."), + "jobdefs.validation.file_missing": ("File not found: {path}", "Datei nicht gefunden: {path}"), + "jobdefs.validation.file_readable": ("File readable: {path} ({count} characters)", "Datei lesbar: {path} ({count} Zeichen)"), + "jobdefs.validation.unc_prefix": ("A UNC path must begin with \\", "Ein UNC-Pfad muss mit \\ beginnen."), + "jobdefs.validation.unc_ok": ("UNC syntax is valid. Reachability is checked on the target device.", "Die UNC-Syntax ist gültig. Die Erreichbarkeit wird auf dem Zielgerät geprüft."), + "jobdefs.validation.python_ok": ("Python syntax is valid.", "Die Python-Syntax ist gültig."), + "jobdefs.validation.python_error": ("Python syntax error on line {line}: {message}", "Python-Syntaxfehler in Zeile {line}: {message}"), + "jobdefs.validation.bash_ok": ("Bash syntax is valid.", "Die Bash-Syntax ist gültig."), + "jobdefs.validation.windows_later": ("Content is present. Full syntax validation is performed later on a Windows test device.", "Inhalt vorhanden. Eine vollständige Syntaxprüfung erfolgt später auf einem Windows-Testgerät."), + "jobdefs.preview.sample_notice": ("Sample values used for preview", "Für die Vorschau verwendete Beispielwerte"), + "jobdefs.insert_test_script": ("Insert test script", "Testskript einsetzen"), + "jobdefs.insert_test_script_confirm": ("Replace the current inline script with a safe test script?", "Das aktuelle Inline-Skript durch ein ungefährliches Testskript ersetzen?"), + "jobdefs.test_script_help": ("The test script only outputs resolved variables and uses the generated callback framework. It does not change the target device.", "Das Testskript gibt nur ersetzte Variablen aus und verwendet den erzeugten Callback-Rahmen. Es verändert das Zielgerät nicht."), + "jobdefs.wrapper_generated": ("The job framework was inserted.", "Der Job-Rahmen wurde eingesetzt."), + "jobdefs.test_script_inserted": ("The safe test script was inserted.", "Das ungefährliche Testskript wurde eingesetzt."), + "jobdefs.generator_generating": ("Generating script...", "Skript wird erzeugt..."), + "jobdefs.generator_failed": ("The script could not be generated.", "Das Skript konnte nicht erzeugt werden."), + "jobdefs.own_script": ("Your script commands", "Eigene Skriptbefehle"), + "jobdefs.own_script_help": ("Enter only your commands. Logging, error handling and callback are generated automatically at runtime. Parameters can be used as {{Param.key}}.", "Nur die eigenen Befehle eingeben. Protokollierung, Fehlerbehandlung und Callback werden zur Laufzeit automatisch erzeugt. Parameter können als {{Param.schluessel}} verwendet werden."), + "jobdefs.insert_test_commands": ("Insert test commands", "Testbefehle einsetzen"), + "jobdefs.test_commands_content": ("Write-Output \"AssetManager test\"\nWrite-Output \"Asset={{AssetName}}\"", "Write-Output \"AssetManager-Test\"\nWrite-Output \"Asset={{AssetName}}\""), + "jobparams.title": ("Parameters", "Parameter"), + "jobparams.help": ("Define optional parameters and defaults. Asset-specific values can override these defaults.", "Optionale Parameter und Standardwerte definieren. Asset-spezifische Werte können diese Standards überschreiben."), + "jobparams.add": ("Add parameter", "Parameter hinzufügen"), + "jobparams.key": ("Technical key", "Technischer Schlüssel"), + "jobparams.label": ("Label", "Bezeichnung"), + "jobparams.type": ("Type", "Typ"), + "jobparams.default": ("Default value", "Standardwert"), + "jobparams.required": ("Required", "Erforderlich"), + "jobparams.asset_title": ("Job parameters", "Jobparameter"), + "jobparams.asset_help": ("Optional asset-specific values override the current defaults of the job definition.", "Optionale Assetwerte überschreiben die aktuellen Standardwerte der Jobdefinition."), + "jobparams.override": ("Use asset-specific value", "Asset-spezifischen Wert verwenden"), + "jobparams.default_value": ("Default", "Standard"), + "jobparams.source": ("Source", "Herkunft"), + "jobparams.source.asset": ("Asset", "Asset"), + "jobparams.source.default": ("Default", "Standard"), + "jobparams.saved": ("Asset-specific job parameters were saved.", "Asset-spezifische Jobparameter wurden gespeichert."), + "jobparams.none_defined": ("This job definition has no configurable parameters.", "Diese Jobdefinition besitzt keine konfigurierbaren Parameter."), + "jobparams.parameter_count": ("{count} parameter(s)", "{count} Parameter"), + "jobs.run_definition": ("Run job", "Job ausführen"), + "jobs.run_definition_help": ("Only active job definitions matching the detected platform of this asset are shown.", "Es werden nur aktive Jobdefinitionen angezeigt, die zur erkannten Plattform dieses Assets passen."), + "jobs.select_definition": ("Select job definition", "Jobdefinition auswählen"), + "jobs.no_compatible_definitions": ("No active job definition is available for this platform.", "Für diese Plattform ist keine aktive Jobdefinition verfügbar."), + "jobs.single.confirm": ("Start the selected job on this asset now?", "Den ausgewählten Job jetzt auf diesem Asset starten?"), + "jobs.started": ("Job started.", "Job wurde gestartet."), + "jobs.event.created_named": ('Job "{name}" created', 'Job „{name}“ wurde erstellt'), + "jobs.definition_unavailable": ("The selected job definition is not available.", "Die ausgewählte Jobdefinition ist nicht verfügbar."), + "jobs.asset_missing_node": ("The asset has no MeshCentral node ID.", "Das Asset besitzt keine MeshCentral Node-ID."), + "jobs.asset_unknown_platform": ("The operating system platform could not be detected.", "Die Betriebssystemplattform konnte nicht erkannt werden."), + "jobs.platform_mismatch": ("The job definition does not match the asset platform.", "Die Jobdefinition passt nicht zur Plattform des Assets."), + "jobdefs.kind": ("Job type", "Jobart"), + "jobdefs.kind.help": ("Script jobs contain your own code. Registry jobs are configured declaratively and generate PowerShell at runtime.", "Skriptjobs enthalten eigenen Code. Registry-Jobs werden deklarativ konfiguriert und erzeugen PowerShell erst zur Laufzeit."), + "jobdefs.kind.script": ("Script", "Skript"), + "jobdefs.kind.registry": ("Windows Registry", "Windows-Registry"), + "jobdefs.validation.registry_empty": ("At least one registry entry is required.", "Mindestens ein Registry-Eintrag ist erforderlich."), + "registry.title": ("Registry entries", "Registry-Einträge"), + "registry.help": ("Set or remove multiple Windows Registry values and keys. Missing keys are created automatically when values are set.", "Mehrere Windows-Registry-Werte und Schlüssel setzen oder entfernen. Fehlende Schlüssel werden beim Setzen automatisch angelegt."), + "registry.add": ("Add registry entry", "Registry-Eintrag hinzufügen"), + "registry.backup": ("Export affected keys locally before changes", "Betroffene Schlüssel vor Änderungen lokal exportieren"), + "registry.action": ("Action", "Aktion"), + "registry.action.set": ("Set value", "Wert setzen"), + "registry.action.delete_value": ("Delete value", "Wert löschen"), + "registry.action.delete_key": ("Delete key recursively", "Schlüssel rekursiv löschen"), + "registry.action.set_permissions": ("Set key permissions", "Schlüsselberechtigungen setzen"), + "registry.operation": ("What should this entry do?", "Was soll dieser Eintrag tun?"), + "registry.action.set.help": ("Create or update one registry value.", "Einen Registry-Wert anlegen oder aktualisieren."), + "registry.action.set_permissions.help": ("Set permissions on the selected registry key.", "Berechtigungen für den ausgewählten Registry-Schlüssel setzen."), + "registry.action.delete_value.help": ("Remove one value while keeping the key.", "Einen Wert entfernen, den Schlüssel aber behalten."), + "registry.action.delete_key.help": ("Remove the key including all subkeys.", "Den Schlüssel einschließlich aller Unterschlüssel entfernen."), + "registry.principal": ("User, group or SID", "Benutzer, Gruppe oder SID"), + "registry.principal_help": ("Use a SID for language-independent assignments. Built-in Users: S-1-5-32-545.", "Für sprachunabhängige Zuweisungen eine SID verwenden. Integrierte Benutzer: S-1-5-32-545."), + "registry.principal.users": ("Users (built-in)", "Benutzer (integriert)"), + "registry.principal.admins": ("Administrators (built-in)", "Administratoren (integriert)"), + "registry.principal.authenticated": ("Authenticated users", "Authentifizierte Benutzer"), + "registry.principal.everyone": ("Everyone", "Jeder"), + "registry.principal_custom": ("SID, user or group", "SID, Benutzer oder Gruppe"), + "registry.custom": ("Custom...", "Benutzerdefiniert …"), + "registry.rights": ("Registry rights", "Registry-Berechtigung"), + "registry.rights.read": ("Read", "Lesen"), + "registry.rights.change": ("Change", "Ändern"), + "registry.rights.full": ("Full control", "Vollzugriff"), + "registry.rights_custom": ("Custom .NET RegistryRights", "Benutzerdefinierte .NET-RegistryRights"), + "registry.access_type": ("Access type", "Zugriffstyp"), + "registry.inherit_subkeys": ("Apply to subkeys", "Auf Unterschlüssel vererben"), + "registry.replace_existing": ("Replace matching explicit rule", "Passende explizite Regel ersetzen"), + "registry.hive": ("Hive", "Hive"), + "registry.path": ("Registry path", "Registry-Pfad"), + "registry.path.help": ("Enter the path below the selected hive, for example SOFTWARE\\Vendor\\Product.", "Pfad unterhalb des ausgewählten Hives eingeben, zum Beispiel SOFTWARE\\Hersteller\\Produkt."), + "registry.name": ("Value name", "Wertname"), + "registry.type": ("Value type", "Werttyp"), + "registry.value": ("Value or parameter", "Wert oder Parameter"), + "registry.only_if_missing": ("Only set when the value is missing", "Nur setzen, wenn der Wert fehlt"), + "registry.set_value_enabled": ("Set registry value", "Registry-Wert setzen"), + "registry.permissions_enabled": ("Change permissions for this key", "Berechtigungen für diesen Schlüssel ändern"), + "registry.parameters.title": ("Parameters in Registry entries", "Parameter in Registry-Einträgen"), + "registry.parameters.body": ("Define technical parameter keys in the Parameter section below. They apply to the complete job definition and can be reused in any Registry path, value, custom identity or custom rights field. Separate per-entry parameter definitions are not required.", "Technische Parameterschlüssel werden unten im Bereich Parameter definiert. Sie gelten für die gesamte Jobdefinition und können in jedem Registry-Pfad, Wert sowie in benutzerdefinierten Identitäts- und Berechtigungsfeldern wiederverwendet werden. Eigene Parameterdefinitionen pro Registry-Eintrag sind nicht nötig."), + "registry.parameters.available": ("Available keys:", "Verfügbare Schlüssel:"), + "registry.parameters.none": ("No parameters defined yet.", "Noch keine Parameter definiert."), + "registry.notes.title": ("Variables and behavior", "Variablen und Verhalten"), + "registry.notes.body": ("Paths, values, identities and rights can contain job parameters such as {{Param.RegistryPath}} or {{Param.RegistryRights}}. Setting values and changing permissions create missing keys. Use SIDs for language-independent identities. Registry jobs run in the MeshCentral agent context; HKCU therefore refers to that execution account.", "Pfade, Werte, Identitäten und Berechtigungen können Jobparameter wie {{Param.RegistryPath}} oder {{Param.RegistryRights}} enthalten. Beim Setzen von Werten und Ändern von Berechtigungen werden fehlende Schlüssel angelegt. Für sprachunabhängige Identitäten sollten SIDs verwendet werden. Registry-Jobs laufen im Kontext des MeshCentral-Agenten; HKCU bezieht sich daher auf dieses Ausführungskonto."), + "common.disabled": ("disabled", "deaktiviert"), + "common.days": ("days", "Tage"), + "assets.job_filter.button": ("Filter by job status", "Jobstatus filtern"), + "assets.job_filter.title": ("Filter assets by job status", "Assets nach Jobstatus filtern"), + "assets.job_filter.help": ("Job information is loaded only while this filter is active. The normal asset list remains unchanged and fast.", "Die Jobinformationen werden nur bei aktivem Filter geladen. Die normale Assetliste bleibt unverändert schnell."), + "assets.job_filter.job": ("Job", "Job"), + "assets.job_filter.jobs": ("Jobs", "Jobs"), + "assets.job_filter.jobs_help": ("Multiple jobs are treated as alternative executions. The most recently executed selected job determines the displayed status.", "Mehrere Jobs werden als alternative Ausführungen zusammengefasst. Maßgeblich ist der zuletzt ausgeführte ausgewählte Job."), + "assets.job_filter.select_at_least_one": ("Select at least one job.", "Bitte mindestens einen Job auswählen."), + "assets.job_filter.start_job": ("Job to run", "Auszuführender Job"), + "assets.job_filter.start_invalid": ("Select one of the filtered jobs to run.", "Wähle einen der gefilterten Jobs zur Ausführung aus."), + "assets.job_filter.condition": ("Condition", "Bedingung"), + "assets.job_filter.condition.successful": ("Successfully completed at least once", "Mindestens einmal erfolgreich durchgeführt"), + "assets.job_filter.condition.not_successful": ("Not yet completed successfully", "Noch nicht erfolgreich durchgeführt"), + "assets.job_filter.condition.never_run": ("Never run", "Noch nie ausgeführt"), + "assets.job_filter.condition.last_failed": ("Last status: failed", "Letzter Status: fehlgeschlagen"), + "assets.job_filter.condition.last_partial": ("Last status: partially successful", "Letzter Status: teilweise erfolgreich"), + "assets.job_filter.condition.timeout": ("Last status: timed out", "Letzter Status: Zeitüberschreitung"), + "assets.job_filter.condition.callback_pending": ("Callback pending", "Callback ausstehend"), + "assets.job_filter.condition.active": ("Currently active or queued", "Aktuell aktiv oder eingereiht"), + "assets.job_filter.condition.success_older": ("Last success older than", "Letzter Erfolg älter als"), + "assets.job_filter.days": ("Success older than days", "Erfolg älter als Tage"), + "assets.job_filter.current_revision": ("current revision only", "nur aktuelle Revision"), + "assets.job_filter.apply": ("Apply filter", "Filter anwenden"), + "assets.job_filter.clear": ("Remove job filter", "Jobfilter entfernen"), + "assets.job_filter.active": ("Active job filter", "Aktiver Jobfilter"), + "assets.job_filter.status": ("Job status", "Jobstatus"), + "assets.job_filter.last_execution": ("Last execution", "Letzte Ausführung"), + "assets.job_filter.last_success": ("Last success", "Letzter Erfolg"), + "assets.job_filter.revision": ("Revision", "Revision"), + "assets.job_filter.never_run": ("Never run", "Noch nie ausgeführt"), + "software.status.never_run": ("Never run", "Noch nie ausgeführt"), + "assets.job_filter.start": ("Run job for filtered assets", "Job für gefilterte Assets starten"), + "assets.job_filter.start_confirm": ("Create and start the selected job for all {count} filtered assets?", "Den ausgewählten Job für alle {count} gefilterten Assets erstellen und starten?"), + "assets.job_filter.invalid": ("The job filter is invalid.", "Der Jobfilter ist ungültig."), + "assets.job_filter.no_assets": ("No assets match this job filter.", "Für diesen Jobfilter wurden keine Assets gefunden."), + "assets.job_filter.started": ("Created {created} jobs for the filtered assets; skipped {skipped} assets.", "Für die gefilterten Assets wurden {created} Jobs erstellt; {skipped} Assets wurden übersprungen."), + "assets.job_filter.excluded_statuses": ("Excluded statuses", "Auszuschließende Status"), + "assets.job_filter.excluded_help": ("Checked statuses are excluded from the asset list. With no selection, all assets are shown.", "Markierte Status werden nicht in der Assetliste angezeigt. Ohne Markierung werden alle Assets angezeigt."), + "assets.job_filter.excluded_summary": ("Excluded", "Ausgeschlossen"), + "assets.job_filter.excluded_none": ("no statuses", "keine Status"), + "assets.job_filter.exclude_all": ("Select all", "Alle markieren"), + "assets.job_filter.exclude_none": ("Clear selection", "Auswahl aufheben"), + "job_states.open": ("Job status by asset", "Jobstatus je Asset"), + "job_states.title": ("Job status by asset", "Jobstatus je Asset"), + "job_states.description": ("Current compact job status per asset and job. The complete history remains available in the individual jobs.", "Aktueller kompakter Jobstatus pro Asset und Job. Die vollständige Historie bleibt in den einzelnen Jobs erhalten."), + "job_states.back": ("Back to job overview", "Zur Jobübersicht"), + "job_states.job": ("Job", "Job"), + "job_states.revision": ("Latest revision", "Letzte Revision"), + "job_states.success_revision": ("Successful revision", "Erfolgsrevision"), + "job_states.execution_count": ("Executions", "Ausführungen"), + "job_states.last_job": ("Latest job", "Letzter Job"), + "job_states.updated_at": ("Updated", "Aktualisiert"), + "job_states.empty": ("No job status data is available yet.", "Noch keine Jobstatusdaten vorhanden."), +}) + +BASE_TRANSLATIONS.update({ + "mesh.link.section_title": ("MeshCentral link", "MeshCentral-Verknüpfung"), + "mesh.link.section_help": ("The node ID can be changed manually without deleting the asset. Other asset fields are updated only by the next MeshCentral synchronization.", "Die Node-ID kann manuell geändert werden, ohne das Asset zu löschen. Andere Assetfelder werden erst bei der nächsten MeshCentral-Synchronisierung aktualisiert."), + "mesh.link.title": ("Change MeshCentral link", "MeshCentral-Verknüpfung ändern"), + "mesh.link.page_help": ("Select an unassigned MeshCentral device. The node ID is changed immediately; other asset fields are updated only during the next synchronization.", "Wähle ein noch nicht zugeordnetes MeshCentral-Gerät aus. Die Node-ID wird sofort geändert; andere Assetfelder werden erst bei der nächsten Synchronisierung aktualisiert."), + "mesh.link.change": ("Change link", "Verknüpfung ändern"), + "mesh.link.initialize": ("Initialize link", "Verknüpfung initialisieren"), + "mesh.link.unlink": ("Remove link", "Verknüpfung lösen"), + "mesh.link.select_existing": ("Select MeshCentral device", "MeshCentral-Gerät auswählen"), + "mesh.link.clear_field": ("Clear node ID", "Node-ID leeren"), + "mesh.link.edit_help": ("The node ID can be edited directly. An empty field removes the link when saved. Other asset data is updated only during the next MeshCentral synchronization.", "Die Node-ID kann direkt bearbeitet werden. Ein leeres Feld löst die Verknüpfung beim Speichern. Andere Assetdaten werden erst beim nächsten MeshCentral-Sync aktualisiert."), + "mesh.link.unlink_confirm": ("Remove the MeshCentral link from this asset? The asset itself and its stored data remain unchanged.", "Die MeshCentral-Verknüpfung dieses Assets wirklich lösen? Das Asset selbst und seine gespeicherten Daten bleiben erhalten."), + "mesh.link.asset_comparison": ("Asset and current link", "Asset und aktuelle Verknüpfung"), + "mesh.link.current_device": ("Current MeshCentral device", "Aktuelles MeshCentral-Gerät"), + "mesh.link.current_missing": ("The currently stored node ID was not found in MeshCentral. You can replace it with one of the available devices below.", "Die aktuell gespeicherte Node-ID wurde in MeshCentral nicht gefunden. Du kannst sie durch eines der unten verfügbaren Geräte ersetzen."), + "mesh.link.available_devices": ("Available MeshCentral devices", "Verfügbare MeshCentral-Geräte"), + "mesh.link.available_help": ("Only node IDs that are not assigned to another asset are shown.", "Es werden nur Node-IDs angezeigt, die keinem anderen Asset zugeordnet sind."), + "mesh.link.select_device": ("Select device", "Gerät auswählen"), + "mesh.link.select_required": ("Select a MeshCentral device.", "Bitte ein MeshCentral-Gerät auswählen."), + "mesh.link.change_confirm": ("Assign the selected MeshCentral device to this asset?", "Das ausgewählte MeshCentral-Gerät diesem Asset zuordnen?"), + "mesh.link.save": ("Save link", "Verknüpfung speichern"), + "mesh.link.no_available_devices": ("No unassigned MeshCentral devices are currently available.", "Aktuell sind keine noch nicht zugeordneten MeshCentral-Geräte verfügbar."), + "mesh.link.fetch_failed": ("The MeshCentral device list could not be loaded: {error}", "Die MeshCentral-Geräteliste konnte nicht geladen werden: {error}"), + "mesh.link.not_found": ("The selected MeshCentral device no longer exists.", "Das ausgewählte MeshCentral-Gerät existiert nicht mehr."), + "mesh.link.already_assigned": ("This MeshCentral device is already linked to asset {asset}.", "Dieses MeshCentral-Gerät ist bereits dem Asset {asset} zugeordnet."), + "mesh.link.unchanged": ("The MeshCentral link is already set to this device.", "Die MeshCentral-Verknüpfung ist bereits auf dieses Gerät gesetzt."), + "mesh.link.pending_sync": ("MeshCentral link changed manually; synchronization is pending.", "MeshCentral-Verknüpfung manuell geändert; Synchronisierung ausstehend."), + "mesh.link.conflict": ("The MeshCentral node ID was assigned by another operation.", "Die MeshCentral Node-ID wurde zwischenzeitlich durch einen anderen Vorgang zugeordnet."), + "mesh.link.changed": ("MeshCentral link changed.", "MeshCentral-Verknüpfung geändert."), + "mesh.link.already_unlinked": ("This asset is not linked to MeshCentral.", "Dieses Asset ist nicht mit MeshCentral verknüpft."), + "mesh.link.unlinked_message": ("MeshCentral link removed manually.", "MeshCentral-Verknüpfung manuell gelöst."), + "mesh.link.unlinked": ("MeshCentral link removed.", "MeshCentral-Verknüpfung gelöst."), +}) + +BASE_TRANSLATIONS.update({ + "mesh.sync_status.pending": ("Synchronization pending", "Synchronisierung ausstehend"), + "mesh.sync_status.unlinked": ("Not linked", "Nicht verknüpft"), + "mesh.sync_status.synced": ("Synchronized", "Synchronisiert"), + "mesh.sync_status.updated": ("Updated", "Aktualisiert"), + "mesh.sync_status.created": ("Created", "Neu angelegt"), + "mesh.sync_status.conflict": ("Conflict", "Konflikt"), + "mesh.sync_status.missing": ("Missing in MeshCentral", "In MeshCentral nicht gefunden"), +}) diff --git a/app/job_state.py b/app/job_state.py new file mode 100644 index 0000000..b837ab8 --- /dev/null +++ b/app/job_state.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy.orm import Session + +from .models import AssetJobState, SoftwareJob + + +TERMINAL_JOB_STATES = {"success", "failed", "partial", "timeout", "cancelled"} + + +def _int_or_none(value: Any) -> int | None: + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def job_definition_id(job: SoftwareJob) -> int | None: + if str(job.job_type or "") != "job_definition": + return None + parameters = job.parameters if isinstance(job.parameters, dict) else {} + return _int_or_none(parameters.get("definition_id")) + + +def job_definition_revision(job: SoftwareJob) -> int | None: + if str(job.job_type or "") != "job_definition": + return None + parameters = job.parameters if isinstance(job.parameters, dict) else {} + return _int_or_none(parameters.get("definition_revision")) + + +def job_state_key(job: SoftwareJob) -> str: + job_type = str(job.job_type or "software_inventory").strip() or "software_inventory" + if job_type == "job_definition": + definition_id = job_definition_id(job) + return f"job_definition:{definition_id}" if definition_id else f"job_definition:job:{job.id}" + return job_type[:120] + + +def filter_state_key(job_type: str, definition_id: int | None = None) -> str: + normalized = str(job_type or "").strip() + if normalized == "job_definition" and definition_id: + return f"job_definition:{int(definition_id)}" + return normalized[:120] + + +def sync_asset_job_state( + db: Session, + job: SoftwareJob, + *, + increment_execution: bool = False, + status_at: datetime | None = None, +) -> AssetJobState: + """Update the compact per-asset state for one job. + + This table is an intentionally small projection of ``software_jobs``. The + full job history remains authoritative; this projection exists only for + fast filters, dashboards and the later software-package desired-state + module. + """ + state_key = job_state_key(job) + state = ( + db.query(AssetJobState) + .filter(AssetJobState.asset_id == job.asset_id, AssetJobState.state_key == state_key) + .with_for_update() + .first() + ) + now = status_at or datetime.utcnow() + if state is None: + state = AssetJobState( + asset_id=job.asset_id, + state_key=state_key, + job_type=str(job.job_type or "software_inventory")[:50], + job_definition_id=job_definition_id(job), + execution_count=0, + ) + db.add(state) + db.flush() + + revision = job_definition_revision(job) + state.job_type = str(job.job_type or "software_inventory")[:50] + state.job_definition_id = job_definition_id(job) + state.definition_revision = revision + state.last_job_id = job.id + state.last_status = str(job.status or "created")[:30] + state.last_status_at = now + state.last_created_at = job.created_at + state.last_sent_at = job.sent_at + state.last_started_at = job.started_at + state.last_completed_at = job.finished_at or job.callback_completed_at + state.updated_at = now + if increment_execution: + state.execution_count = int(state.execution_count or 0) + 1 + elif int(state.execution_count or 0) <= 0: + state.execution_count = max(1, int(job.attempt_count or 0)) + + if state.last_status == "success": + state.last_success_at = job.finished_at or job.callback_completed_at or now + state.last_success_revision = revision + return state + + +def backfill_asset_job_states(db: Session) -> int: + """Create compact states from historic jobs once after the table is added.""" + if db.query(AssetJobState.id).limit(1).first() is not None: + return 0 + + rows = ( + db.query( + SoftwareJob.id, + SoftwareJob.asset_id, + SoftwareJob.job_type, + SoftwareJob.parameters, + SoftwareJob.status, + SoftwareJob.attempt_count, + SoftwareJob.created_at, + SoftwareJob.sent_at, + SoftwareJob.started_at, + SoftwareJob.finished_at, + SoftwareJob.callback_completed_at, + ) + .order_by(SoftwareJob.created_at.asc(), SoftwareJob.id.asc()) + .all() + ) + if not rows: + return 0 + + states: dict[tuple[int, str], dict[str, Any]] = {} + for row in rows: + parameters = row.parameters if isinstance(row.parameters, dict) else {} + definition_id = _int_or_none(parameters.get("definition_id")) if row.job_type == "job_definition" else None + revision = _int_or_none(parameters.get("definition_revision")) if row.job_type == "job_definition" else None + state_key = ( + f"job_definition:{definition_id}" + if row.job_type == "job_definition" and definition_id + else (str(row.job_type or "software_inventory")[:120]) + ) + key = (int(row.asset_id), state_key) + current = states.get(key) + if current is None: + current = { + "asset_id": int(row.asset_id), + "state_key": state_key, + "job_type": str(row.job_type or "software_inventory")[:50], + "job_definition_id": definition_id, + "definition_revision": revision, + "last_success_revision": None, + "last_job_id": int(row.id), + "last_status": str(row.status or "created")[:30], + "last_status_at": row.finished_at or row.callback_completed_at or row.started_at or row.sent_at or row.created_at or datetime.utcnow(), + "last_created_at": row.created_at, + "last_sent_at": row.sent_at, + "last_started_at": row.started_at, + "last_completed_at": row.finished_at or row.callback_completed_at, + "last_success_at": None, + "execution_count": 0, + "updated_at": datetime.utcnow(), + } + states[key] = current + + current.update({ + "job_type": str(row.job_type or "software_inventory")[:50], + "job_definition_id": definition_id, + "definition_revision": revision, + "last_job_id": int(row.id), + "last_status": str(row.status or "created")[:30], + "last_status_at": row.finished_at or row.callback_completed_at or row.started_at or row.sent_at or row.created_at or datetime.utcnow(), + "last_created_at": row.created_at, + "last_sent_at": row.sent_at, + "last_started_at": row.started_at, + "last_completed_at": row.finished_at or row.callback_completed_at, + "updated_at": datetime.utcnow(), + }) + current["execution_count"] = int(current.get("execution_count") or 0) + max(1, int(row.attempt_count or 0)) + if str(row.status or "") == "success": + success_at = row.finished_at or row.callback_completed_at or row.started_at or row.sent_at or row.created_at + if success_at and (current.get("last_success_at") is None or success_at >= current["last_success_at"]): + current["last_success_at"] = success_at + current["last_success_revision"] = revision + + db.bulk_insert_mappings(AssetJobState, list(states.values())) + db.commit() + return len(states) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..f9b8ecf --- /dev/null +++ b/app/main.py @@ -0,0 +1,7581 @@ +import os +import asyncio +import shutil +import uuid +import threading +import tempfile +import subprocess +import shutil +import io +import json +import logging +import traceback +import hashlib +import hmac +import secrets +import subprocess +import base64 +import re +import unicodedata +import socket +import ssl +import time +import urllib.error +import urllib.request +from typing import Any +from concurrent.futures import ThreadPoolExecutor +from urllib.parse import quote +import urllib.parse +from datetime import datetime, date, timedelta +from pathlib import Path +from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse, Response, FileResponse, HTMLResponse +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.sessions import SessionMiddleware +from starlette.requests import ClientDisconnect +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy import and_, case, func, or_, false, distinct, cast, String +from sqlalchemy.orm import Session, aliased, joinedload, selectinload, load_only, contains_eager +from sqlalchemy.exc import IntegrityError +from jinja2 import pass_context + +from .database import Base, engine, get_db, SessionLocal +from .fields import ASSET_FIELDS, COMPUTER_FIELDS, DEFAULT_FIELDS, FIELD_DATA_TYPES, SYSTEM_READONLY_FIELDS +from .models import Asset, AssetHistory, Category, CategoryField, StatusOption, SyncRun, User, ChartDefinition, Language, Translation, FieldDefinition, AssetFieldValue, MeshFieldMapping, SoftwarePackage, SoftwarePackageParameter, SoftwareJob, InstalledSoftware, JobEvent, JobDefinition, JobDefinitionRevision, JobDefinitionParameter, JobDefinitionAssetParameter, AssetJobState +from .config import load_config, public_config, save_config +from .meshcentral import synchronize, fetch_device_summaries_for_linking +from .migrations import apply_lightweight_migrations +from .i18n import seed_i18n, translate, dictionary as translation_dictionary, languages as i18n_languages, clear_translation_cache +from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script +from .job_state import backfill_asset_job_states, filter_state_key, sync_asset_job_state +from .presence import mesh_presence_loop +from .version import APP_VERSION +from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information) +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font, PatternFill, Alignment + +BASE_DIR = Path(__file__).resolve().parent +UPLOAD_DIR = BASE_DIR / "static" / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync")) +LOG_DIR.mkdir(parents=True, exist_ok=True) +APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) +APP_LOG_DIR.mkdir(parents=True, exist_ok=True) +SOFTWARE_CALLBACK_DEBUG_LOG = APP_LOG_DIR / "software-callback-debug.log" +_SOFTWARE_LOG_LOCK = threading.Lock() + + +def _optional_form_int(value: Any, *, field_name: str, minimum: int | None = None, maximum: int | None = None) -> int | None: + """Convert an optional HTML form value to int. + + Browsers submit an empty numeric input as an empty string. FastAPI/Pydantic + cannot coerce that value directly to ``int | None``, so optional numeric + form fields are normalized here before validation and persistence. + """ + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + parsed = int(text) + except (TypeError, ValueError) as exc: + raise HTTPException(400, f"Invalid integer value for {field_name}.") from exc + if minimum is not None: + parsed = max(minimum, parsed) + if maximum is not None: + parsed = min(maximum, parsed) + return parsed + + +def _record_job_event(db: Session, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None, created_by: str | None = None) -> JobEvent: + event = JobEvent( + job_id=job.id, + attempt=int(job.attempt_count or 0), + event_type=event_type, + status=status if status is not None else job.status, + message=(message or "")[:4000] or None, + created_by=created_by, + ) + db.add(event) + return event + + +_SOFTWARE_EXCLUSION_PLATFORMS = {"all", "windows", "linux", "macos", "unknown"} + + +def _normalize_software_exclusion_rules(value: Any) -> list[dict[str, str]]: + """Return safe, deduplicated substring rules from configuration or forms.""" + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + seen: set[tuple[str, str]] = set() + for raw_rule in value[:500]: + if not isinstance(raw_rule, dict): + continue + platform = str(raw_rule.get("platform") or "all").strip().lower() + if platform not in _SOFTWARE_EXCLUSION_PLATFORMS: + platform = "all" + name_contains = re.sub( + r"\s+", + " ", + str(raw_rule.get("name_contains") or raw_rule.get("name") or "").strip(), + )[:300] + if not name_contains: + continue + identity = (platform, name_contains.casefold()) + if identity in seen: + continue + seen.add(identity) + normalized.append({"platform": platform, "name_contains": name_contains}) + return normalized + + +def _software_name_is_excluded( + name: str, + platform: str, + rules: list[dict[str, str]], +) -> bool: + """Match a software name against case-insensitive platform substring rules.""" + name_key = str(name or "").casefold() + platform_key = str(platform or "unknown").strip().lower() or "unknown" + for rule in rules: + rule_platform = rule.get("platform", "all") + if rule_platform not in {"all", platform_key}: + continue + needle = str(rule.get("name_contains") or "").casefold() + if needle and needle in name_key: + return True + return False + + +def _software_settings() -> dict[str, Any]: + config = load_config() + software = dict(config.get("software", {})) + # Compatibility with the callback field that existed in the general section. + if not software.get("callback_base_url"): + software["callback_base_url"] = str(config.get("general", {}).get("callback_base_url", "") or "") + software["inventory_exclusion_rules"] = _normalize_software_exclusion_rules( + software.get("inventory_exclusion_rules", []) + ) + return software + + +def _software_debug_log(message: str, *, force: bool = False) -> None: + settings = _software_settings() + if not force and not bool(settings.get("debug_logging", True)): + return + line = f"{datetime.utcnow().isoformat(timespec='milliseconds')}Z | {message.rstrip()}\n" + try: + with _SOFTWARE_LOG_LOCK: + SOFTWARE_CALLBACK_DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True) + with SOFTWARE_CALLBACK_DEBUG_LOG.open("a", encoding="utf-8") as handle: + handle.write(line) + max_lines = max(100, min(int(settings.get("debug_log_max_lines", 1000) or 1000), 10000)) + lines = SOFTWARE_CALLBACK_DEBUG_LOG.read_text(encoding="utf-8", errors="replace").splitlines() + if len(lines) > max_lines: + SOFTWARE_CALLBACK_DEBUG_LOG.write_text("\n".join(lines[-max_lines:]) + "\n", encoding="utf-8") + except Exception: + logger.exception("Software callback debug log could not be written") + + +def _software_debug_tail(max_lines: int = 400) -> str: + try: + lines = SOFTWARE_CALLBACK_DEBUG_LOG.read_text(encoding="utf-8", errors="replace").splitlines() + return "\n".join(lines[-max(20, min(max_lines, 2000)):]) + except OSError: + return "" + +logger = logging.getLogger("assetmanager") +logger.setLevel(logging.INFO) +if not logger.handlers: + file_handler = logging.FileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8") + file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) + logger.addHandler(file_handler) + +ldap_logger = logging.getLogger("assetmanager.ldap") +ldap_logger.setLevel(logging.DEBUG) +ldap_logger.propagate = False +if not ldap_logger.handlers: + ldap_file_handler = logging.FileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8") + ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) + ldap_logger.addHandler(ldap_file_handler) + ldap_console_handler = logging.StreamHandler() + ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s")) + ldap_logger.addHandler(ldap_console_handler) + +app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager")) +_session_cfg = load_config().get("authentication", {}) +_session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET") +_session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret" +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") +templates = Jinja2Templates(directory=BASE_DIR / "templates") +templates.env.globals["application_config"] = load_config + +def application_version() -> str: + """Read VERSION when available and otherwise use the embedded build version.""" + candidates = [ + BASE_DIR.parent / "VERSION", + BASE_DIR / "VERSION", + ] + for candidate in candidates: + try: + value = candidate.read_text(encoding="utf-8").strip() + if value: + return value + except OSError: + continue + return APP_VERSION + + +templates.env.globals["application_version"] = application_version + +def application_info() -> dict[str, str]: + """Read persistent application metadata. + + Primary file: /app/config/APPINFO.json (or APPINFO_PATH). + A legacy APPINFO.json is migrated once when possible. + """ + defaults = { + "name": "AssetManager", "author": "", "organization": "", + "contact": "", "website": "", "repository": "", + "license": "", "copyright": "", "description": "", + } + persistent = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json")) + legacy_candidates = [ + Path("/app/data/config/APPINFO.json"), # path used by v0.3.14.2 + BASE_DIR.parent / "APPINFO.json", # /app/APPINFO.json + BASE_DIR / "APPINFO.json", + Path("/APPINFO.json"), # legacy file in container root + ] + + if not persistent.exists(): + for legacy in legacy_candidates: + try: + if legacy.exists(): + persistent.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(legacy, persistent) + break + except OSError: + continue + + candidates = [persistent, *legacy_candidates] + for candidate in candidates: + try: + payload = json.loads(candidate.read_text(encoding="utf-8")) + if isinstance(payload, dict): + return {**defaults, **{str(k): str(v or "") for k, v in payload.items()}} + except (OSError, ValueError, TypeError): + continue + return defaults + + +templates.env.globals["application_info"] = application_info + + +def manufacturer_logo_path() -> str: + """Return the first fixed manufacturer logo found in the static image folder. + + The file is intentionally not configurable through the GUI. Administrators + can replace it only in the application files using the fixed base name + ``manufacturer-logo`` and one of the supported image extensions. + """ + image_dir = BASE_DIR / "static" / "img" + for extension in (".svg", ".png", ".jpg", ".jpeg", ".webp"): + candidate = image_dir / f"manufacturer-logo{extension}" + try: + if candidate.is_file(): + return f"/static/img/{candidate.name}" + except OSError: + continue + return "" + + +templates.env.globals["manufacturer_logo_path"] = manufacturer_logo_path + + +@pass_context +def _template_translate(context, key: str, default: str | None = None, **values): + """Translate template text without opening a database session for every cell. + + The previous implementation created and closed a SQLAlchemy session for every + ``t(...)`` call. A job table with hundreds of rows therefore caused thousands + of avoidable pool checkouts. Translation dictionaries are already cached by + the i18n module; keep the two dictionaries on the request and format locally. + """ + request = context.get("request") + language_code = "en" + if request: + user = request.session.get("user") or {} + language_code = user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en") + language_code = str(language_code or "en").lower() + + cached = getattr(request.state, "template_translation_dictionaries", None) + if not cached or cached[0] != language_code: + db = SessionLocal() + try: + own = translation_dictionary(db, language_code) + english = translation_dictionary(db, "en") + finally: + db.close() + cached = (language_code, own, english) + request.state.template_translation_dictionaries = cached + + _, own, english = cached + text_value = own.get(key) or english.get(key) or default or key + try: + return text_value.format(**values) + except (KeyError, ValueError): + return text_value + + db = SessionLocal() + try: + return translate(db, language_code, key, default, **values) + finally: + db.close() + +def _template_languages(): + db = SessionLocal() + try: + return [{"code": item.code, "name": item.name, "native_name": item.native_name} for item in i18n_languages(db)] + finally: + db.close() + +templates.env.globals["t"] = _template_translate +templates.env.globals["available_languages"] = _template_languages + +def _utc_iso(value): + """Serialize project timestamps as explicit UTC for browser conversion.""" + if value is None: + return "" + if isinstance(value, datetime): + if value.tzinfo is None: + return value.isoformat(timespec="seconds") + "Z" + return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + return str(value) + +templates.env.globals["utc_iso"] = _utc_iso + + +def _translate_request(request: Request, key: str, default: str | None = None, **values) -> str: + user = _session_user(request) or {} + language_code = user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en") + db = SessionLocal() + try: + return translate(db, language_code, key, default, **values) + finally: + db.close() + + +@app.get("/custom.css", include_in_schema=False) +def custom_css(): + css = str(load_config().get("general", {}).get("custom_css", "") or "") + return Response(content=css, media_type="text/css", headers={"Cache-Control": "no-store, max-age=0"}) + + +SYNC_JOBS: dict[str, dict] = {} +SYNC_JOBS_LOCK = threading.Lock() +BACKUP_STOP_EVENT = threading.Event() +BACKUP_THREAD: threading.Thread | None = None +PRESENCE_STOP_EVENT = threading.Event() +PRESENCE_THREAD: threading.Thread | None = None +SOFTWARE_TIMEOUT_STOP_EVENT = threading.Event() +SOFTWARE_TIMEOUT_THREAD: threading.Thread | None = None +SOFTWARE_JOB_TERMINAL_STATES = {"success", "failed", "partial", "timeout", "cancelled"} +SOFTWARE_JOB_AUTOMATIC_RETRY_STATES = {"failed", "partial", "timeout", "sent"} + + +def _configured_callback_worker_count() -> int: + try: + value = int(_software_settings().get("callback_worker_count", 4) or 4) + except (TypeError, ValueError): + value = 4 + return max(1, min(value, 8)) + + +SOFTWARE_CALLBACK_WORKER_COUNT = _configured_callback_worker_count() +SOFTWARE_CALLBACK_EXECUTOR = ThreadPoolExecutor( + max_workers=SOFTWARE_CALLBACK_WORKER_COUNT, + thread_name_prefix="assetmanager-callback", +) + + +def _expire_stale_software_jobs(db: Session, job_id: int | None = None) -> int: + """Mark callback jobs as timed out independently of page views.""" + query = db.query(SoftwareJob).filter( + SoftwareJob.callback_completed_at.is_(None), + SoftwareJob.callback_expires_at < datetime.utcnow(), + SoftwareJob.status.notin_(SOFTWARE_JOB_TERMINAL_STATES), + ) + if job_id is not None: + query = query.filter(SoftwareJob.id == job_id) + rows = query.all() + if not rows: + return 0 + + language_code = str(load_config().get("general", {}).get("default_language", "de") or "de") + timeout_message = translate(db, language_code, "software.status.timeout", "Timed out") + now = datetime.utcnow() + for job in rows: + job.status = "timeout" + job.message = timeout_message + job.finished_at = now + _record_job_event(db, job, "timeout", "timeout", timeout_message) + sync_asset_job_state(db, job, status_at=now) + db.commit() + return len(rows) + + +def _automatic_retry_runtime_settings() -> tuple[bool, int, int, int]: + settings = _software_settings() + enabled = bool(settings.get("automatic_retry_enabled", True)) + try: + max_age_hours = int(settings.get("automatic_retry_max_age_hours", 12) or 12) + except (TypeError, ValueError): + max_age_hours = 12 + try: + retry_interval_seconds = int(settings.get("automatic_retry_interval_seconds", 60) or 60) + except (TypeError, ValueError): + retry_interval_seconds = 60 + try: + max_retries = int(settings.get("automatic_retry_max_retries", 3) or 3) + except (TypeError, ValueError): + max_retries = 3 + return ( + enabled, + max(1, min(max_age_hours, 168)), + max(10, min(retry_interval_seconds, 86400)), + max(1, min(max_retries, 10)), + ) + + +def _job_callback_base_for_background(job: SoftwareJob) -> str: + parameters = job.parameters if isinstance(job.parameters, dict) else {} + stored = str(parameters.get("_callback_base_url") or "").strip().rstrip("/") + if stored: + return stored + configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") + if configured: + return configured + # Compatibility for jobs created before the callback base was stored in + # the job snapshot. The resolved script contains the complete callback URL. + match = re.search( + r"(https?://[^\s'\"<>]+?)/api/software-jobs/\d+/callback(?:\?[^\s'\"<>]*)?", + str(job.resolved_script or ""), + flags=re.IGNORECASE, + ) + return match.group(1).rstrip("/") if match else "" + + +def _queue_automatic_job_retries(db: Session) -> list[tuple[int, str, str]]: + enabled, max_age_hours, retry_interval_seconds, max_retries = _automatic_retry_runtime_settings() + if not enabled: + return [] + + now = datetime.utcnow() + oldest_created_at = now - timedelta(hours=max_age_hours) + retry_ready_at = now - timedelta(seconds=retry_interval_seconds) + candidates = ( + db.query(SoftwareJob) + .options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)) + .filter( + SoftwareJob.status.in_(SOFTWARE_JOB_AUTOMATIC_RETRY_STATES), + SoftwareJob.created_at >= oldest_created_at, + func.coalesce(SoftwareJob.finished_at, SoftwareJob.sent_at, SoftwareJob.created_at) <= retry_ready_at, + ) + .order_by(func.coalesce(SoftwareJob.finished_at, SoftwareJob.sent_at, SoftwareJob.created_at), SoftwareJob.id) + .limit(100) + .all() + ) + if not candidates: + return [] + + candidate_ids = [job.id for job in candidates] + retry_counts = dict( + db.query(JobEvent.job_id, func.count(JobEvent.id)) + .filter( + JobEvent.job_id.in_(candidate_ids), + JobEvent.event_type == "automatic_retry_requested", + ) + .group_by(JobEvent.job_id) + .all() + ) + + queued: list[tuple[int, str, str]] = [] + for job in candidates: + if int(retry_counts.get(job.id, 0) or 0) >= max_retries: + continue + if not job.asset or not job.asset.mesh_node_id: + continue + callback_base = _job_callback_base_for_background(job) + if not callback_base: + logger.warning( + "Automatic retry skipped for software job %s because no callback base URL could be determined", + job.id, + ) + continue + token = _prepare_existing_job_restart(db, None, job, automatic=True) + queued.append((job.id, token, callback_base)) + + if queued: + db.commit() + return queued + + +def _software_job_timeout_loop(stop_event: threading.Event) -> None: + while not stop_event.is_set(): + queued: list[tuple[int, str, str]] = [] + db = SessionLocal() + try: + expired = _expire_stale_software_jobs(db) + if expired: + logger.info("%s software jobs automatically marked as timed out", expired) + queued = _queue_automatic_job_retries(db) + except Exception: + db.rollback() + logger.exception("Software job timeout or retry check failed") + finally: + db.close() + + for job_id, token, callback_base in queued: + threading.Thread( + target=execute_job, + args=(job_id, token, callback_base), + daemon=True, + name=f"assetmanager-auto-retry-{job_id}", + ).start() + if queued: + logger.info("%s software jobs automatically queued again", len(queued)) + + _, _, retry_interval_seconds, _ = _automatic_retry_runtime_settings() + stop_event.wait(min(30, max(5, retry_interval_seconds))) + + +@app.get('/job-definitions', response_class=HTMLResponse) +def job_definitions_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + search_text = str(request.query_params.get('q') or '').strip() + active_only = str(request.query_params.get('active_only', '1')).lower() not in {'0', 'false', 'off', 'no'} + + query = db.query(JobDefinition) + if active_only: + query = query.filter(JobDefinition.enabled.is_(True)) + if search_text: + pattern = f"%{search_text}%" + query = query.filter(or_(JobDefinition.name.ilike(pattern), JobDefinition.description.ilike(pattern))) + + rows = query.order_by(JobDefinition.enabled.desc(), JobDefinition.name.asc()).all() + + referenced_definition_ids = set() + for job in db.query(SoftwareJob).filter(SoftwareJob.job_type == 'job_definition').all(): + try: + referenced_definition_ids.add(int((job.parameters or {}).get('definition_id'))) + except (TypeError, ValueError, AttributeError): + continue + + definition_rows = [ + { + 'item': row, + 'can_delete': (not row.is_system and row.id not in referenced_definition_ids), + 'delete_reason': ( + 'system' if row.is_system else + 'referenced' if row.id in referenced_definition_ids else + None + ), + } + for row in rows + ] + return templates.TemplateResponse( + 'job_definitions.html', + { + 'request': request, + 'definition_rows': definition_rows, + 'active_only': active_only, + 'search_text': search_text, + }, + ) + +@app.get('/job-definitions/new', response_class=HTMLResponse) +def job_definition_new(request: Request): + _require_admin(request) + return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':None,'test_result':None,'script_preview':None,'preview_values':None}) + +@app.get('/job-definitions/{definition_id}/edit', response_class=HTMLResponse) +def job_definition_edit(definition_id:int, request:Request, db:Session=Depends(get_db)): + _require_admin(request); row=db.get(JobDefinition,definition_id) + if not row: raise HTTPException(404) + return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':row,'test_result':None,'script_preview':None,'preview_values':None}) + +def _parse_registry_entries_json(raw: str) -> list[dict]: + try: + items = json.loads(raw or "[]") + except (TypeError, ValueError): + raise HTTPException(400, "Invalid registry entry definition.") + allowed_actions={"set","delete_value","delete_key"} + allowed_hives={"HKLM","HKCU","HKCR","HKU","HKCC"} + allowed_types={"REG_SZ","REG_EXPAND_SZ","REG_MULTI_SZ","REG_DWORD","REG_QWORD","REG_BINARY"} + result=[] + for item in items if isinstance(items,list) else []: + if not isinstance(item,dict): continue + raw_action=str(item.get("action") or "set") + # Compatibility for the short-lived 0.5.4.1 permission-only action. + legacy_permission_action = raw_action == "set_permissions" + action = "set" if legacy_permission_action else raw_action + hive=str(item.get("hive") or "HKLM").upper() + path=str(item.get("path") or "").strip().strip("\\") + name=str(item.get("name") or "").strip() + value_type=str(item.get("value_type") or "REG_SZ").upper() + if action not in allowed_actions or hive not in allowed_hives or not path: continue + if value_type not in allowed_types: value_type="REG_SZ" + result.append({ + "action":action,"hive":hive,"path":path,"name":name, + "value_type":value_type,"value":str(item.get("value") or ""), + "set_value":False if legacy_permission_action else bool(item.get("set_value", True)), + "only_if_missing":bool(item.get("only_if_missing")), + "permissions_enabled":True if legacy_permission_action else bool(item.get("permissions_enabled")), + "principal":str(item.get("principal") or "S-1-5-32-545").strip(), + "rights":str(item.get("rights") or "FullControl").strip(), + "access_type":"Deny" if str(item.get("access_type") or "Allow").lower()=="deny" else "Allow", + "inherit_subkeys":bool(item.get("inherit_subkeys", True)), + "replace_existing":bool(item.get("replace_existing", True)), + }) + return result + + +def _apply_job_definition_form(row, request, name, description, platform, interpreter, source_type, source_path, inline_script, arguments, timeout_seconds, success_codes, enabled, job_kind='script', registry_entries_json='[]', registry_backup=None): + kind = job_kind if job_kind in {'script','registry'} else 'script' + row.name=name.strip();row.description=description.strip() or None;row.job_kind=kind + row.platform='windows' if kind=='registry' else platform + row.interpreter='powershell' if kind=='registry' else interpreter + row.source_type='inline' if kind=='registry' else source_type + row.source_path=None if kind=='registry' else (source_path.strip() or None) + row.inline_script=None if kind=='registry' else (inline_script if source_type=='inline' else None) + row.arguments=None if kind=='registry' else (arguments.strip() or None) + row.configuration={"registry_entries":_parse_registry_entries_json(registry_entries_json),"registry_backup":registry_backup=='on'} if kind=='registry' else {} + row.timeout_seconds=max(1,min(int(timeout_seconds or 300),86400));row.success_codes=success_codes.strip() or '0';row.enabled=enabled=='on';row.updated_by=(request.session.get('user') or {}).get('username') + +def _parse_job_parameters_json(raw: str) -> list[dict]: + try: + items = json.loads(raw or "[]") + except (TypeError, ValueError): + raise HTTPException(400, "Invalid job parameter definition.") + result=[]; seen=set() + for index,item in enumerate(items if isinstance(items,list) else []): + if not isinstance(item,dict): continue + key=re.sub(r"[^A-Za-z0-9_]", "_", str(item.get("parameter_key") or "").strip()).strip("_") + if not key or key in seen: continue + seen.add(key) + result.append({"parameter_key":key,"label":str(item.get("label") or key).strip()[:140],"description":str(item.get("description") or "").strip() or None,"data_type":str(item.get("data_type") or "text") if str(item.get("data_type") or "text") in {"text","number","boolean","path"} else "text","default_value":str(item.get("default_value") or ""),"required":bool(item.get("required")),"sort_order":index}) + return result + +def _sync_job_definition_parameters(db: Session, definition: JobDefinition, items: list[dict]) -> None: + existing={x.parameter_key:x for x in definition.parameters} + wanted={x["parameter_key"] for x in items} + for row in list(definition.parameters): + if row.parameter_key not in wanted: + db.delete(row) + for item in items: + row=existing.get(item["parameter_key"]) + if row is None: + row=JobDefinitionParameter(definition_id=definition.id, parameter_key=item["parameter_key"]); db.add(row) + for key,value in item.items(): setattr(row,key,value) + db.flush() + +def _job_effective_parameters(db: Session, definition: JobDefinition, asset: Asset) -> dict[str,str]: + parameter_ids=[x.id for x in definition.parameters] + overrides={} + if parameter_ids: + overrides={x.definition_parameter_id:x.value_text for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id, JobDefinitionAssetParameter.definition_parameter_id.in_(parameter_ids)).all()} + return {x.parameter_key: (overrides[x.id] if x.id in overrides else (x.default_value or "")) for x in definition.parameters} + +@app.post('/job-definitions') +def job_definition_create(request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)): + _require_admin(request); row=JobDefinition(created_by=(request.session.get('user') or {}).get('username')) + _apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup);db.add(row);db.flush();_sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json));db.commit();db.refresh(row) + db.add(JobDefinitionRevision(definition_id=row.id,revision=row.revision,snapshot=_job_definition_snapshot(row),changed_by=row.updated_by));db.commit() + return RedirectResponse('/job-definitions?toast_success='+quote(_translate_request(request,'jobdefs.saved','Job definition saved.')),303) + +@app.post('/job-definitions/{definition_id}') +def job_definition_update(definition_id:int,request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)): + _require_admin(request);row=db.get(JobDefinition,definition_id) + if not row: raise HTTPException(404) + row.revision=int(row.revision or 0)+1;_apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup);_sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json));db.flush();db.add(JobDefinitionRevision(definition_id=row.id,revision=row.revision,snapshot=_job_definition_snapshot(row),changed_by=row.updated_by));db.commit() + return RedirectResponse(f'/job-definitions/{row.id}/edit?toast_success='+quote(_translate_request(request,'jobdefs.saved','Job definition saved.')),303) + +@app.post('/job-definitions/{definition_id}/delete') +def job_definition_delete(definition_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + row = db.get(JobDefinition, definition_id) + if not row: + raise HTTPException(404) + if row.is_system: + return RedirectResponse('/job-definitions?toast_error=' + quote(_translate_request(request, 'jobdefs.delete.system_forbidden', 'System job definitions cannot be deleted.')), 303) + + referenced_jobs = [] + for job in db.query(SoftwareJob).filter(SoftwareJob.job_type == 'job_definition').all(): + try: + referenced_id = int((job.parameters or {}).get('definition_id')) + except (TypeError, ValueError): + continue + if referenced_id == row.id: + referenced_jobs.append(job.id) + if len(referenced_jobs) >= 1: + break + if referenced_jobs: + message = _translate_request( + request, + 'jobdefs.delete.referenced', + 'This job definition is referenced by existing jobs and cannot be deleted. Deactivate it instead.', + ) + return RedirectResponse('/job-definitions?toast_error=' + quote(message), 303) + + name = row.name + db.delete(row) + db.commit() + message = _translate_request(request, 'jobdefs.deleted', 'Job definition "{name}" was deleted.').format(name=name) + return RedirectResponse('/job-definitions?toast_success=' + quote(message), 303) + + +@app.post('/job-definitions/{definition_id}/test', response_class=HTMLResponse) +def job_definition_test(definition_id:int,request:Request,name:str=Form(...),description:str=Form(''),job_kind:str=Form('script'),platform:str=Form('windows'),interpreter:str=Form('powershell'),source_type:str=Form('inline'),source_path:str=Form(''),inline_script:str=Form(''),arguments:str=Form(''),timeout_seconds:int=Form(300),success_codes:str=Form('0'),enabled:str|None=Form(None),parameters_json:str=Form('[]'),registry_entries_json:str=Form('[]'),registry_backup:str|None=Form(None),db:Session=Depends(get_db)): + _require_admin(request);row=db.get(JobDefinition,definition_id) + if not row: raise HTTPException(404) + _apply_job_definition_form(row,request,name,description,platform,interpreter,source_type,source_path,inline_script,arguments,timeout_seconds,success_codes,enabled,job_kind,registry_entries_json,registry_backup) + _sync_job_definition_parameters(db,row,_parse_job_parameters_json(parameters_json)) + result=_validate_job_definition(row, request) + preview='' + if row.source_type=='inline': + preview_interpreter='powershell' if row.job_kind=='registry' else row.interpreter + preview_source=build_registry_user_script(row.configuration) if row.job_kind=='registry' else (row.inline_script or '') + wrapper=_generate_job_script(preview_interpreter,'wrapper') + start_marker,end_marker=JOB_SCRIPT_MARKERS[preview_interpreter] + block=start_marker+'\n\n'+end_marker + indent={'powershell':' ','python':' ','bash':'','cmd':''}[preview_interpreter] + commands='\n'.join(indent+line if line.strip() else line for line in preview_source.splitlines()) + preview=wrapper.replace(block,start_marker+'\n'+commands+'\n'+end_marker,1) + values=_job_preview_values() + for parameter in row.parameters: values[parameter.parameter_key]=parameter.default_value or '' + preview=_replace_job_placeholders(preview,values) + db.rollback() + return templates.TemplateResponse('job_definition_form.html', {'request':request,'definition':row,'test_result':result,'script_preview':preview,'preview_values':_job_preview_values()}) + +JOB_SCRIPT_TEMPLATE_DIR = BASE_DIR / "templates" / "jobs" +JOB_SCRIPT_TEMPLATE_FILES = { + "powershell": ("powershell_wrapper.ps1", "powershell_test.txt"), + "python": ("python_wrapper.py.txt", "python_test.txt"), + "bash": ("bash_wrapper.sh", "bash_test.txt"), + "cmd": ("cmd_wrapper.cmd", "cmd_test.txt"), +} +JOB_SCRIPT_MARKERS = { + "powershell": (" # --- Eigene Befehle hier einfügen ---", " # --- Ende eigene Befehle ---"), + "python": (" # --- Eigene Befehle hier einfügen ---", " # --- Ende eigene Befehle ---"), + "bash": ("# --- Eigene Befehle hier einfügen ---", "# --- Ende eigene Befehle ---"), + "cmd": ("rem --- Eigene Befehle hier einfügen ---", "rem --- Ende eigene Befehle ---"), +} + +def _read_job_script_template(filename: str) -> str: + path = (JOB_SCRIPT_TEMPLATE_DIR / filename).resolve() + root = JOB_SCRIPT_TEMPLATE_DIR.resolve() + if root not in path.parents or not path.is_file(): + raise HTTPException(500, "Job script template is missing.") + return path.read_text(encoding="utf-8").replace("\r\n", "\n") + +def _generate_job_script(interpreter: str, action: str) -> str: + interpreter = str(interpreter or "").strip().lower() + action = str(action or "").strip().lower() + if interpreter not in JOB_SCRIPT_TEMPLATE_FILES: + raise HTTPException(400, "Unsupported interpreter.") + if action not in {"wrapper", "test"}: + raise HTTPException(400, "Unsupported generator action.") + wrapper_file, test_file = JOB_SCRIPT_TEMPLATE_FILES[interpreter] + wrapper = _read_job_script_template(wrapper_file) + if action == "wrapper": + return wrapper + commands = _read_job_script_template(test_file).rstrip("\n") + start_marker, end_marker = JOB_SCRIPT_MARKERS[interpreter] + marker_block = start_marker + "\n\n" + end_marker + replacement = start_marker + "\n" + commands + "\n" + end_marker + if marker_block not in wrapper: + raise HTTPException(500, "Job script template markers are invalid.") + return wrapper.replace(marker_block, replacement, 1) + +@app.post('/api/job-definitions/script-generator') +async def job_definition_script_generator(request: Request): + _require_admin(request) + try: + payload = await request.json() + except Exception as exc: + raise HTTPException(400, "Invalid JSON request.") from exc + interpreter = str((payload or {}).get("interpreter") or "") + action = str((payload or {}).get("action") or "") + logger.info( + "Job script generator requested | user=%s interpreter=%s action=%s", + (_session_user(request) or {}).get("username"), + interpreter, + action, + ) + script = _generate_job_script(interpreter, action) + logger.info( + "Job script generator completed | interpreter=%s action=%s bytes=%s", + interpreter, + action, + len(script.encode("utf-8")), + ) + return JSONResponse({"script": script, "interpreter": interpreter, "action": action}) + + +@app.on_event("startup") +def start_backup_scheduler(): + global BACKUP_THREAD + storage = system_storage_information() + backup_status = storage["backups"] + logger.info( + "Backup directory: %s | exists=%s | writable=%s | mount=%s", + backup_status["path"], backup_status["exists"], backup_status["writable"], backup_status["is_mount"], + ) + if not backup_status["writable"]: + logger.error("Backup directory is not writable: %s", backup_status.get("error") or backup_status["path"]) + if BACKUP_THREAD is None or not BACKUP_THREAD.is_alive(): + BACKUP_STOP_EVENT.clear() + BACKUP_THREAD = threading.Thread(target=automatic_backup_loop, args=(BACKUP_STOP_EVENT,), daemon=True, name="assetmanager-backup") + BACKUP_THREAD.start() + + +@app.on_event("shutdown") +def stop_backup_scheduler(): + BACKUP_STOP_EVENT.set() + + +def _hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + iterations = 310_000 + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}" + + +def _verify_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + algorithm, iterations_text, salt_hex, digest_hex = encoded.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + candidate = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), bytes.fromhex(salt_hex), int(iterations_text) + ) + return hmac.compare_digest(candidate.hex(), digest_hex) + except (ValueError, TypeError): + return False + + +def _ensure_local_emergency_admin(db: Session) -> None: + """Create or protect the configured local break-glass administrator. + + The password from the environment is used only when the account is first + created or when an existing local account has no password hash. Existing + password hashes are never overwritten during container startup. + """ + username = str(os.getenv("LOCAL_ADMIN_USERNAME", "") or "").strip() + password = str(os.getenv("LOCAL_ADMIN_PASSWORD", "") or "") + + if not username and not password: + return + if not username or not password: + logger.error( + "Lokaler Notfall-Administrator wurde nicht eingerichtet: " + "LOCAL_ADMIN_USERNAME und LOCAL_ADMIN_PASSWORD müssen gemeinsam gesetzt sein." + ) + return + if len(username) > 120: + logger.error("LOCAL_ADMIN_USERNAME ist länger als 120 Zeichen; Konto wurde nicht eingerichtet.") + return + if len(password) < 12: + logger.error("LOCAL_ADMIN_PASSWORD muss mindestens 12 Zeichen lang sein; Konto wurde nicht eingerichtet.") + return + + matches = db.query(User).filter(func.lower(User.username) == username.casefold()).all() + if len(matches) > 1: + logger.error( + "Lokaler Notfall-Administrator konnte nicht eingerichtet werden: " + "Der Benutzername %r existiert mehrfach mit unterschiedlicher Schreibweise.", + username, + ) + return + user = matches[0] if matches else None + if user and user.auth_source != "local": + logger.error( + "Lokaler Notfall-Administrator konnte nicht eingerichtet werden: " + "Benutzername %r gehört bereits zu einem %s-Konto.", + username, + user.auth_source, + ) + return + + created = False + password_initialized = False + if not user: + user = User( + username=username, + display_name="Lokaler Notfall-Administrator", + password_hash=_hash_password(password), + auth_source="local", + is_active=True, + is_admin=True, + is_protected=True, + allowed_mesh_groups=[], + asset_access_scopes=["all"], + language_code=load_config().get("general", {}).get("default_language", "de"), + ) + db.add(user) + created = True + password_initialized = True + else: + user.is_active = True + user.is_admin = True + user.is_protected = True + user.auth_source = "local" + if not user.display_name: + user.display_name = "Lokaler Notfall-Administrator" + if not user.password_hash: + user.password_hash = _hash_password(password) + password_initialized = True + + db.commit() + logger.info( + "Lokaler Notfall-Administrator %r ist aktiv und geschützt " + "(neu=%s, Passwort initialisiert=%s).", + user.username, + created, + password_initialized, + ) + + +def _session_user(request: Request) -> dict | None: + value = request.session.get("user") + return value if isinstance(value, dict) else None + + +def _changed_by(request: Request) -> str | None: + user = _session_user(request) + return (user.get("display_name") or user.get("username")) if user else None + + +def _login_user(request: Request, user: User) -> None: + request.session["user"] = { + "id": user.id, + "username": user.username, + "display_name": user.display_name or user.username, + "email": user.email or "", + "auth_source": user.auth_source, + "is_admin": bool(user.is_admin), + "is_protected": bool(getattr(user, "is_protected", False)), + "allowed_mesh_groups": user.allowed_mesh_groups or [], + "asset_access_scopes": user.asset_access_scopes or [], + "profile_department": user.profile_department or "", + "profile_location": user.profile_location or "", + "language_code": user.language_code or "en", + "persist_browser_state": bool(getattr(user, "persist_browser_state", False)), + "toast_position": getattr(user, "toast_position", "top-right") or "top-right", + "toast_duration_seconds": int(getattr(user, "toast_duration_seconds", 6) or 6), + "theme_mode": getattr(user, "theme_mode", "light") if getattr(user, "theme_mode", "light") in {"light", "dark"} else "light", + } + + + +def _is_admin(request: Request) -> bool: + user = _session_user(request) + return bool(user and user.get("is_admin")) + + +def _require_admin(request: Request) -> None: + if not _is_admin(request): + raise HTTPException(403, "Diese Funktion ist nur für Administratoren verfügbar.") + + +ASSET_ACCESS_SCOPES = {"self", "department", "location", "all"} + + +def _allowed_groups(request: Request) -> list[str] | None: + user = _session_user(request) + if not user or user.get("is_admin"): + return None + return [str(x).strip() for x in (user.get("allowed_mesh_groups") or []) if str(x).strip()] + + +def _asset_access_conditions(request: Request): + """Build additive (OR-linked) asset visibility conditions for a non-admin user.""" + user = _session_user(request) + if not user or user.get("is_admin"): + return None + + scopes = {str(value).strip().lower() for value in (user.get("asset_access_scopes") or [])} + scopes &= ASSET_ACCESS_SCOPES + if "all" in scopes: + return None + + conditions = [] + username = str(user.get("username") or "").strip() + department = str(user.get("profile_department") or "").strip() + location = str(user.get("profile_location") or "").strip() + + if "self" in scopes and username: + conditions.append(func.lower(func.trim(Asset.assigned_to)) == username.casefold()) + if "department" in scopes and department: + conditions.append(func.lower(func.trim(Asset.department)) == department.casefold()) + if "location" in scopes and location: + conditions.append(func.lower(func.trim(Asset.location)) == location.casefold()) + + groups = _allowed_groups(request) or [] + if groups: + conditions.append(Asset.mesh_group.in_(groups)) + + return conditions + + +def _apply_asset_access(query, request: Request): + conditions = _asset_access_conditions(request) + if conditions is None: + return query + return query.filter(or_(*conditions) if conditions else false()) + + +def _get_visible_asset(db: Session, request: Request, asset_id: int) -> Asset: + query = _apply_asset_access(db.query(Asset), request) + asset = query.filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden oder keine Berechtigung") + return asset + + +def _action_status(db: Session, action: str) -> StatusOption | None: + column = StatusOption.use_for_issue if action == "issue" else StatusOption.use_for_return + return db.query(StatusOption).filter(StatusOption.active.is_(True), column.is_(True)).first() + + +def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict | None: + from ldap3 import ALL, Connection, Server, SUBTREE + from ldap3.core.exceptions import LDAPException + from ldap3.utils.conv import escape_filter_chars + + ldap = auth_config.get("ldap", {}) + server_name = str(ldap.get("server", "")).strip() + base_dn = str(ldap.get("user_base_dn", "")).strip() + use_ssl = bool(ldap.get("use_ssl", False)) + start_tls = bool(ldap.get("start_tls", False)) + port = int(ldap.get("port") or (636 if use_ssl else 389)) + bind_dn = str(ldap.get("bind_dn", "")).strip() + env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip() + bind_password = os.getenv(env_name, "") if env_name else "" + if not bind_password: + bind_password = str(ldap.get("bind_password", "")) + + ldap_logger.info( + "Anmeldeversuch Benutzer=%r Server=%s Port=%s SSL=%s StartTLS=%s BaseDN=%r BindDN=%r Passwortvariable=%r gesetzt=%s", + username, server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password), + ) + + if not server_name: + ldap_logger.error("LDAP-Server ist nicht konfiguriert") + return None + if not base_dn: + ldap_logger.error("Benutzer-Basis-DN ist nicht konfiguriert") + return None + if not password: + ldap_logger.warning("Leeres Benutzerpasswort für Benutzer=%r", username) + return None + if bind_dn and not bind_password: + ldap_logger.error("Bind-DN ist gesetzt, aber das Bind-Passwort fehlt. Erwartete Umgebungsvariable=%r", env_name) + return None + + server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10) + search_connection = None + user_connection = None + try: + search_connection = Connection( + server, + user=bind_dn or None, + password=bind_password or None, + auto_bind=False, + receive_timeout=15, + raise_exceptions=False, + ) + if start_tls and not use_ssl: + search_connection.open() + ldap_logger.debug("TCP/LDAP-Verbindung geöffnet") + if not search_connection.start_tls(): + ldap_logger.error("StartTLS fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) + return None + ldap_logger.debug("StartTLS erfolgreich") + + if not search_connection.bind(): + ldap_logger.error("Dienstkonto-Bind fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) + return None + ldap_logger.info("Dienstkonto-Bind erfolgreich") + + raw_filter = str(ldap.get("user_filter", "(sAMAccountName={username})")) + escaped_username = escape_filter_chars(username) + user_filter = raw_filter.replace("{username}", escaped_username).replace("{{username}}", escaped_username) + display_attr = str(ldap.get("display_name_attribute", "displayName")) + email_attr = str(ldap.get("email_attribute", "mail")) + attributes = list(dict.fromkeys([display_attr, email_attr, "distinguishedName"])) + ldap_logger.info("Benutzersuche BaseDN=%r Filter=%r Attribute=%r", base_dn, user_filter, attributes) + + search_ok = search_connection.search( + base_dn, + user_filter, + search_scope=SUBTREE, + attributes=attributes, + ) + ldap_logger.info( + "Benutzersuche beendet: success=%s Treffer=%s result=%r last_error=%r", + search_ok, len(search_connection.entries), search_connection.result, search_connection.last_error, + ) + if len(search_connection.entries) != 1: + if len(search_connection.entries) == 0: + ldap_logger.warning("Kein LDAP-Benutzer für Benutzer=%r gefunden", username) + else: + ldap_logger.warning("Mehrere LDAP-Benutzer für Benutzer=%r gefunden: %s", username, len(search_connection.entries)) + return None + + entry = search_connection.entries[0] + user_dn = entry.entry_dn + display_name = str(getattr(entry, display_attr, "") or username) + email = str(getattr(entry, email_attr, "") or "") + ldap_logger.info("Benutzer gefunden: DN=%r Anzeigename=%r E-Mail=%r", user_dn, display_name, email) + + user_connection = Connection( + server, + user=user_dn, + password=password, + auto_bind=False, + receive_timeout=15, + raise_exceptions=False, + ) + if start_tls and not use_ssl: + user_connection.open() + if not user_connection.start_tls(): + ldap_logger.error("StartTLS beim Benutzer-Bind fehlgeschlagen: result=%r last_error=%r", user_connection.result, user_connection.last_error) + return None + if not user_connection.bind(): + ldap_logger.warning("Benutzer-Bind fehlgeschlagen für DN=%r: result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error) + return None + + ldap_logger.info("LDAP-Anmeldung erfolgreich für Benutzer=%r DN=%r", username, user_dn) + return {"username": username, "display_name": display_name, "email": email} + except LDAPException as exc: + ldap_logger.exception("LDAP-Ausnahme für Benutzer=%r: %s", username, exc) + return None + except Exception as exc: + ldap_logger.exception("Unerwarteter LDAP-Fehler für Benutzer=%r: %s", username, exc) + return None + finally: + for connection in (user_connection, search_connection): + if connection is not None: + try: + connection.unbind() + except Exception: + pass + + +@app.middleware("http") +async def authentication_gate(request: Request, call_next): + mode = str(load_config().get("authentication", {}).get("mode", "none")) + public_paths = {"/login", "/logout"} + is_agent_callback = (request.url.path.startswith("/api/software-jobs/") and request.url.path.endswith("/callback")) or request.url.path == "/api/software-callback/health" + if mode in {"local", "ldap"} and not request.session.get("user") and request.url.path not in public_paths and not request.url.path.startswith("/static/") and not is_agent_callback: + if request.method == "GET": + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + return JSONResponse({"detail": "Anmeldung erforderlich"}, status_code=401) + return await call_next(request) + + +app.add_middleware( + SessionMiddleware, + secret_key=_session_secret, + same_site="lax", + https_only=False, +) + + +def _is_browser_request(request: Request) -> bool: + accept = request.headers.get("accept", "") + return "text/html" in accept or request.method != "GET" + + +def _toast_redirect(request: Request, message: str, level: str = "error") -> RedirectResponse: + referer = request.headers.get("referer") + target = referer if referer and referer.startswith(str(request.base_url).rstrip("/")) else "/" + separator = "&" if "?" in target else "?" + return RedirectResponse(f"{target}{separator}toast_{level}={quote(message)}", status_code=303) + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + message = str(exc.detail) if exc.detail else f"HTTP-Fehler {exc.status_code}" + logger.error("HTTP %s | %s %s | %s", exc.status_code, request.method, request.url.path, message) + if _is_browser_request(request): + return _toast_redirect(request, message) + return JSONResponse({"detail": message}, status_code=exc.status_code) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.error( + "Unerwarteter Fehler | %s %s | %s\n%s", + request.method, + request.url.path, + exc, + traceback.format_exc(), + ) + if _is_browser_request(request): + return _toast_redirect(request, "Ein unerwarteter Fehler ist aufgetreten. Details wurden protokolliert.") + return JSONResponse({"detail": "Interner Serverfehler"}, status_code=500) + +def _job_log(job_id: str, level: str, message: str) -> None: + entry = { + "time": datetime.now().strftime("%H:%M:%S"), + "level": level, + "message": str(message), + } + with SYNC_JOBS_LOCK: + job = SYNC_JOBS.get(job_id) + if job is not None: + job["logs"].append(entry) + job["logs"] = job["logs"][-10000:] + try: + with (LOG_DIR / f"{job_id}.jsonl").open("a", encoding="utf-8") as handle: + import json + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError: + pass + +def _run_sync_job(job_id: str) -> None: + db = SessionLocal() + try: + result = synchronize(db, lambda level, message: _job_log(job_id, level, message)) + with SYNC_JOBS_LOCK: + job = SYNC_JOBS[job_id] + job["status"] = result.run.status + job["run_id"] = result.run.id + job["finished"] = True + except Exception as exc: + _job_log(job_id, "error", f"Unerwarteter Fehler: {exc}") + with SYNC_JOBS_LOCK: + job = SYNC_JOBS[job_id] + job["status"] = "error" + job["finished"] = True + finally: + db.close() + + + +def _seed_field_definitions(db: Session) -> None: + existing = {item.field_name: item for item in db.query(FieldDefinition).all()} + for order, (field_name, label) in enumerate(ASSET_FIELDS.items()): + item = existing.get(field_name) + if not item: + item = FieldDefinition( + field_name=field_name, label=label, description=None, + data_type=("boolean" if field_name == "mesh_online" else "integer" if field_name == "mesh_mtype" else "long_text" if field_name in {"notes","storage_details","memory_details","gpu_name","antivirus"} else "date" if field_name in {"purchase_date","warranty_until","assigned_on"} else "status" if field_name == "status" else "ip" if field_name == "ip_address" else "short_text"), + readonly=field_name in SYSTEM_READONLY_FIELDS, is_system=True, + translation_key=f"field.{field_name}", sort_order=order, + excel_import=field_name not in SYSTEM_READONLY_FIELDS, + bulk_edit=field_name not in SYSTEM_READONLY_FIELDS, + is_default=field_name in DEFAULT_FIELDS, + ) + db.add(item); db.flush(); existing[field_name] = item + elif field_name in SYSTEM_READONLY_FIELDS: + item.readonly = True; item.excel_import = False; item.bulk_edit = False + db.flush() + for link in db.query(CategoryField).all(): + definition = existing.get(link.field_name) + if definition and link.field_definition_id != definition.id: + link.field_definition_id = definition.id + # Every category gets every available field; new links are inactive. + categories = db.query(Category).all() + definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() + for category in categories: + linked = {x.field_definition_id for x in category.visible_fields if x.field_definition_id} + linked_names = {x.field_name for x in category.visible_fields} + next_order = max((x.sort_order for x in category.visible_fields), default=-1) + 1 + for definition in definitions: + if definition.id in linked or definition.field_name in linked_names: + continue + db.add(CategoryField(category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=False, required=False, show_in_list=False, readonly=definition.readonly, sort_order=next_order)) + next_order += 1 + db.commit() + + + +DEFAULT_MESH_FIELD_MAPPINGS = [ + # source_path, target, transform, multi mode, separator, enabled, priority, description + ("node._id|node.nodeid|node.nodeId|node.id|_id|nodeid|nodeId|id", "mesh_node_id", "string", "first", "\n", True, 10, "Eindeutige MeshCentral Node-ID"), + ("node.name|node.hostname|node.rname|name|hostname|rname", "name", "string", "first", "\n", True, 20, "Gerätename aus MeshCentral"), + ("node.hostname|node.name|node.rname|hostname|name|rname", "hostname", "string", "first", "\n", True, 30, "Computername/Hostname"), + ("node.osdesc|node.os|node.operatingSystem|osdesc|os|operatingSystem", "operating_system", "string", "first", "\n", True, 40, "Betriebssystembeschreibung"), + ("sys.hardware.windows.osinfo.BuildNumber|sys.hardware.windows.osinfo.Version", "os_patch_level", "string", "first", "\n", True, 50, "Windows Build- oder Versionsnummer"), + ("net.netif2", "ip_address", "active_ipv4_address", "first", "\n", True, 60, "IPv4-Adresse des ersten aktiven Nicht-Loopback-Adapters"), + ("net.netif2", "mac_address", "active_ipv4_mac", "first", "\n", True, 70, "MAC-Adresse desselben aktiven IPv4-Adapters"), + ("sys.hardware.identifiers.board_vendor|sys.hardware.identifiers.bios_vendor", "manufacturer", "string", "first", "\n", True, 80, "System- oder BIOS-Hersteller"), + ("sys.hardware.identifiers.product_name", "model", "string", "first", "\n", True, 90, "Produkt-/Modellbezeichnung"), + ("sys.hardware.identifiers.bios_serial|sys.hardware.windows.bios.SerialNumber|node.serial_number|node.serialNumber", "serial_number", "normalize_serial", "first", "\n", True, 100, "BIOS-/Geräteseriennummer"), + ("sys.hardware.identifiers.cpu_name|sys.hardware.windows.cpu[].Name", "cpu", "string", "first", "\n", True, 110, "CPU-Bezeichnung"), + ("sys.hardware.windows.memory", "memory_gb", "sum_memory_gb", "first", "\n", True, 120, "Summe der Speichermodule in GiB"), + ("sys.hardware.windows.memory", "memory_details", "format_memory_modules", "first", "\n", True, 130, "Formatierte Liste der Speichermodule"), + ("sys.hardware.windows.drives", "storage_gb", "sum_storage_gb", "first", "\n", True, 140, "Gesamtkapazität der Datenträger in GB"), + ("sys.hardware.windows.drives", "storage_details", "format_drives", "first", "\n", True, 150, "Formatierte Liste der Datenträger"), + ("sys.hardware.windows.gpu", "gpu_name", "gpu_names", "first", "\n", True, 160, "Namen der erkannten Grafikkarten"), + ("sys.hardware.tpm.SpecVersion", "tpm_version", "string", "first", "\n", True, 170, "TPM-Spezifikationsversion"), + ("node.av|av", "antivirus", "active_antivirus", "first", "\n", True, 180, "Aktive Antivirenprodukte"), + ("node.groupname|groupname", "mesh_group", "string", "first", "\n", True, 190, "MeshCentral-Gerätegruppe"), + ("node.mtype|node.icon|mtype|icon", "mesh_mtype", "integer", "first", "\n", True, 200, "MeshCentral Gerätetyp (mtype)"), +] + + +def _seed_mesh_field_mappings(db: Session) -> None: + """Create missing defaults without overwriting persisted user settings.""" + rows = db.query(MeshFieldMapping).order_by(MeshFieldMapping.id).all() + by_target: dict[str, list[MeshFieldMapping]] = {} + for row in rows: + by_target.setdefault(row.target_field_name, []).append(row) + + # Import legacy config mappings only when that exact database mapping does + # not already exist. Existing database rows remain the source of truth. + config_mappings = load_config().get("meshcentral", {}).get("field_mappings", []) or [] + for index, item in enumerate(config_mappings): + if not isinstance(item, dict): + continue + target = str(item.get("target_field") or item.get("target_field_name") or "").strip() + source = str(item.get("source_path") or "").strip() + if not target or not source: + continue + if any(row.source_path == source and row.target_field_name == target for row in rows): + continue + row = MeshFieldMapping( + source_path=source, + target_field_name=target, + transform=str(item.get("transform") or "raw"), + multi_value_mode=str(item.get("multi_value_mode") or "first"), + separator=str(item.get("separator") or "\\n"), + enabled=bool(item.get("enabled", True)), + priority=int(item.get("priority") or (1000 + index)), + description=item.get("description"), + update_rule=str(item.get("update_rule") or "fill_empty"), + is_system=False, + ) + db.add(row) + rows.append(row) + by_target.setdefault(target, []).append(row) + + legacy_rules = load_config().get("meshcentral", {}).get("field_rules", {}) or {} + + for source, target, transform, multi, separator, enabled, priority, description in DEFAULT_MESH_FIELD_MAPPINGS: + candidates = by_target.get(target, []) + system_row = next((row for row in candidates if bool(getattr(row, "is_system", False))), None) + + if system_row is None: + # A legacy database may contain exactly one mapping for the target. + # Adopt it as the system row without changing its configured values. + system_row = candidates[0] if candidates else None + + if system_row is None: + system_row = MeshFieldMapping( + source_path=source, + target_field_name=target, + transform=transform, + multi_value_mode=multi, + separator=separator, + enabled=enabled, + priority=priority, + description=description, + update_rule=str(legacy_rules.get(target) or "fill_empty"), + is_system=True, + ) + db.add(system_row) + by_target.setdefault(target, []).append(system_row) + continue + + # Only fill genuinely missing values. Never reset a user's persisted + # "who wins" rule, enabled state, source path, transform or priority. + system_row.is_system = True + if not str(system_row.source_path or "").strip(): + system_row.source_path = source + if not str(system_row.transform or "").strip(): + system_row.transform = transform + if not str(system_row.multi_value_mode or "").strip(): + system_row.multi_value_mode = multi + if system_row.separator is None: + system_row.separator = separator + if system_row.priority is None: + system_row.priority = priority + if not str(system_row.description or "").strip(): + system_row.description = description + if not str(system_row.update_rule or "").strip(): + configured_rule = legacy_rules.get(target) + system_row.update_rule = configured_rule if configured_rule in {"fill_empty", "meshcentral_wins", "local_wins"} else "fill_empty" + + db.commit() + +def _definition_label(field: CategoryField) -> str: + return field.definition.label if field.definition else field.label + + +def _definition_readonly(field: CategoryField) -> bool: + return bool(field.definition.readonly) if field.definition else bool(getattr(field, "readonly", False)) + + +def _custom_value_get(asset: Asset, definition: FieldDefinition, db: Session): + row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == asset.id, AssetFieldValue.field_definition_id == definition.id).first() + if not row: return None + return row.value_json if definition.data_type == "json" else row.value_boolean if definition.data_type == "boolean" else row.value_datetime if definition.data_type == "datetime" else row.value_date if definition.data_type == "date" else row.value_number if definition.data_type in {"integer","decimal"} else row.value_text + + +def _custom_value_set(asset: Asset, definition: FieldDefinition, raw_value, db: Session) -> None: + row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == asset.id, AssetFieldValue.field_definition_id == definition.id).first() + if not row: + row = AssetFieldValue(asset_id=asset.id, field_definition_id=definition.id); db.add(row) + row.value_text=row.value_number=row.value_date=None; row.value_boolean=None; row.value_datetime=None; row.value_json=None + value = None if raw_value in (None, "") else raw_value + if definition.data_type == "boolean": row.value_boolean = str(value).lower() in {"1","true","on","yes"} if value is not None else None + elif definition.data_type in {"integer","decimal"}: row.value_number = str(value) if value is not None else None + elif definition.data_type == "date": row.value_date = str(value) if value is not None else None + elif definition.data_type == "datetime": row.value_datetime = datetime.fromisoformat(str(value)) if value else None + elif definition.data_type == "json": + import json; row.value_json = json.loads(str(value)) if value else None + else: row.value_text = str(value) if value is not None else None + + +templates.env.globals["field_label"] = _definition_label +templates.env.globals["field_readonly"] = _definition_readonly + + + +SOFTWARE_INVENTORY_WINDOWS_SCRIPT = r''' +$paths = @( + 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$software = Get-ItemProperty $paths -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName } | + ForEach-Object { + [PSCustomObject]@{ + name = [string]$_.DisplayName + version = [string]$_.DisplayVersion + publisher = [string]$_.Publisher + install_date = [string]$_.InstallDate + architecture = $(if ($_.PSPath -like '*WOW6432Node*') { 'x86' } else { 'x64' }) + } + } | Sort-Object name, version -Unique +Write-JobLog ("Software inventory completed; entries=" + $software.Count) +$JobResult = @{ software = $software } +''' + +SOFTWARE_INVENTORY_LINUX_SCRIPT = r''' +import os +import subprocess + +items = [] +if os.path.exists("/usr/bin/dpkg-query"): + output = subprocess.check_output( + ["dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Maintainer}\n"], + text=True, + errors="replace", + ) + for line in output.splitlines(): + parts = line.split("\t") + items.append({ + "name": parts[0], + "version": parts[1] if len(parts) > 1 else "", + "publisher": parts[2] if len(parts) > 2 else "", + "architecture": "", + "install_date": None, + }) +elif os.path.exists("/usr/bin/rpm"): + output = subprocess.check_output( + ["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\t%{ARCH}\n"], + text=True, + errors="replace", + ) + for line in output.splitlines(): + parts = line.split("\t") + items.append({ + "name": parts[0], + "version": parts[1] if len(parts) > 1 else "", + "publisher": parts[2] if len(parts) > 2 else "", + "architecture": parts[3] if len(parts) > 3 else "", + "install_date": None, + }) +elif os.path.exists("/sbin/apk") or os.path.exists("/usr/sbin/apk"): + output = subprocess.check_output(["apk", "info", "-v"], text=True, errors="replace") + for line in output.splitlines(): + items.append({"name": line, "version": "", "publisher": "", "architecture": "", "install_date": None}) +print("Software inventory completed; entries=" + str(len(items))) +job_result = {"software": items} +''' + +def _seed_job_definitions(db: Session) -> None: + defaults = [ + dict(name='Software-Inventur Windows', system_key='software_inventory_windows', platform='windows', interpreter='powershell', inline_script=SOFTWARE_INVENTORY_WINDOWS_SCRIPT), + dict(name='Software-Inventur Linux', system_key='software_inventory_linux', platform='linux', interpreter='python', inline_script=SOFTWARE_INVENTORY_LINUX_SCRIPT), + ] + for data in defaults: + row=db.query(JobDefinition).filter(JobDefinition.system_key==data['system_key']).first() + if row is None: + row=JobDefinition(description='Systemdefinition für die Softwareinventur. Enthält ausschließlich den fachlichen Skriptcode; der technische Rahmen wird zur Laufzeit erzeugt.', source_type='inline', enabled=True, is_system=True, timeout_seconds=600, success_codes='0', **data) + db.add(row) + else: + # System definitions are maintained by the application. This one-time + # normalization removes previously stored callback/wrapper code. + row.name=data['name']; row.platform=data['platform']; row.interpreter=data['interpreter'] + row.source_type='inline'; row.inline_script=data['inline_script']; row.source_path=None + row.description='Systemdefinition für die Softwareinventur. Enthält ausschließlich den fachlichen Skriptcode; der technische Rahmen wird zur Laufzeit erzeugt.' + row.enabled=True; row.is_system=True; row.timeout_seconds=600; row.success_codes='0' + db.commit() + +def _job_definition_snapshot(row: JobDefinition) -> dict: + return {**{k:getattr(row,k) for k in ('name','description','job_kind','configuration','system_key','platform','interpreter','source_type','source_path','inline_script','arguments','timeout_seconds','success_codes','enabled','is_system','revision')}, 'parameters':[{'parameter_key':x.parameter_key,'label':x.label,'description':x.description,'data_type':x.data_type,'default_value':x.default_value,'required':x.required,'sort_order':x.sort_order} for x in row.parameters]} + +def _job_preview_values() -> dict[str, str]: + return { + "JobId": "12345", + "CallbackUrl": "https://assetmanager.example.invalid/api/software-jobs/12345/callback?token=preview-token", + "AssetName": "DEMO-PC-001", + "Hostname": "DEMO-PC-001", + "IPAddress": "192.0.2.25", + "CurrentUser": "demo.user", + "SerialNumber": "DEMO-SERIAL-001", + "Manufacturer": "Example Manufacturer", + "Model": "Example Model", + "Department": "IT", + "Location": "Head Office", + "MeshNodeId": "node/demo-preview" + } + + +def _replace_job_placeholders(script: str, values: dict[str, str] | None = None) -> str: + rendered = str(script or "") + for key, value in (values or _job_preview_values()).items(): + rendered = rendered.replace("{{" + key + "}}", str(value)) + return rendered + + +def _validate_job_definition(row: JobDefinition, request: Request | None = None) -> str: + def tr(key: str, default: str, **values) -> str: + return _translate_request(request, key, default, **values) if request else default.format(**values) + errors=[]; notes=[] + script=build_registry_user_script(row.configuration) if row.job_kind=='registry' else ((row.inline_script or '') if row.source_type=='inline' else '') + if row.job_kind=='registry' and not (row.configuration or {}).get('registry_entries'): errors.append(tr('jobdefs.validation.registry_empty','At least one registry entry is required.')) + if row.job_kind!='registry' and row.source_type=='inline' and not script.strip(): errors.append(tr('jobdefs.validation.inline_empty','The inline script is empty.')) + if row.source_type=='mounted': + path=Path(row.source_path or '') + if not path.is_absolute(): errors.append(tr('jobdefs.validation.mounted_absolute','The mounted path must be absolute.')) + elif not path.is_file(): errors.append(tr('jobdefs.validation.file_missing','File not found: {path}',path=path)) + else: + script=path.read_text(encoding='utf-8',errors='replace') + notes.append(tr('jobdefs.validation.file_readable','File readable: {path} ({count} characters)',path=path,count=len(script))) + if row.source_type=='unc': + path=str(row.source_path or '') + if not path.startswith('\\\\'): errors.append(tr('jobdefs.validation.unc_prefix','A UNC path must begin with \\\\.')) + else: notes.append(tr('jobdefs.validation.unc_ok','UNC syntax is valid. Reachability is checked on the target device.')) + rendered = _replace_job_placeholders(script) + if rendered and row.interpreter=='python': + try: compile(rendered,'','exec');notes.append(tr('jobdefs.validation.python_ok','Python syntax is valid.')) + except SyntaxError as exc: errors.append(tr('jobdefs.validation.python_error','Python syntax error on line {line}: {message}',line=exc.lineno,message=exc.msg)) + elif rendered and row.interpreter=='bash' and shutil.which('bash'): + with tempfile.NamedTemporaryFile('w',suffix='.sh',delete=False,encoding='utf-8') as f:f.write(rendered);name=f.name + try: + r=subprocess.run(['bash','-n',name],capture_output=True,text=True,timeout=10) + if r.returncode: errors.append(r.stderr.strip()) + else: notes.append(tr('jobdefs.validation.bash_ok','Bash syntax is valid.')) + finally: Path(name).unlink(missing_ok=True) + elif rendered and row.interpreter in {'powershell','cmd'}: notes.append(tr('jobdefs.validation.windows_later','Content is present. Full syntax validation is performed later on a Windows test device.')) + if errors: return 'ERROR\n'+'\n'.join('- '+x for x in errors)+'\n\n'+'\n'.join(x for x in notes if x) + return 'OK\n'+'\n'.join(x for x in notes if x) + + +def _seed_software_inventory_package(db: Session) -> SoftwarePackage: + package = db.query(SoftwarePackage).filter(SoftwarePackage.name == "Software-Inventur").first() + if package is None: + package = SoftwarePackage( + name="Software-Inventur", + description="Liest installierte Software über den vorhandenen MeshCentral-Agenten aus.", + package_type="inventory", + enabled=True, + is_system=True, + callback_timeout_minutes=30, + ) + db.add(package) + db.commit() + db.refresh(package) + return package + + +def _seed_generic_job_package(db: Session) -> SoftwarePackage: + package = db.query(SoftwarePackage).filter(SoftwarePackage.name == "Jobdefinition").first() + if package is None: + package = SoftwarePackage( + name="Jobdefinition", + description="Technisches Systempaket für ausführbare Jobdefinitionen.", + package_type="job_definition", + enabled=True, + is_system=True, + callback_timeout_minutes=30, + ) + db.add(package) + db.commit() + db.refresh(package) + return package + + +@app.on_event("startup") +def startup(): + Base.metadata.create_all(bind=engine) + apply_lightweight_migrations() + from .database import SessionLocal + db = SessionLocal() + try: + _ensure_local_emergency_admin(db) + seed_i18n(db) + _seed_field_definitions(db) + _seed_mesh_field_mappings(db) + _seed_software_inventory_package(db) + _seed_generic_job_package(db) + _seed_job_definitions(db) + backfill_asset_job_states(db) + default_statuses = ["Aktiv", "Defekt", "im Lager", "Ausgegeben", "Verschrottet"] + if db.query(StatusOption).count() == 0: + for order, status_name in enumerate(default_statuses): + db.add(StatusOption(name=status_name, sort_order=order, active=True)) + db.commit() + if not db.query(StatusOption).filter(StatusOption.use_for_issue.is_(True)).first(): + item = db.query(StatusOption).filter(StatusOption.name == "Ausgegeben").first() + if item: item.use_for_issue = True + if not db.query(StatusOption).filter(StatusOption.use_for_return.is_(True)).first(): + item = db.query(StatusOption).filter(StatusOption.name == "im Lager").first() + if item: item.use_for_return = True + db.commit() + if db.query(Category).count() == 0: + computer = Category(name="Computer", description="Computer und Notebooks aus MeshCentral oder manueller Erfassung") + db.add(computer) + db.flush() + for order, field_name in enumerate(COMPUTER_FIELDS): + db.add(CategoryField( + category_id=computer.id, + field_name=field_name, + label=ASSET_FIELDS[field_name], + active=True, + required=field_name == "name", + show_in_list=field_name in {"asset_tag", "name", "hostname", "operating_system", "os_patch_level", "mac_address", "status"}, + sort_order=order, + )) + db.commit() + else: + computer = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.name == "Computer").first() + if computer: + existing = {field.field_name for field in computer.visible_fields} + next_order = max((field.sort_order for field in computer.visible_fields), default=-1) + 1 + important_list_fields = {"hostname", "manufacturer", "model", "serial_number", "operating_system", "ip_address", "mac_address", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group"} + changed = False + for field_name in COMPUTER_FIELDS: + if field_name not in existing: + db.add(CategoryField( + category_id=computer.id, field_name=field_name, label=ASSET_FIELDS[field_name], + active=True, required=False, show_in_list=field_name in important_list_fields, + sort_order=next_order, + )) + next_order += 1 + changed = True + if changed: + db.commit() + _seed_field_definitions(db) + + # Frühere Excel-Importe konnten Datumswerte als + # "YYYY-MM-DD 00:00:00" speichern. Browser zeigen solche Werte in + # leer an. Bestehende System-Datumsfelder werden + # deshalb einmalig auf YYYY-MM-DD normalisiert. + date_repairs = 0 + for asset in db.query(Asset).filter( + (Asset.purchase_date.like("% %")) | + (Asset.purchase_date.like("%T%")) | + (Asset.warranty_until.like("% %")) | + (Asset.warranty_until.like("%T%")) | + (Asset.assigned_on.like("% %")) | + (Asset.assigned_on.like("%T%")) + ).all(): + for field_name in ("purchase_date", "warranty_until", "assigned_on"): + old_value = getattr(asset, field_name, None) + new_value = _normalize_date_value(old_value) + if old_value and new_value and old_value != new_value: + setattr(asset, field_name, new_value) + date_repairs += 1 + if date_repairs: + db.commit() + logger.info("%s ältere Datumswerte auf YYYY-MM-DD normalisiert", date_repairs) + finally: + db.close() + + +@app.on_event("startup") +def start_software_timeout_service(): + global SOFTWARE_TIMEOUT_THREAD + if SOFTWARE_TIMEOUT_THREAD is None or not SOFTWARE_TIMEOUT_THREAD.is_alive(): + SOFTWARE_TIMEOUT_STOP_EVENT.clear() + SOFTWARE_TIMEOUT_THREAD = threading.Thread( + target=_software_job_timeout_loop, + args=(SOFTWARE_TIMEOUT_STOP_EVENT,), + daemon=True, + name="assetmanager-software-timeouts", + ) + SOFTWARE_TIMEOUT_THREAD.start() + + +@app.on_event("shutdown") +def stop_software_timeout_service(): + SOFTWARE_TIMEOUT_STOP_EVENT.set() + + +@app.on_event("startup") +def start_mesh_presence_service(): + global PRESENCE_THREAD + if PRESENCE_THREAD is None or not PRESENCE_THREAD.is_alive(): + PRESENCE_STOP_EVENT.clear() + PRESENCE_THREAD = threading.Thread( + target=mesh_presence_loop, + args=(PRESENCE_STOP_EVENT,), + daemon=True, + name="assetmanager-mesh-presence", + ) + PRESENCE_THREAD.start() + + +@app.on_event("shutdown") +def stop_mesh_presence_service(): + PRESENCE_STOP_EVENT.set() + + + +def delete_asset_image(image_path: str | None) -> None: + """Löscht nur individuell hochgeladene Asset-Bilder aus dem Upload-Ordner.""" + if not image_path or not image_path.startswith("/static/uploads/"): + return + filename = Path(image_path).name + target = UPLOAD_DIR / filename + try: + target.unlink(missing_ok=True) + except OSError: + pass + + +def save_upload(upload: UploadFile | None) -> str | None: + if not upload or not upload.filename: + return None + suffix = Path(upload.filename).suffix.lower() + if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + raise HTTPException(400, "Nur PNG, JPG, WEBP oder GIF sind erlaubt.") + filename = f"{uuid.uuid4().hex}{suffix}" + target = UPLOAD_DIR / filename + with target.open("wb") as buffer: + shutil.copyfileobj(upload.file, buffer) + return f"/static/uploads/{filename}" + + +def _display_value(value) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + import json + return json.dumps(value, ensure_ascii=False, sort_keys=True) + return str(value) + + +def _record_asset_history(db: Session, asset: Asset, changes: dict, source: str = "manual", changed_by: str | None = None) -> None: + if not changes: + return + db.add(AssetHistory(asset_id=asset.id, source=source, changed_by=changed_by, changes=changes)) + + +def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Response: + """Erzeugt eine echte XLSX-Datei und liefert vollständige Bytes statt eines Streams. + + Das verhindert beschädigte Downloads bei einzelnen Reverse-Proxies und Browsern. + """ + workbook = Workbook() + sheet = workbook.active + sheet.title = "Export" + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = f"A1:{sheet.cell(row=1, column=max(1, len(headers))).column_letter}1" + sheet.append(headers) + header_fill = PatternFill("solid", fgColor="D9EAF7") + for cell in sheet[1]: + cell.font = Font(bold=True) + cell.fill = header_fill + cell.alignment = Alignment(vertical="top") + for row in rows: + sheet.append([_display_value(value) for value in row]) + for column in sheet.columns: + max_length = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60) + sheet.column_dimensions[column[0].column_letter].width = max_length + output = io.BytesIO() + workbook.save(output) + payload = output.getvalue() + ascii_name = "".join(ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in filename) + disposition = f"attachment; filename={ascii_name}; filename*=UTF-8''{quote(filename)}" + return Response( + content=payload, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": disposition, + "Content-Length": str(len(payload)), + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }, + ) + + +def _import_field_map(category: Category) -> dict[str, str]: + result: dict[str, str] = {"asset-id": "asset_id", "asset_id": "asset_id", "id": "asset_id", "bezeichnung": "name", "name": "name", "kategorie": "category"} + for field in category.visible_fields: + if field.active and hasattr(Asset, field.field_name): + result[field.label.strip().casefold()] = field.field_name + result[field.field_name.strip().casefold()] = field.field_name + return result + + +def _excel_cell_value(value) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, date): + return value.isoformat() + text = str(value).strip() + return text or None + + +def _normalize_date_value(value: Any) -> str | None: + """Normalisiert Excel-/Datenbankwerte für HTML-Datumsfelder auf YYYY-MM-DD.""" + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + text = str(value).strip() + if not text: + return None + # ISO-Datum oder ISO-Zeitstempel: Der Datumsteil sind die ersten 10 Zeichen. + if re.match(r"^\d{4}-\d{2}-\d{2}(?:[ T].*)?$", text): + return text[:10] + # Deutsche Schreibweise aus manuellen Excel-Dateien. + match = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+.*)?$", text) + if match: + day, month, year = match.groups() + return f"{year}-{int(month):02d}-{int(day):02d}" + return text + + +def _normalize_datetime_value(value: Any) -> str | None: + """Normalisiert Werte für .""" + if value in (None, ""): + return None + if isinstance(value, datetime): + return value.strftime("%Y-%m-%dT%H:%M") + if isinstance(value, date): + return f"{value.isoformat()}T00:00" + text = str(value).strip() + if not text: + return None + text = text.replace(" ", "T") + if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", text): + return text[:16] + if re.match(r"^\d{4}-\d{2}-\d{2}$", text): + return f"{text}T00:00" + return text + + +def _html_field_value(value: Any, data_type: str | None = None) -> str: + if data_type == "date": + return _normalize_date_value(value) or "" + if data_type == "datetime": + return _normalize_datetime_value(value) or "" + return _display_value(value) + + +templates.env.globals["html_field_value"] = _html_field_value + + +def _request_language(request: Request | None) -> str: + if request is None: + return "en" + user = request.session.get("user") or {} + return str(user.get("language_code") or request.session.get("language_code") or load_config().get("general", {}).get("default_language", "en")).lower() + + +def _localized_text_key(value: Any, language_code: str = "en") -> str: + text = str(value or "").strip().casefold() + # German users expect umlauts near their expanded base letters instead of after Z. + if language_code.startswith("de"): + text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") + return "".join(ch for ch in unicodedata.normalize("NFKD", text) if not unicodedata.combining(ch)) + + +def _sort_localized(items: list[Any], attribute: str, request: Request | None) -> list[Any]: + language_code = _request_language(request) + return sorted(items, key=lambda item: _localized_text_key(getattr(item, attribute, ""), language_code)) + + +def _active_status_options(db: Session, request: Request | None = None) -> list[StatusOption]: + items = db.query(StatusOption).filter(StatusOption.active.is_(True)).all() + return _sort_localized(items, "name", request) + + +def _asset_action_changes(asset: Asset, action: str, status_name: str, assigned_to: str | None = None, assigned_on: str | None = None) -> dict[str, dict[str, str]]: + changes: dict[str, dict[str, str]] = {} + if _display_value(asset.status) != status_name: + changes[ASSET_FIELDS["status"]] = {"old": _display_value(asset.status), "new": status_name} + asset.status = status_name + if action == "issue": + new_name = (assigned_to or "").strip() or None + new_date = (assigned_on or date.today().isoformat()).strip() + if _display_value(asset.assigned_to) != _display_value(new_name): + changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": _display_value(new_name)} + asset.assigned_to = new_name + if _display_value(asset.assigned_on) != new_date: + changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": new_date} + asset.assigned_on = new_date + else: + if asset.assigned_to: + changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": ""} + asset.assigned_to = None + if asset.assigned_on: + changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": ""} + asset.assigned_on = None + return changes + + +def _normalize_optional_text(value: str | None) -> str | None: + text = (value or "").strip() + return text or None + + +def _validate_asset_identifiers(db: Session, *, asset_tag: str | None, manufacturer: str | None, serial_number: str | None, exclude_id: int | None = None) -> None: + asset_tag = _normalize_optional_text(asset_tag) + manufacturer = _normalize_optional_text(manufacturer) + serial_number = _normalize_optional_text(serial_number) + if asset_tag: + query = db.query(Asset).filter(Asset.asset_tag == asset_tag) + if exclude_id is not None: + query = query.filter(Asset.id != exclude_id) + if query.first(): + raise HTTPException(400, f"Die Inventarnummer {asset_tag} ist bereits vergeben.") + if manufacturer and serial_number: + query = db.query(Asset).filter(Asset.manufacturer == manufacturer, Asset.serial_number == serial_number) + if exclude_id is not None: + query = query.filter(Asset.id != exclude_id) + if query.first(): + raise HTTPException(400, f"Die Seriennummer {serial_number} ist beim Hersteller {manufacturer} bereits vorhanden.") + + +def _would_create_parent_cycle(db: Session, asset_id: int, parent_id: int | None) -> bool: + seen = {asset_id} + current = parent_id + while current: + if current in seen: + return True + seen.add(current) + parent = db.query(Asset.parent_asset_id).filter(Asset.id == current).scalar() + current = parent + return False + + +def _list_fields_for_categories(categories: list[Category]) -> list[CategoryField]: + """Bildet eine eindeutige, sortierte Vereinigungsmenge der konfigurierten Listenspalten.""" + result: list[CategoryField] = [] + seen: set[str] = set() + for category in categories: + for field in sorted(category.visible_fields, key=lambda item: item.sort_order): + if not field.active or not field.show_in_list or field.field_name in seen: + continue + result.append(field) + seen.add(field.field_name) + return result + + + +CHART_FIELDS = { + "category": "Kategorie", + "status": "Status", + "operating_system": "Betriebssystem", + "manufacturer": "Hersteller", + "model": "Modell", + "location": "Standort", + "room_number": "Raumnummer", + "department": "Abteilung", + "mesh_group": "MeshCentral-Gruppe", + "antivirus": "Antivirus", + "tpm_version": "TPM-Version", +} +CHART_TYPES = { + "donut": "Donutdiagramm", + "pie": "Kuchendiagramm", + "bar": "Balkendiagramm", + "stacked_bar": "Gestapeltes Balkendiagramm", +} + + +def _parse_value_mappings(text_value: str) -> list[dict[str, str]]: + """Liest Regeln im Format Suchtext => Anzeigename (eine Regel je Zeile).""" + mappings: list[dict[str, str]] = [] + for line_number, raw_line in enumerate((text_value or "").splitlines(), start=1): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + separator = "=>" if "=>" in line else "=" if "=" in line else None + if not separator: + raise HTTPException(400, f"Ungültige Wertzuordnung in Zeile {line_number}. Erwartet: Suchtext => Anzeigename") + search, label = (part.strip() for part in line.split(separator, 1)) + if not search or not label: + raise HTTPException(400, f"Unvollständige Wertzuordnung in Zeile {line_number}.") + mappings.append({"search": search, "label": label}) + return mappings + + +def _value_mappings_text(chart: ChartDefinition | None) -> str: + if not chart: + return "" + return "\n".join( + f"{entry.get('search', '')} => {entry.get('label', '')}" + for entry in (chart.value_mappings or []) + if isinstance(entry, dict) and entry.get("search") and entry.get("label") + ) + + +def _automatic_chart_value(field: str, value: str) -> str: + """Fasst technische Rohwerte für Diagramme zusammen, ohne die Assetdaten zu verändern.""" + text = value.strip() + lower = text.casefold() + if field == "operating_system": + if "windows server" in lower: + match = re.search(r"windows server\s*(20\d{2})", text, re.IGNORECASE) + return f"Windows Server {match.group(1)}" if match else "Windows Server" + if "windows 11" in lower: + return "Windows 11" + if "windows 10" in lower: + return "Windows 10" + if "ubuntu" in lower: + return "Ubuntu" + if "debian" in lower: + return "Debian" + if "red hat" in lower or "rhel" in lower: + return "Red Hat Enterprise Linux" + if "centos" in lower: + return "CentOS" + if "fedora" in lower: + return "Fedora" + if "macos" in lower or "mac os" in lower: + return "macOS" + if "linux" in lower: + return "Linux" + if field == "manufacturer": + if lower.startswith("hewlett") or lower in {"hp", "hp inc.", "hp inc"}: + return "HP" + if lower.startswith("dell"): + return "Dell" + if lower.startswith("lenovo"): + return "Lenovo" + if lower.startswith("microsoft"): + return "Microsoft" + return text + + +def _transform_chart_value(chart: ChartDefinition, field: str, value: str) -> str: + # Eigene Regeln haben Vorrang; gesucht wird ohne Beachtung der Groß-/Kleinschreibung. + lower = value.casefold() + for entry in chart.value_mappings or []: + if not isinstance(entry, dict): + continue + search = str(entry.get("search") or "").strip() + label = str(entry.get("label") or "").strip() + if search and label and search.casefold() in lower: + return label + if (chart.value_mode or "raw") == "automatic": + return _automatic_chart_value(field, value) + return value + + +def _chart_value(asset: Asset, chart: ChartDefinition, field: str, show_empty: bool) -> str | None: + if field == "category": + value = asset.category.name if asset.category else None + else: + value = getattr(asset, field, None) + if isinstance(value, str): + value = value.strip() + if value in (None, ""): + return "Nicht angegeben" if show_empty else None + return _transform_chart_value(chart, field, str(value)) + +def _chart_payload(chart: ChartDefinition, assets: list[Asset]) -> dict: + grouped: dict[str, dict[str, int] | int] = {} + stack_values: set[str] = set() + excluded_statuses = {str(value).casefold() for value in (chart.excluded_statuses or []) if value not in (None, "")} + for asset in assets: + if excluded_statuses and str(asset.status or "").casefold() in excluded_statuses: + continue + group = _chart_value(asset, chart, chart.group_field, chart.show_empty) + if group is None: + continue + if chart.chart_type == "stacked_bar" and chart.stack_field: + stack = _chart_value(asset, chart, chart.stack_field, chart.show_empty) + if stack is None: + continue + stack_values.add(stack) + bucket = grouped.setdefault(group, {}) + bucket[stack] = bucket.get(stack, 0) + 1 + else: + grouped[group] = int(grouped.get(group, 0)) + 1 + def total(item): + value = item[1] + return sum(value.values()) if isinstance(value, dict) else value + items = list(grouped.items()) + if chart.sort_mode == "alpha": + items.sort(key=lambda item: item[0].casefold()) + else: + items.sort(key=total, reverse=True) + limit = max(1, min(int(chart.max_items or 12), 50)) + if len(items) > limit and chart.chart_type != "stacked_bar": + kept, rest = items[:limit-1], items[limit-1:] + kept.append(("Sonstige", sum(int(v) for _, v in rest))) + items = kept + else: + items = items[:limit] + maximum = max((total(item) for item in items), default=1) + total_count = sum(total(item) for item in items) + donut_stops = [] + current = 0.0 + for index, (_, value) in enumerate(items): + count = total(("", value)) + end = current + (count / total_count * 100 if total_count else 0) + donut_stops.append(f"var(--chart-color-{index % 12}) {current:.2f}% {end:.2f}%") + current = end + return { + "chart": chart, + "items": items, + "stack_values": sorted(stack_values, key=str.casefold), + "maximum": maximum, + "total": total_count, + "donut_gradient": ", ".join(donut_stops) or "#e5e7eb 0 100%", + } + + +def _seed_default_charts(db: Session) -> None: + defaults = [ + {"name": "Betriebssysteme", "chart_type": "donut", "group_field": "operating_system", "value_mode": "automatic", "dashboard_visible": True, "max_items": 10}, + {"name": "Geräte nach Kategorie und Status", "chart_type": "stacked_bar", "group_field": "category", "stack_field": "status", "dashboard_visible": True, "max_items": 15}, + {"name": "Hersteller", "chart_type": "bar", "group_field": "manufacturer", "dashboard_visible": False, "max_items": 10}, + {"name": "Statusübersicht", "chart_type": "donut", "group_field": "status", "dashboard_visible": False, "max_items": 10}, + ] + changed = False + for values in defaults: + chart = db.query(ChartDefinition).filter(ChartDefinition.name == values["name"]).first() + if chart is None: + chart = ChartDefinition(**values, is_system=True) + db.add(chart) + changed = True + elif not chart.is_system: + chart.is_system = True + changed = True + if changed: + db.commit() + + +@app.get("/") +def dashboard(request: Request, db: Session = Depends(get_db)): + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + assets = ( + _apply_asset_access(db.query(Asset), request) + .options(joinedload(Asset.category).joinedload(Category.visible_fields)) + .order_by(Asset.updated_at.desc()) + .limit(10) + .all() + ) + recent_categories = [] + seen_category_ids = set() + for asset in assets: + if asset.category_id not in seen_category_ids: + recent_categories.append(asset.category) + seen_category_ids.add(asset.category_id) + fields = _list_fields_for_categories(recent_categories) + _seed_default_charts(db) + chart_assets = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category)).all() + dashboard_charts = [_chart_payload(chart, chart_assets) for chart in db.query(ChartDefinition).filter(ChartDefinition.dashboard_visible.is_(True)).order_by(ChartDefinition.id).all()] + return templates.TemplateResponse( + "dashboard.html", + {"request": request, "categories": categories, "assets": assets, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), "dashboard_charts": dashboard_charts}, + ) + + +BULK_EDIT_EXCLUDED_FIELDS = {"asset_tag", "serial_number", "mesh_node_id"} + +def _bulk_edit_fields_for_assets(assets: list[Asset]) -> list[dict]: + """Return active fields shared by all selected asset categories.""" + if not assets: + return [] + category_maps = [] + for asset in assets: + fields = { + field.field_name: _definition_label(field) + for field in asset.category.visible_fields + if field.active and hasattr(asset, field.field_name) and field.field_name not in BULK_EDIT_EXCLUDED_FIELDS and not _definition_readonly(field) and (not field.definition or field.definition.bulk_edit) + } + category_maps.append(fields) + common = set(category_maps[0]) + for item in category_maps[1:]: + common &= set(item) + return [{"field_name": name, "label": category_maps[0][name]} for name in sorted(common, key=lambda n: category_maps[0][n].casefold())] + + +def _selected_asset_ids(values: list[str]) -> list[int]: + result = [] + for value in values: + try: + number = int(value) + except (TypeError, ValueError): + continue + if number > 0 and number not in result: + result.append(number) + return result + + + +ASSET_JOB_FILTER_STATUSES = ( + "never_run", + "created", + "sending", + "sent", + "running", + "success", + "partial", + "failed", + "timeout", + "cancelled", +) + + +def _resolve_asset_job_filter(db: Session, selection: str) -> dict[str, Any] | None: + selection = str(selection or "").strip() + if selection == "software_inventory": + return { + "selection": selection, + "state_key": filter_state_key("software_inventory"), + "job_type": "software_inventory", + "definition": None, + "definition_id": None, + "current_revision": None, + "label": "Softwareinventur", + "label_key": "jobs.type.software_inventory", + } + if not selection.startswith("definition:"): + return None + try: + definition_id = int(selection.split(":", 1)[1]) + except (TypeError, ValueError): + return None + definition = db.get(JobDefinition, definition_id) + if not definition: + return None + return { + "selection": selection, + "state_key": filter_state_key("job_definition", definition.id), + "job_type": "job_definition", + "definition": definition, + "definition_id": definition.id, + "current_revision": int(definition.revision or 0), + "label": definition.name, + "label_key": None, + } + + +def _normalize_job_filter_selections(value: Any) -> list[str]: + """Normalize repeated query/form values while preserving their order.""" + if value is None: + raw_values: list[Any] = [] + elif isinstance(value, (list, tuple, set)): + raw_values = list(value) + else: + raw_values = [value] + + result: list[str] = [] + for raw in raw_values: + for item in str(raw or "").split(","): + selection = item.strip() + if not selection or selection in result: + continue + if selection == "software_inventory" or re.fullmatch(r"definition:\d+", selection): + result.append(selection) + return result + + +def _legacy_job_filter_exclusions(condition: str) -> list[str]: + """Preserve links generated by 0.5.5.17 while switching to exclusions.""" + include_by_condition = { + "successful": {"success"}, + "not_successful": set(ASSET_JOB_FILTER_STATUSES) - {"success"}, + "never_run": {"never_run"}, + "last_failed": {"failed"}, + "last_partial": {"partial"}, + "timeout": {"timeout"}, + "callback_pending": {"sent"}, + "active": {"created", "sending", "running"}, + } + included = include_by_condition.get(str(condition or "").strip()) + if included is None: + return [] + return [status for status in ASSET_JOB_FILTER_STATUSES if status not in included] + + +def _normalize_job_filter_exclusions(value: Any) -> list[str]: + """Normalize repeated form/query values or a comma-separated status list.""" + if value is None: + raw_values: list[Any] = [] + elif isinstance(value, (list, tuple, set)): + raw_values = list(value) + else: + raw_values = [value] + + requested: set[str] = set() + for raw in raw_values: + for item in str(raw or "").split(","): + status = item.strip().lower() + if status in ASSET_JOB_FILTER_STATUSES: + requested.add(status) + return [status for status in ASSET_JOB_FILTER_STATUSES if status in requested] + + +def _normalize_asset_job_filter( + db: Session, + *, + selection: Any, + excluded_statuses: Any = None, + revision_mode: str = "all", +) -> dict[str, Any] | None: + selections = _normalize_job_filter_selections(selection) + specs: list[dict[str, Any]] = [] + state_keys: set[str] = set() + for selected_value in selections: + spec = _resolve_asset_job_filter(db, selected_value) + if spec is None or spec["state_key"] in state_keys: + continue + specs.append(spec) + state_keys.add(spec["state_key"]) + if not specs: + return None + + revision_mode = "current" if str(revision_mode or "").strip() == "current" else "all" + if not any(spec["job_type"] == "job_definition" for spec in specs): + revision_mode = "all" + return { + "selections": [spec["selection"] for spec in specs], + "specs": specs, + "state_keys": [spec["state_key"] for spec in specs], + "labels": [spec["label"] for spec in specs], + "startable_specs": [spec for spec in specs if spec.get("definition") is None or spec["definition"].enabled], + "excluded_statuses": _normalize_job_filter_exclusions(excluded_statuses), + "revision_mode": revision_mode, + } + + +def _asset_job_filter_url(job_filter: dict[str, Any], category_id: int | None = None, **toast: str) -> str: + params: list[tuple[str, Any]] = [] + params.extend(("job_filter", selection) for selection in job_filter.get("selections") or []) + params.append(("job_filter_revision", job_filter["revision_mode"])) + params.extend(("job_filter_exclude", status) for status in job_filter.get("excluded_statuses") or []) + if category_id: + params.append(("category_id", category_id)) + params.extend((key, value) for key, value in toast.items() if value) + return "/assets?" + urllib.parse.urlencode(params, doseq=True) + + +def _asset_job_filter_view(state: AssetJobState | None, job_filter: dict[str, Any] | None = None) -> dict[str, Any]: + effective_never_run = state is None + if state is not None and job_filter and job_filter.get("revision_mode") == "current": + current_revision = job_filter.get("current_revision") + effective_never_run = current_revision is not None and state.definition_revision != current_revision + if state is None: + return { + "status": "never_run", + "last_execution": None, + "last_success": None, + "definition_revision": None, + "last_success_revision": None, + "execution_count": 0, + } + return { + "status": "never_run" if effective_never_run else (state.last_status or "created"), + "last_execution": state.last_started_at or state.last_sent_at or state.last_status_at or state.last_created_at, + "last_success": state.last_success_at, + "definition_revision": state.definition_revision, + "last_success_revision": state.last_success_revision, + "execution_count": int(state.execution_count or 0), + } + + +def _combined_asset_job_filter_view( + states_by_key: dict[str, AssetJobState], + job_filter: dict[str, Any], +) -> dict[str, Any]: + """Combine alternative selected jobs using the most recently executed state. + + This is intentional for cases such as the generic software inventory job + and platform-specific inventory definitions: an asset counts as successful + when its latest execution among the selected alternatives was successful. + """ + candidates: list[tuple[datetime, dict[str, Any]]] = [] + latest_success: datetime | None = None + total_executions = 0 + + for spec in job_filter.get("specs") or []: + state = states_by_key.get(spec["state_key"]) + view = _asset_job_filter_view( + state, + { + "revision_mode": job_filter.get("revision_mode"), + "current_revision": spec.get("current_revision"), + }, + ) + total_executions += int(view.get("execution_count") or 0) + if state is not None: + success_is_current = True + if job_filter.get("revision_mode") == "current" and spec.get("current_revision") is not None: + success_is_current = state.last_success_revision == spec.get("current_revision") + if success_is_current and state.last_success_at and (latest_success is None or state.last_success_at > latest_success): + latest_success = state.last_success_at + if view["status"] == "never_run": + continue + timestamp = view.get("last_execution") or (state.updated_at if state is not None else None) or datetime.min + view.update({ + "job_label": spec["label"], + "job_selection": spec["selection"], + }) + candidates.append((timestamp, view)) + + if not candidates: + return { + "status": "never_run", + "last_execution": None, + "last_success": latest_success, + "definition_revision": None, + "last_success_revision": None, + "execution_count": total_executions, + "job_label": None, + "job_selection": None, + } + + result = max(candidates, key=lambda item: item[0])[1] + result["last_success"] = latest_success + result["execution_count"] = total_executions + return result + + +def _filter_assets_by_job_states( + db: Session, + assets: list[Asset], + job_filter: dict[str, Any], +) -> tuple[list[Asset], dict[int, dict[str, Any]]]: + if not assets: + return [], {} + asset_ids = [asset.id for asset in assets] + rows = ( + db.query(AssetJobState) + .filter( + AssetJobState.asset_id.in_(asset_ids), + AssetJobState.state_key.in_(job_filter.get("state_keys") or [""]), + ) + .all() + ) + states_by_asset: dict[int, dict[str, AssetJobState]] = {} + for state in rows: + states_by_asset.setdefault(state.asset_id, {})[state.state_key] = state + + excluded = set(job_filter.get("excluded_statuses") or []) + filtered_assets: list[Asset] = [] + views: dict[int, dict[str, Any]] = {} + for asset in assets: + view = _combined_asset_job_filter_view(states_by_asset.get(asset.id, {}), job_filter) + if view["status"] in excluded: + continue + filtered_assets.append(asset) + views[asset.id] = view + return filtered_assets, views + + +@app.get("/assets") +def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + categories = ( + db.query(Category) + .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) + .order_by(Category.name) + .all() + ) + + requested_exclusions = request.query_params.getlist("job_filter_exclude") + if not requested_exclusions and request.query_params.get("job_filter_condition"): + requested_exclusions = _legacy_job_filter_exclusions(request.query_params.get("job_filter_condition", "")) + job_filter = _normalize_asset_job_filter( + db, + selection=request.query_params.getlist("job_filter"), + excluded_statuses=requested_exclusions, + revision_mode=request.query_params.get("job_filter_revision", "all"), + ) + + query = _apply_asset_access(db.query(Asset), request) + selected = None + if category_id: + selected = next((category for category in categories if category.id == category_id), None) + if not selected: + raise HTTPException(404, "Kategorie nicht gefunden") + query = query.filter(Asset.category_id == category_id) + + assets = ( + query + .options(joinedload(Asset.category)) + .order_by(func.lower(Asset.name), Asset.id) + .all() + ) + job_state_by_asset: dict[int, dict[str, Any]] = {} + if job_filter: + assets, job_state_by_asset = _filter_assets_by_job_states(db, assets, job_filter) + + fields = ( + [field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] + if selected + else _list_fields_for_categories(categories) + ) + issue_status = _action_status(db, "issue") + enabled_job_definitions = ( + db.query(JobDefinition) + .filter(JobDefinition.enabled.is_(True)) + .order_by(func.lower(JobDefinition.name), JobDefinition.id) + .all() + if _is_admin(request) + else [] + ) + filter_job_definitions = ( + db.query(JobDefinition) + .filter(JobDefinition.enabled.is_(True)) + .order_by(func.lower(JobDefinition.name), JobDefinition.id) + .all() + if _is_admin(request) + else [] + ) + return templates.TemplateResponse( + "assets.html", + { + "request": request, + "assets": assets, + "categories": categories, + "selected": selected, + "fields": fields, + "is_admin": _is_admin(request), + "issue_status_name": issue_status.name if issue_status else None, + "total_assets": len(assets), + "job_definitions": enabled_job_definitions, + "job_filter_definitions": filter_job_definitions, + "job_filter": job_filter, + "job_filter_statuses": ASSET_JOB_FILTER_STATUSES, + "job_state_by_asset": job_state_by_asset, + "asset_platforms": {asset.id: detect_platform(asset) for asset in assets}, + }, + ) + + +@app.get("/charts") +def charts_page(request: Request, db: Session = Depends(get_db)): + _seed_default_charts(db) + assets = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category)).all() + charts = [_chart_payload(chart, assets) for chart in db.query(ChartDefinition).order_by(ChartDefinition.id).all()] + return templates.TemplateResponse("charts.html", {"request": request, "charts": charts, "is_admin": _is_admin(request)}) + + +@app.get("/charts/manage") +def charts_manage(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + _seed_default_charts(db) + charts = db.query(ChartDefinition).order_by(ChartDefinition.name).all() + return templates.TemplateResponse("charts_manage.html", {"request": request, "charts": charts, "chart_fields": CHART_FIELDS, "chart_types": CHART_TYPES}) + + +def _chart_form_context(request: Request, db: Session, chart: ChartDefinition | None = None) -> dict[str, Any]: + language_code = _request_language(request) + statuses = db.query(StatusOption).filter(StatusOption.active.is_(True)).all() + statuses.sort(key=lambda item: _localized_text_key(item.name, language_code)) + return { + "request": request, + "chart": chart, + "chart_fields": CHART_FIELDS, + "chart_types": CHART_TYPES, + "value_mappings_text": _value_mappings_text(chart), + "status_options": statuses, + "excluded_statuses": set(chart.excluded_statuses or []) if chart else set(), + } + + +@app.get("/charts/new") +def chart_new(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + return templates.TemplateResponse("chart_form.html", _chart_form_context(request, db)) + + +@app.post("/charts/new") +def chart_create(request: Request, name: str = Form(...), description: str = Form(""), chart_type: str = Form("bar"), group_field: str = Form("category"), stack_field: str = Form(""), dashboard_visible: str | None = Form(None), show_empty: str | None = Form(None), max_items: int = Form(12), sort_mode: str = Form("count_desc"), value_mode: str = Form("raw"), value_mappings_text: str = Form(""), excluded_statuses: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + if chart_type not in CHART_TYPES or group_field not in CHART_FIELDS: + raise HTTPException(400, "Ungültige Diagrammkonfiguration") + if chart_type == "stacked_bar" and stack_field not in CHART_FIELDS: + raise HTTPException(400, "Für ein gestapeltes Diagramm ist eine gültige Unterteilung erforderlich.") + if value_mode not in {"raw", "automatic", "custom"}: + raise HTTPException(400, "Ungültige Wertedarstellung") + mappings = _parse_value_mappings(value_mappings_text) + valid_statuses = {row.name for row in db.query(StatusOption).all()} + selected_statuses = [value for value in excluded_statuses if value in valid_statuses] + chart = ChartDefinition(name=name.strip(), description=description.strip() or None, chart_type=chart_type, group_field=group_field, stack_field=stack_field if chart_type == "stacked_bar" else None, dashboard_visible=dashboard_visible == "on", show_empty=show_empty == "on", max_items=max(1, min(max_items, 50)), sort_mode=sort_mode, value_mode=value_mode, value_mappings=mappings, excluded_statuses=selected_statuses, created_by=_changed_by(request), is_system=False) + db.add(chart) + try: + db.commit() + except IntegrityError: + db.rollback(); raise HTTPException(400, "Ein Diagramm mit diesem Namen existiert bereits.") + return RedirectResponse("/charts/manage?toast_success=Diagramm angelegt", status_code=303) + + +@app.get("/charts/{chart_id}/edit") +def chart_edit(chart_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + chart = db.get(ChartDefinition, chart_id) + if not chart: raise HTTPException(404, "Diagramm nicht gefunden") + return templates.TemplateResponse("chart_form.html", _chart_form_context(request, db, chart)) + + +@app.post("/charts/{chart_id}/edit") +def chart_update(chart_id: int, request: Request, name: str = Form(...), description: str = Form(""), chart_type: str = Form("bar"), group_field: str = Form("category"), stack_field: str = Form(""), dashboard_visible: str | None = Form(None), show_empty: str | None = Form(None), max_items: int = Form(12), sort_mode: str = Form("count_desc"), value_mode: str = Form("raw"), value_mappings_text: str = Form(""), excluded_statuses: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + chart = db.get(ChartDefinition, chart_id) + if not chart: raise HTTPException(404, "Diagramm nicht gefunden") + if chart_type not in CHART_TYPES or group_field not in CHART_FIELDS or (chart_type == "stacked_bar" and stack_field not in CHART_FIELDS): + raise HTTPException(400, "Ungültige Diagrammkonfiguration") + if value_mode not in {"raw", "automatic", "custom"}: + raise HTTPException(400, "Ungültige Wertedarstellung") + mappings = _parse_value_mappings(value_mappings_text) + valid_statuses = {row.name for row in db.query(StatusOption).all()} + chart.name=name.strip(); chart.description=description.strip() or None; chart.chart_type=chart_type; chart.group_field=group_field; chart.stack_field=stack_field if chart_type == "stacked_bar" else None; chart.dashboard_visible=dashboard_visible == "on"; chart.show_empty=show_empty == "on"; chart.max_items=max(1,min(max_items,50)); chart.sort_mode=sort_mode; chart.value_mode=value_mode; chart.value_mappings=mappings; chart.excluded_statuses=[value for value in excluded_statuses if value in valid_statuses] + try: + db.commit() + except IntegrityError: + db.rollback(); raise HTTPException(400, "Ein Diagramm mit diesem Namen existiert bereits.") + return RedirectResponse("/charts/manage?toast_success=Diagramm gespeichert", status_code=303) + + +@app.post("/charts/{chart_id}/duplicate") +def chart_duplicate(chart_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + source = db.get(ChartDefinition, chart_id) + if not source: raise HTTPException(404, "Diagramm nicht gefunden") + base_name = f"{source.name} (Kopie)" + name = base_name + number = 2 + while db.query(ChartDefinition).filter(ChartDefinition.name == name).first(): + name = f"{source.name} (Kopie {number})" + number += 1 + duplicate = ChartDefinition(name=name, description=source.description, chart_type=source.chart_type, group_field=source.group_field, stack_field=source.stack_field, dashboard_visible=False, show_empty=source.show_empty, max_items=source.max_items, sort_mode=source.sort_mode, value_mode=source.value_mode, value_mappings=list(source.value_mappings or []), excluded_statuses=list(source.excluded_statuses or []), created_by=_changed_by(request), is_system=False) + db.add(duplicate); db.commit(); db.refresh(duplicate) + return RedirectResponse(f"/charts/{duplicate.id}/edit?toast_success=Diagramm dupliziert", status_code=303) + + +@app.post("/charts/{chart_id}/delete") +def chart_delete(chart_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + chart=db.get(ChartDefinition, chart_id) + if not chart: raise HTTPException(404, "Diagramm nicht gefunden") + if chart.is_system: + return RedirectResponse("/charts/manage?toast_warning=Standarddiagramme können bearbeitet, aber nicht gelöscht werden", status_code=303) + db.delete(chart); db.commit() + return RedirectResponse("/charts/manage?toast_success=Diagramm gelöscht", status_code=303) + + +@app.get("/assets/new") +def asset_new(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None) + return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}}) + + +@app.get("/assets/{asset_id}/duplicate") +def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + original = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() + if not original: + raise HTTPException(404, "Asset nicht gefunden") + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + class DuplicateValues: + pass + duplicate = DuplicateValues() + for field_name in ASSET_FIELDS: + setattr(duplicate, field_name, getattr(original, field_name, None)) + duplicate.asset_tag = None + duplicate.mesh_node_id = None + duplicate.name = f"{original.name} – Kopie" + return templates.TemplateResponse("asset_form.html", { + "request": request, "asset": duplicate, "categories": categories, "selected": original.category, + "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": True, "parent_candidates": db.query(Asset).filter(Asset.id != original.id).order_by(Asset.name).all(), "custom_values": {}, + }) + + +@app.post("/assets/new") +async def asset_create(request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + data = {f.field_name: form.get(f.field_name) or None for f in category.visible_fields if f.active and hasattr(Asset, f.field_name) and f.field_name != "parent_asset_id" and not _definition_readonly(f)} + for field in category.visible_fields: + if field.field_name not in data or not field.definition: + continue + if field.definition.data_type == "date": + data[field.field_name] = _normalize_date_value(data[field.field_name]) + elif field.definition.data_type == "datetime": + data[field.field_name] = _normalize_datetime_value(data[field.field_name]) + data["room_number"] = _normalize_optional_text(form.get("room_number")) + parent_raw = _normalize_optional_text(form.get("parent_asset_id")) + data["parent_asset_id"] = int(parent_raw) if parent_raw else None + if not data.get("name"): + raise HTTPException(400, "Bezeichnung fehlt") + _validate_asset_identifiers(db, asset_tag=data.get("asset_tag"), manufacturer=data.get("manufacturer"), serial_number=data.get("serial_number")) + asset = Asset(category_id=category_id, image_path=save_upload(image), **data) + db.add(asset) + try: + db.flush() + for field in category.visible_fields: + if field.active and field.definition and not field.definition.is_system and not field.definition.readonly: + _custom_value_set(asset, field.definition, form.get(field.field_name), db) + created_changes = {_definition_label(field): {"old": "", "new": _display_value(getattr(asset, field.field_name, None))} for field in category.visible_fields if field.active and hasattr(asset, field.field_name) and getattr(asset, field.field_name, None) not in (None, "")} + _record_asset_history(db, asset, created_changes, source="manual-create", changed_by=_changed_by(request)) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc + return RedirectResponse(f"/assets/{asset.id}", status_code=303) + + +@app.get("/assets/import") +def assets_import_form(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + categories = _sort_localized(db.query(Category).options(joinedload(Category.visible_fields)).all(), "name", request) + selected = next((category for category in categories if category.id == category_id), categories[0] if categories else None) + return templates.TemplateResponse( + "assets_import.html", + {"request": request, "categories": categories, "selected": selected, "result": None, "import_only_active_fields": False}, + ) + + +@app.get("/assets/import/template.xlsx") +def assets_import_template(request: Request, category_id: int, db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + + definitions = ( + db.query(FieldDefinition) + .filter( + FieldDefinition.is_active.is_(True), + FieldDefinition.excel_import.is_(True), + FieldDefinition.readonly.is_(False), + ) + .order_by(FieldDefinition.sort_order, FieldDefinition.label) + .all() + ) + import_definitions = [definition for definition in definitions if definition.field_name != "name"] + headers = ["Asset-ID", "Kategorie", "Bezeichnung"] + [definition.label for definition in import_definitions] + return _xlsx_response(f"asset-import-{category.name}.xlsx", headers, []) + + +@app.post("/assets/import") +async def assets_import( + request: Request, + category_id: int = Form(...), + file: UploadFile = File(...), + duplicate_action: str = Form("skip"), + import_only_active_fields: str | None = Form(None), + import_mode: str = Form("import"), + db: Session = Depends(get_db), +): + _require_admin(request) + dry_run = import_mode == "dry_run" + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + categories = _sort_localized(db.query(Category).options(joinedload(Category.visible_fields)).all(), "name", request) + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + if not file.filename or Path(file.filename).suffix.lower() != ".xlsx": + raise HTTPException(400, "Bitte eine XLSX-Datei auswählen.") + try: + raw = await file.read() + workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) + sheet = workbook.active + except Exception as exc: + logger.exception("Excel-Import konnte nicht geöffnet werden") + raise HTTPException(400, "Die Excel-Datei ist ungültig oder beschädigt.") from exc + + rows_iter = sheet.iter_rows(values_only=True) + header_row = next(rows_iter, None) + if not header_row: + raise HTTPException(400, "Die Excel-Datei enthält keine Kopfzeile.") + # Excel columns are recognized from the global field catalog. Category + # visibility is only applied when the optional checkbox is enabled. + import_definitions = ( + db.query(FieldDefinition) + .filter( + FieldDefinition.is_active.is_(True), + FieldDefinition.excel_import.is_(True), + FieldDefinition.readonly.is_(False), + ) + .order_by(FieldDefinition.sort_order, FieldDefinition.label) + .all() + ) + definitions_by_name = {definition.field_name: definition for definition in import_definitions} + field_map: dict[str, str] = { + "asset-id": "asset_id", + "asset_id": "asset_id", + "id": "asset_id", + "bezeichnung": "name", + "name": "name", + "kategorie": "category", + "category": "category", + "category_id": "category", + } + for definition in import_definitions: + field_map[definition.label.strip().casefold()] = definition.field_name + field_map[definition.field_name.strip().casefold()] = definition.field_name + categories_by_id = {item.id: item for item in categories} + categories_by_name = {item.name.strip().casefold(): item for item in categories} + columns: list[str | None] = [] + unknown_headers: list[str] = [] + for header in header_row: + label = _excel_cell_value(header) or "" + field_name = field_map.get(label.casefold()) + columns.append(field_name) + if label and not field_name: + unknown_headers.append(label) + if "name" not in columns: + raise HTTPException(400, "Die Spalte ‚Bezeichnung‘ bzw. ‚Name‘ fehlt.") + + created = 0 + updated = 0 + skipped = 0 + errors: list[str] = [] + row_results: list[dict[str, Any]] = [] + changed_by = _changed_by(request) + for excel_row_number, values in enumerate(rows_iter, start=2): + raw_data: dict[str, str | None] = {} + for index, value in enumerate(values): + if index >= len(columns): + break + field_name = columns[index] + if field_name: + raw_data[field_name] = _excel_cell_value(value) + if not any(value not in (None, "") for value in raw_data.values()): + continue + + asset_id_value = raw_data.pop("asset_id", None) + category_value = raw_data.pop("category", None) + category_was_supplied = category_value not in (None, "") + + row_category = category + if category_was_supplied: + try: + row_category = categories_by_id.get(int(category_value)) + except (TypeError, ValueError): + row_category = categories_by_name.get(str(category_value).strip().casefold()) + if row_category is None: + message = f"Kategorie ‚{category_value}‘ wurde nicht gefunden" + errors.append(f"Zeile {excel_row_number}: {message}") + row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message}) + continue + + if import_only_active_fields == "on": + allowed_fields = { + field.field_name + for field in row_category.visible_fields + if field.active + and field.definition + and field.definition.is_active + and field.definition.excel_import + and not field.definition.readonly + } | {"name"} + else: + allowed_fields = set(definitions_by_name) | {"name"} + + # Leere Zellen werden bewusst nicht in Updates übernommen. + data = { + name: value + for name, value in raw_data.items() + if name in allowed_fields and value not in (None, "") + } + for field_name, value in list(data.items()): + definition = definitions_by_name.get(field_name) + if definition and definition.data_type == "date": + data[field_name] = _normalize_date_value(value) + elif definition and definition.data_type == "datetime": + data[field_name] = _normalize_datetime_value(value) + + system_data = {name: value for name, value in data.items() if hasattr(Asset, name)} + custom_data = { + name: value + for name, value in data.items() + if name in definitions_by_name and not definitions_by_name[name].is_system + } + + if asset_id_value not in (None, ""): + try: + parsed_asset_id = int(str(asset_id_value).strip()) + except (TypeError, ValueError): + message = f"Asset-ID ‚{asset_id_value}‘ ist ungültig" + errors.append(f"Zeile {excel_row_number}: {message}") + row_results.append({"row": excel_row_number, "asset_id": str(asset_id_value or ""), "action": "Fehler", "reason": message}) + continue + + existing = db.query(Asset).filter(Asset.id == parsed_asset_id).first() + if existing is None: + message = f"Asset-ID {parsed_asset_id} wurde nicht gefunden" + errors.append(f"Zeile {excel_row_number}: {message}") + row_results.append({"row": excel_row_number, "asset_id": parsed_asset_id, "action": "Fehler", "reason": message}) + continue + + changes: dict[str, dict[str, str]] = {} + if category_was_supplied and existing.category_id != row_category.id: + changes["Kategorie"] = { + "old": existing.category.name if existing.category else str(existing.category_id), + "new": row_category.name, + } + existing.category_id = row_category.id + + for field_name, value in system_data.items(): + old_value = getattr(existing, field_name, None) + if _display_value(old_value) != _display_value(value): + definition = definitions_by_name.get(field_name) + label = definition.label if definition else ASSET_FIELDS.get(field_name, field_name) + changes[label] = { + "old": _display_value(old_value), + "new": _display_value(value), + } + setattr(existing, field_name, value) + + for field_name, value in custom_data.items(): + definition = definitions_by_name[field_name] + old_value = _custom_value_get(existing, definition, db) + if _display_value(old_value) != _display_value(value): + changes[definition.label] = { + "old": _display_value(old_value), + "new": _display_value(value), + } + _custom_value_set(existing, definition, value, db) + + if changes: + _record_asset_history( + db, + existing, + changes, + source="excel-import-update", + changed_by=changed_by, + ) + updated += 1 + changed_labels = ", ".join(changes.keys()) + row_results.append({ + "row": excel_row_number, + "asset_id": existing.id, + "action": "Aktualisiert", + "reason": f"Geänderte Felder: {changed_labels}", + }) + else: + skipped += 1 + row_results.append({ + "row": excel_row_number, + "asset_id": existing.id, + "action": "Übersprungen", + "reason": "Keine Änderungen; alle ausgefüllten Werte entsprechen bereits dem Datensatz.", + }) + continue + + # Ohne Asset-ID wird immer ein neuer Datensatz erzeugt. + if not system_data.get("name"): + message = "Für ein neues Asset fehlt die Bezeichnung" + errors.append(f"Zeile {excel_row_number}: {message}") + row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message}) + continue + + try: + with db.begin_nested(): + asset = Asset(category_id=row_category.id, **system_data) + db.add(asset) + db.flush() + for field_name, value in custom_data.items(): + _custom_value_set(asset, definitions_by_name[field_name], value, db) + created_changes = { + "Kategorie": {"old": "", "new": row_category.name}, + **{ + (definitions_by_name[name].label if name in definitions_by_name else ASSET_FIELDS.get(name, name)): {"old": "", "new": _display_value(value)} + for name, value in data.items() + }, + } + _record_asset_history( + db, + asset, + created_changes, + source="excel-import-create", + changed_by=changed_by, + ) + created += 1 + row_results.append({ + "row": excel_row_number, + "asset_id": asset.id, + "action": "Neu angelegt", + "reason": f"Neues Asset in Kategorie {row_category.name}", + }) + except IntegrityError: + message = ( + "Das neue Asset verletzt eine Eindeutigkeitsregel " + "(zum Beispiel Inventarnummer, Seriennummer oder MeshCentral Node-ID)" + ) + errors.append(f"Zeile {excel_row_number}: {message}") + row_results.append({"row": excel_row_number, "asset_id": "", "action": "Fehler", "reason": message}) + continue + if dry_run: + # Flushes and nested savepoints above provide the same validation as a + # real import. The outer transaction is rolled back so no asset, + # custom field value or history entry remains stored. + db.rollback() + else: + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Der Import konnte wegen doppelter eindeutiger Werte nicht gespeichert werden.") from exc + result = { + "created": created, + "updated": updated, + "skipped": skipped, + "errors": errors, + "unknown_headers": unknown_headers, + "row_results": row_results, + "dry_run": dry_run, + } + return templates.TemplateResponse( + "assets_import.html", + {"request": request, "categories": categories, "selected": category, "result": result, "import_only_active_fields": import_only_active_fields == "on", "import_mode": import_mode}, + ) + + + +@app.get("/asset-tree") +def asset_tree( + request: Request, + root_id: str | None = None, + name_filter: str = "", + hide_without_parent: str | None = None, + db: Session = Depends(get_db), +): + """Zeigt die gefilterte Gerätehierarchie. + + Mehrere Kategorien werden als wiederholte Query-Parameter ``category_id`` + übergeben. Filter werden auf jedes Gerät angewendet, nicht nur auf die + Wurzelknoten. Wenn der direkte Elternknoten ausgefiltert ist, wird das + passende Gerät in der Darstellung als eigener Wurzelknoten angezeigt. + """ + try: + parsed_root_id = int(root_id) if root_id not in (None, "") else None + except (TypeError, ValueError): + parsed_root_id = None + + selected_category_ids: list[int] = [] + for value in request.query_params.getlist("category_id"): + try: + category_id = int(value) + except (TypeError, ValueError): + continue + if category_id not in selected_category_ids: + selected_category_ids.append(category_id) + + # Zuerst alle für den angemeldeten Benutzer sichtbaren Assets laden. Die + # Filterung erfolgt anschließend in Python, damit Treffer in beliebiger + # Hierarchietiefe nicht durch eine SQL-Abfrage von ihren Eltern getrennt + # oder versehentlich nur auf Wurzelknoten beschränkt werden. + all_assets = ( + _apply_asset_access(db.query(Asset), request) + .options(joinedload(Asset.category)) + .order_by(Asset.name) + .all() + ) + all_by_id = {asset.id: asset for asset in all_assets} + all_children: dict[int | None, list[Asset]] = {} + for asset in all_assets: + parent_key = asset.parent_asset_id if asset.parent_asset_id in all_by_id else None + all_children.setdefault(parent_key, []).append(asset) + + # Bei ausgewähltem obersten Knoten nur dessen Teilbaum berücksichtigen. + scope_ids: set[int] + selected_root = all_by_id.get(parsed_root_id) if parsed_root_id else None + if selected_root: + scope_ids = set() + pending = [selected_root.id] + while pending: + current_id = pending.pop() + if current_id in scope_ids: + continue + scope_ids.add(current_id) + pending.extend(child.id for child in all_children.get(current_id, [])) + else: + scope_ids = set(all_by_id) + + needle = name_filter.strip().casefold() + selected_categories = set(selected_category_ids) + visible_assets: list[Asset] = [] + for asset in all_assets: + if asset.id not in scope_ids: + continue + category_matches = not selected_categories or asset.category_id in selected_categories + name_matches = not needle or needle in (asset.name or "").casefold() + if category_matches and name_matches: + visible_assets.append(asset) + + # Der explizit gewählte oberste Knoten bleibt als Orientierung sichtbar, + # auch wenn er selbst den Namens- oder Kategorienfilter nicht erfüllt. + if selected_root and selected_root not in visible_assets: + visible_assets.insert(0, selected_root) + + visible_ids = {asset.id for asset in visible_assets} + by_parent: dict[int | None, list[Asset]] = {} + for asset in visible_assets: + parent_key = asset.parent_asset_id if asset.parent_asset_id in visible_ids else None + by_parent.setdefault(parent_key, []).append(asset) + for children in by_parent.values(): + children.sort(key=lambda item: (item.name or "").casefold()) + + hide_unassigned = str(hide_without_parent or "").lower() in {"1", "true", "on", "yes"} + if selected_root: + roots = [selected_root] + # Treffer, deren Zwischenknoten weggefiltert wurden, zusätzlich als + # eigene Wurzeln darstellen, damit kein Treffer verloren geht. + roots.extend( + asset for asset in by_parent.get(None, []) + if asset.id != selected_root.id + ) + else: + roots = list(by_parent.get(None, [])) + + if hide_unassigned: + # Nur echte Einzelgeräte ohne hinterlegtes übergeordnetes Gerät + # ausblenden. Geräte, die lediglich wegen eines Filters als Wurzel + # erscheinen, bleiben sichtbar. Ein gewählter oberster Knoten bleibt. + roots = [ + asset for asset in roots + if asset.id == parsed_root_id + or asset.parent_asset_id is not None + or bool(by_parent.get(asset.id)) + ] + + categories = db.query(Category).order_by(Category.name).all() + actual_roots = [ + asset for asset in all_assets + if asset.parent_asset_id is None or asset.parent_asset_id not in all_by_id + ] + actual_roots.sort(key=lambda item: (item.name or "").casefold()) + + return templates.TemplateResponse( + "asset_tree.html", + { + "request": request, + "roots": roots, + "by_parent": by_parent, + "categories": categories, + "all_roots": actual_roots, + "selected_root_id": parsed_root_id, + "selected_category_ids": selected_category_ids, + "name_filter": name_filter, + "hide_without_parent": hide_unassigned, + }, + ) + + + +def _inventory_fallback(asset: Asset) -> tuple[dict, list | dict, dict]: + raw = asset.mesh_source_data if isinstance(asset.mesh_source_data, dict) else {} + hardware = asset.mesh_hardware_data if isinstance(getattr(asset, "mesh_hardware_data", None), dict) else {} + software = getattr(asset, "mesh_software_data", None) + # Rebuild from the raw snapshot whenever possible. This upgrades older snapshots + # with language-neutral i18n keys immediately, without waiting for another sync. + if raw: + try: + from app.meshcentral import _build_hardware_inventory + hardware = _build_hardware_inventory(raw) + except Exception: + if not hardware: + hardware = (((raw.get("sys") or {}).get("hardware")) if isinstance(raw.get("sys"), dict) else {}) or {} + if not isinstance(software, (list, dict)) or not software: + candidates = [] + sys_data = raw.get("sys") if isinstance(raw.get("sys"), dict) else {} + windows = (((sys_data.get("hardware") or {}).get("windows")) if isinstance(sys_data.get("hardware"), dict) else {}) or {} + for candidate in (sys_data.get("software"), windows.get("software") if isinstance(windows, dict) else None, raw.get("software"), raw.get("installedSoftware"), raw.get("installedApplications")): + if isinstance(candidate, (list, dict)) and candidate: + software = candidate + break + else: + software = [] + return hardware, software, raw + + +def _software_table(software: list | dict) -> tuple[list[str], list[dict]]: + if isinstance(software, dict): + if all(isinstance(value, dict) for value in software.values()): + rows = [{"Name": key, **value} for key, value in software.items()] + else: + rows = [{"Eigenschaft": key, "Wert": value} for key, value in software.items()] + elif isinstance(software, list): + rows = [item if isinstance(item, dict) else {"Wert": item} for item in software] + else: + rows = [] + preferred = ["Name", "DisplayName", "name", "Version", "DisplayVersion", "version", "Publisher", "publisher", "InstallDate", "installDate"] + keys = [] + for key in preferred: + if any(key in row for row in rows) and key not in keys: + keys.append(key) + for row in rows: + for key in row.keys(): + if key not in keys: + keys.append(key) + return keys, rows + + +@app.get("/assets/{asset_id:int}") +def asset_detail(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + active_fields = [f for f in asset.category.visible_fields if f.active] + history_count = db.query(AssetHistory).filter(AssetHistory.asset_id == asset.id).count() + hardware_inventory, software_inventory, raw_inventory = _inventory_fallback(asset) + + # The software tab on the asset detail page uses the dedicated callback + # inventory. MeshCentral raw details remain available independently. + stored_software = ( + db.query(InstalledSoftware) + .filter(InstalledSoftware.asset_id == asset.id) + .order_by(InstalledSoftware.name.asc(), InstalledSoftware.version.asc()) + .all() + ) + latest_software_job = ( + db.query(SoftwareJob) + .options(joinedload(SoftwareJob.package)) + .filter(SoftwareJob.asset_id == asset.id) + .order_by(SoftwareJob.created_at.desc()) + .first() + ) + + return templates.TemplateResponse("asset_detail.html", { + "request": request, + "asset": asset, + "active_fields": active_fields, + "history_count": history_count, + "is_admin": _is_admin(request), + "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), + "today": date.today().isoformat(), + "parent_asset": asset.parent_asset, + "child_assets": sorted(asset.child_assets, key=lambda item: item.name.casefold()), + "custom_values": { + f.field_name: _custom_value_get(asset, f.definition, db) + for f in active_fields + if f.definition and not f.definition.is_system + }, + "hardware_inventory": hardware_inventory, + "software_inventory": software_inventory, + "installed_software": stored_software, + "latest_software_job": latest_software_job, + "raw_inventory": raw_inventory, + "asset_platform": detect_platform(asset), + "job_definitions": ( + db.query(JobDefinition).options(selectinload(JobDefinition.parameters)) + .filter(JobDefinition.enabled.is_(True), JobDefinition.platform.in_([detect_platform(asset), "all"])) + .order_by(JobDefinition.name).all() + if _is_admin(request) and asset.mesh_node_id and detect_platform(asset) != "unknown" else [] + ), + "job_parameter_overrides": ({x.definition_parameter_id:x.value_text for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id).all()} if _is_admin(request) else {}), + }) + + +def _reset_mesh_link_runtime_state(asset: Asset) -> None: + """Clear only connection-derived runtime state after a manual relink. + + Inventory and normal asset fields intentionally remain untouched until the + next regular MeshCentral synchronization applies the configured mappings. + """ + asset.mesh_online = None + asset.mesh_online_state = "unknown" + asset.mesh_presence_updated_at = None + asset.mesh_last_online_at = None + asset.mesh_last_seen = None + asset.mesh_presence_source = None + asset.mesh_last_sync = None + + +def _mesh_link_asset(db: Session, asset_id: int) -> Asset: + asset = db.query(Asset).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + return asset + + +@app.get("/assets/{asset_id}/mesh-link") +def asset_mesh_link_page(asset_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + asset = _mesh_link_asset(db, asset_id) + try: + devices = fetch_device_summaries_for_linking() + except RuntimeError as exc: + return RedirectResponse( + f"/assets/{asset.id}?toast_error=" + quote( + _translate_request( + request, + "mesh.link.fetch_failed", + "The MeshCentral device list could not be loaded: {error}", + error=str(exc), + ) + ), + status_code=303, + ) + + node_ids = [item["node_id"] for item in devices if item.get("node_id")] + assigned_rows = ( + db.query(Asset.id, Asset.name, Asset.mesh_node_id) + .filter(Asset.mesh_node_id.in_(node_ids)) + .all() + if node_ids + else [] + ) + assigned = {str(row.mesh_node_id): {"asset_id": row.id, "asset_name": row.name} for row in assigned_rows if row.mesh_node_id} + + available_devices: list[dict[str, Any]] = [] + current_device: dict[str, Any] | None = None + for item in devices: + row = dict(item) + owner = assigned.get(row["node_id"]) + row["assigned_asset_id"] = owner["asset_id"] if owner else None + row["assigned_asset_name"] = owner["asset_name"] if owner else None + row["is_current"] = row["node_id"] == (asset.mesh_node_id or "") + if row["is_current"]: + current_device = row + continue + if owner is None: + available_devices.append(row) + + return templates.TemplateResponse( + "asset_mesh_link.html", + { + "request": request, + "asset": asset, + "current_device": current_device, + "available_devices": available_devices, + }, + ) + + +@app.post("/assets/{asset_id}/mesh-link") +def asset_mesh_link_save( + asset_id: int, + request: Request, + node_id: str = Form(...), + db: Session = Depends(get_db), +): + _require_admin(request) + asset = _mesh_link_asset(db, asset_id) + requested_node_id = str(node_id or "").strip() + if not requested_node_id: + return RedirectResponse( + f"/assets/{asset.id}/mesh-link?toast_error=" + + quote(_translate_request(request, "mesh.link.select_required", "Select a MeshCentral device.")), + status_code=303, + ) + + try: + device_ids = {item["node_id"] for item in fetch_device_summaries_for_linking() if item.get("node_id")} + except RuntimeError as exc: + return RedirectResponse( + f"/assets/{asset.id}?toast_error=" + quote( + _translate_request( + request, + "mesh.link.fetch_failed", + "The MeshCentral device list could not be loaded: {error}", + error=str(exc), + ) + ), + status_code=303, + ) + + if requested_node_id not in device_ids: + return RedirectResponse( + f"/assets/{asset.id}/mesh-link?toast_error=" + + quote(_translate_request(request, "mesh.link.not_found", "The selected MeshCentral device no longer exists.")), + status_code=303, + ) + + owner = db.query(Asset).filter(Asset.mesh_node_id == requested_node_id, Asset.id != asset.id).first() + if owner: + message = _translate_request( + request, + "mesh.link.already_assigned", + "This MeshCentral device is already linked to asset {asset}.", + asset=owner.name, + ) + return RedirectResponse( + f"/assets/{asset.id}/mesh-link?toast_error=" + quote(message), + status_code=303, + ) + + old_node_id = asset.mesh_node_id or "" + if old_node_id == requested_node_id: + return RedirectResponse( + f"/assets/{asset.id}?toast_warning=" + + quote(_translate_request(request, "mesh.link.unchanged", "The MeshCentral link is already set to this device.")), + status_code=303, + ) + + asset.mesh_node_id = requested_node_id + _reset_mesh_link_runtime_state(asset) + asset.mesh_sync_status = "pending" + asset.mesh_sync_message = _translate_request( + request, + "mesh.link.pending_sync", + "MeshCentral link changed manually; synchronization is pending.", + ) + _record_asset_history( + db, + asset, + { + _translate_request(request, "field.mesh_node_id", "MeshCentral node ID"): { + "old": old_node_id, + "new": requested_node_id, + } + }, + source="mesh-link-manual", + changed_by=_changed_by(request), + ) + try: + db.commit() + except IntegrityError: + db.rollback() + return RedirectResponse( + f"/assets/{asset.id}/mesh-link?toast_error=" + + quote(_translate_request(request, "mesh.link.conflict", "The MeshCentral node ID was assigned by another operation.")), + status_code=303, + ) + + return RedirectResponse( + f"/assets/{asset.id}?toast_success=" + + quote(_translate_request(request, "mesh.link.changed", "MeshCentral link changed.")), + status_code=303, + ) + + +@app.post("/assets/{asset_id}/mesh-unlink") +def asset_mesh_unlink(asset_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + asset = _mesh_link_asset(db, asset_id) + old_node_id = asset.mesh_node_id or "" + if not old_node_id: + return RedirectResponse( + f"/assets/{asset.id}?toast_warning=" + + quote(_translate_request(request, "mesh.link.already_unlinked", "This asset is not linked to MeshCentral.")), + status_code=303, + ) + + asset.mesh_node_id = None + _reset_mesh_link_runtime_state(asset) + asset.mesh_sync_status = "unlinked" + asset.mesh_sync_message = _translate_request(request, "mesh.link.unlinked_message", "MeshCentral link removed manually.") + _record_asset_history( + db, + asset, + { + _translate_request(request, "field.mesh_node_id", "MeshCentral node ID"): { + "old": old_node_id, + "new": "", + } + }, + source="mesh-unlink-manual", + changed_by=_changed_by(request), + ) + db.commit() + return RedirectResponse( + f"/assets/{asset.id}?toast_success=" + + quote(_translate_request(request, "mesh.link.unlinked", "MeshCentral link removed.")), + status_code=303, + ) + + +def _presence_payload(asset: Asset) -> dict[str, Any]: + return { + "asset_id": asset.id, + "has_mesh_node_id": bool(asset.mesh_node_id), + "state": asset.mesh_online_state or "unknown", + "online": asset.mesh_online, + "updated_at": _utc_iso(asset.mesh_presence_updated_at) or None, + "last_online_at": _utc_iso(asset.mesh_last_online_at) or None, + "last_seen_at": _utc_iso(asset.mesh_presence_updated_at if (asset.mesh_online_state or "unknown") == "online" else asset.mesh_last_seen) or None, + "historical_last_seen_at": _utc_iso(asset.mesh_last_seen) or None, + "source": asset.mesh_presence_source, + } + + +@app.get("/api/assets/presence") +def assets_presence(request: Request, db: Session = Depends(get_db)): + assets = ( + _apply_asset_access(db.query(Asset), request) + .filter(Asset.mesh_node_id.isnot(None), Asset.mesh_node_id != "") + .all() + ) + return {"items": [_presence_payload(asset) for asset in assets]} + + +@app.get("/api/assets/{asset_id}/presence") +def asset_presence(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _get_visible_asset(db, request, asset_id) + return _presence_payload(asset) + + +@app.get("/assets/{asset_id}/mesh-inventory.json") +def asset_mesh_inventory_json(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _apply_asset_access(db.query(Asset), request).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + hardware, software, raw = _inventory_fallback(asset) + payload = { + "asset_id": asset.id, + "asset_name": asset.name, + "mesh_node_id": asset.mesh_node_id, + "updated_at": _utc_iso(asset.mesh_inventory_updated_at) or None, + "hardware": hardware, + "software": software, + "raw": raw, + } + filename = re.sub(r"[^A-Za-z0-9._-]+", "_", asset.name or f"asset-{asset.id}") + "-mesh-inventory.json" + return Response(content=json.dumps(payload, ensure_ascii=False, indent=2, default=str), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="{filename}"'}) + + +@app.get("/assets/{asset_id}/edit") +def asset_edit(asset_id: int, request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + _require_admin(request) + asset = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category + if selected is None: + selected = asset.category + return templates.TemplateResponse("asset_form.html", {"request": request, "asset": asset, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).filter(Asset.id != asset.id).order_by(Asset.name).all(), "custom_values": {f.field_name: _custom_value_get(asset, f.definition, db) for f in selected.visible_fields if f.definition and not f.definition.is_system}}) + + +@app.post("/assets/{asset_id}/edit") +async def asset_update(asset_id: int, request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + asset = db.query(Asset).filter(Asset.id == asset_id).first() + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not asset or not category: + raise HTTPException(404, "Asset oder Kategorie nicht gefunden") + form = await request.form() + changes: dict[str, dict[str, str]] = {} + + # mesh_node_id is a protected system field. On the administrator-only + # edit page it may nevertheless be changed directly or cleared. Do this + # separately from the generic field loop because readonly system fields + # are intentionally skipped there. + if "mesh_node_id" in form: + old_node_id = _normalize_optional_text(asset.mesh_node_id) + requested_node_id = _normalize_optional_text(form.get("mesh_node_id")) + if old_node_id != requested_node_id: + if requested_node_id: + owner = ( + db.query(Asset.id, Asset.name) + .filter(Asset.mesh_node_id == requested_node_id, Asset.id != asset.id) + .first() + ) + if owner: + message = _translate_request( + request, + "mesh.link.already_assigned", + "This MeshCentral device is already linked to asset {asset}.", + ).replace("{asset}", owner.name or str(owner.id)) + return RedirectResponse( + f"/assets/{asset.id}/edit?toast_error=" + quote(message), + status_code=303, + ) + + changes[_translate_request(request, "field.mesh_node_id", "MeshCentral node ID")] = { + "old": old_node_id or "", + "new": requested_node_id or "", + } + asset.mesh_node_id = requested_node_id + _reset_mesh_link_runtime_state(asset) + if requested_node_id: + asset.mesh_sync_status = "pending" + asset.mesh_sync_message = _translate_request( + request, + "mesh.link.pending_sync", + "MeshCentral link changed manually; synchronization is pending.", + ) + else: + asset.mesh_sync_status = "unlinked" + asset.mesh_sync_message = _translate_request( + request, + "mesh.link.unlinked_message", + "MeshCentral link removed manually.", + ) + + old_category_id = asset.category_id + if old_category_id != category_id: + old_category = db.query(Category).filter(Category.id == old_category_id).first() + changes["Kategorie"] = {"old": old_category.name if old_category else str(old_category_id), "new": category.name} + asset.category_id = category_id + for field in category.visible_fields: + if ( + field.active + and hasattr(asset, field.field_name) + and field.field_name not in {"room_number", "parent_asset_id"} + and not _definition_readonly(field) + ): + old_value = getattr(asset, field.field_name) + new_value = form.get(field.field_name) or None + if field.definition and field.definition.data_type == "date": + new_value = _normalize_date_value(new_value) + elif field.definition and field.definition.data_type == "datetime": + new_value = _normalize_datetime_value(new_value) + if _display_value(old_value) != _display_value(new_value): + changes[_definition_label(field)] = {"old": _display_value(old_value), "new": _display_value(new_value)} + setattr(asset, field.field_name, new_value) + new_room = _normalize_optional_text(form.get("room_number")) + if _display_value(asset.room_number) != _display_value(new_room): + changes["Raumnummer"] = {"old": _display_value(asset.room_number), "new": _display_value(new_room)} + asset.room_number = new_room + parent_raw = _normalize_optional_text(form.get("parent_asset_id")) + new_parent_id = int(parent_raw) if parent_raw else None + if new_parent_id and _would_create_parent_cycle(db, asset.id, new_parent_id): + raise HTTPException(400, "Diese Zuordnung würde einen Kreis in der Baustruktur erzeugen.") + current_parent_id = int(asset.parent_asset_id) if asset.parent_asset_id not in (None, "") else None + if current_parent_id != new_parent_id: + old_parent = db.query(Asset).filter(Asset.id == current_parent_id).first() if current_parent_id else None + new_parent = db.query(Asset).filter(Asset.id == new_parent_id).first() if new_parent_id else None + changes["Übergeordnetes Gerät"] = {"old": old_parent.name if old_parent else "", "new": new_parent.name if new_parent else ""} + asset.parent_asset_id = new_parent_id + for field in category.visible_fields: + if field.active and field.definition and not field.definition.is_system and not field.definition.readonly: + old_custom = _custom_value_get(asset, field.definition, db) + new_custom = form.get(field.field_name) + if _display_value(old_custom) != _display_value(new_custom): + changes[_definition_label(field)] = {"old": _display_value(old_custom), "new": _display_value(new_custom)} + _custom_value_set(asset, field.definition, new_custom, db) + _validate_asset_identifiers(db, asset_tag=asset.asset_tag, manufacturer=asset.manufacturer, serial_number=asset.serial_number, exclude_id=asset.id) + new_image = save_upload(image) + if new_image: + if asset.image_path != new_image: + changes["Bild"] = {"old": asset.image_path or "", "new": new_image} + asset.image_path = new_image + try: + _record_asset_history(db, asset, changes, source="manual-update", changed_by=_changed_by(request)) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc + return RedirectResponse(f"/assets/{asset.id}", status_code=303) + + +@app.get("/assets/{asset_id}/history") +def asset_history(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _get_visible_asset(db, request, asset_id) + entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all() + return templates.TemplateResponse("asset_history.html", {"request": request, "asset": asset, "entries": entries}) + + +@app.get("/assets/{asset_id}/history/export.xlsx") +def asset_history_export(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _get_visible_asset(db, request, asset_id) + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all() + rows = [] + for entry in entries: + for label, change in (entry.changes or {}).items(): + rows.append([entry.changed_at, entry.source, entry.changed_by or "", label, change.get("old", ""), change.get("new", "")]) + return _xlsx_response(f"asset-{asset.id}-historie.xlsx", ["Zeitpunkt", "Quelle", "Benutzer", "Feld", "Alter Wert", "Neuer Wert"], rows) + + +@app.get("/assets/export.xlsx") +def assets_export(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + selected = next((category for category in categories if category.id == category_id), None) if category_id else None + query = _apply_asset_access(db.query(Asset), request) + if selected: + query = query.filter(Asset.category_id == selected.id) + assets = query.order_by(Asset.name).all() + fields = ([field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] if selected else _list_fields_for_categories(categories)) + headers = ["Asset-ID"] + ((["Kategorie"] if not selected else []) + [field.label for field in fields]) + if not any(field.field_name == "name" for field in fields): + headers.insert(1, "Bezeichnung") + rows = [] + for asset in assets: + row = [asset.id] + (([asset.category.name] if not selected else []) + [getattr(asset, field.field_name, "") for field in fields]) + if not any(field.field_name == "name" for field in fields): + row.insert(1, asset.name) + rows.append(row) + suffix = selected.name if selected else "alle" + return _xlsx_response(f"assets-{suffix}.xlsx", headers, rows) + + +@app.get("/export/assets.xlsx") +def assets_export_static(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + return assets_export(request=request, category_id=category_id, db=db) + + +@app.post("/assets/{asset_id}/issue") +def asset_issue(asset_id: int, request: Request, return_to: str = Form("detail"), assigned_to: str = Form(""), assigned_on: str = Form(""), db: Session = Depends(get_db)): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + status = _action_status(db, "issue") + if not status: + raise HTTPException(400, "Kein Status für die Aktion Ausgeben konfiguriert") + changes = _asset_action_changes(asset, "issue", status.name, assigned_to, assigned_on) + _record_asset_history(db, asset, changes, source="manual-issue", changed_by=_changed_by(request)) + db.commit() + return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303) + + +@app.post("/assets/{asset_id}/return") +def asset_return(asset_id: int, request: Request, return_to: str = Form("detail"), db: Session = Depends(get_db)): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + status = _action_status(db, "return") + if not status: + raise HTTPException(400, "Kein Status für die Aktion Zurücknehmen konfiguriert") + changes = _asset_action_changes(asset, "return", status.name) + _record_asset_history(db, asset, changes, source="manual-return", changed_by=_changed_by(request)) + db.commit() + return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303) + + + +MERGE_PREVIEW_FIELDS = [ + ("id", "Asset-ID"), + ("category", "Kategorie"), + ("name", "Bezeichnung"), + ("model", "Modell"), + ("serial_number", "Seriennummer"), + ("hostname", "Computername"), + ("notes", "Notizen"), + ("mesh_node_id", "MeshCentral Node-ID"), +] + +MERGE_FILL_FIELDS = ["model", "serial_number", "hostname", "notes", "mesh_node_id"] + + +def _merge_display_value(asset: Asset, field_name: str) -> str: + if field_name == "id": + return str(asset.id) + if field_name == "category": + return asset.category.name if asset.category else "" + return _display_value(getattr(asset, field_name, None)) + + +def _merge_preview(assets: list[Asset]) -> list[dict]: + result = [] + for field_name, label in MERGE_PREVIEW_FIELDS: + source = assets[0] + value = _merge_display_value(source, field_name) + if field_name not in {"id", "category", "name"} and not value: + for candidate in assets[1:]: + candidate_value = _merge_display_value(candidate, field_name) + if candidate_value: + source = candidate + value = candidate_value + break + result.append({ + "field_name": field_name, + "label": label, + "value": value, + "source_asset_id": source.id, + }) + return result + + +def _ordered_merge_assets(db: Session, asset_ids: list[str]) -> list[Asset]: + ids = _selected_asset_ids(asset_ids) + if len(ids) < 2: + raise HTTPException(400, "Bitte mindestens zwei Assets auswählen.") + rows = db.query(Asset).options(joinedload(Asset.category)).filter(Asset.id.in_(ids)).all() + by_id = {asset.id: asset for asset in rows} + if len(by_id) != len(ids): + raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") + return [by_id[asset_id] for asset_id in ids] + + +@app.post("/assets/merge") +def assets_merge_order(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + assets = _ordered_merge_assets(db, asset_ids) + return templates.TemplateResponse( + "assets_merge_order.html", + {"request": request, "assets": assets}, + ) + + +@app.post("/assets/merge/preview") +def assets_merge_preview(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + assets = _ordered_merge_assets(db, asset_ids) + return templates.TemplateResponse( + "assets_merge_preview.html", + {"request": request, "assets": assets, "preview": _merge_preview(assets)}, + ) + + +@app.post("/assets/merge/apply") +def assets_merge_apply(request: Request, asset_ids: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + assets = _ordered_merge_assets(db, asset_ids) + target = assets[0] + sources = assets[1:] + source_ids = [asset.id for asset in sources] + changes = {} + + for field_name in MERGE_FILL_FIELDS: + if _display_value(getattr(target, field_name, None)): + continue + for source in sources: + candidate = getattr(source, field_name, None) + if not _display_value(candidate): + continue + + if field_name == "mesh_node_id": + # mesh_node_id is unique. When a manually imported asset stays + # and absorbs a MeshCentral asset, the source must release the + # node ID before the target can receive it. Without this flush, + # PostgreSQL may update the target first and reject the merge. + source.mesh_node_id = None + db.flush() + + setattr(target, field_name, candidate) + changes[field_name] = { + "old": "", + "new": _display_value(candidate), + "source_asset_id": str(source.id), + } + break + + target_values = { + row.field_definition_id: row + for row in db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == target.id).all() + } + for source in sources: + for source_value in db.query(AssetFieldValue).filter(AssetFieldValue.asset_id == source.id).all(): + existing = target_values.get(source_value.field_definition_id) + current = "" + if existing: + current = _display_value( + existing.value_text if existing.value_text is not None else + existing.value_number if existing.value_number is not None else + existing.value_boolean if existing.value_boolean is not None else + existing.value_date if existing.value_date is not None else + existing.value_datetime if existing.value_datetime is not None else + existing.value_json + ) + if current: + continue + if existing is None: + source_value.asset_id = target.id + target_values[source_value.field_definition_id] = source_value + else: + existing.value_text = source_value.value_text + existing.value_number = source_value.value_number + existing.value_boolean = source_value.value_boolean + existing.value_date = source_value.value_date + existing.value_datetime = source_value.value_datetime + existing.value_json = source_value.value_json + db.delete(source_value) + + db.query(AssetHistory).filter(AssetHistory.asset_id.in_(source_ids)).update( + {AssetHistory.asset_id: target.id}, synchronize_session=False + ) + db.query(SoftwareJob).filter(SoftwareJob.asset_id.in_(source_ids)).update( + {SoftwareJob.asset_id: target.id}, synchronize_session=False + ) + # Compact job states are derived data. Rebuild them for the surviving asset + # after the complete job history has been moved. + db.query(AssetJobState).filter(AssetJobState.asset_id.in_([target.id, *source_ids])).delete(synchronize_session=False) + db.flush() + for merged_job in ( + db.query(SoftwareJob) + .filter(SoftwareJob.asset_id == target.id) + .order_by(SoftwareJob.created_at.asc(), SoftwareJob.id.asc()) + .all() + ): + sync_asset_job_state( + db, + merged_job, + increment_execution=True, + status_at=merged_job.finished_at or merged_job.callback_completed_at or merged_job.started_at or merged_job.sent_at or merged_job.created_at, + ) + + existing_software = { + (row.name.casefold(), (row.version or "").casefold(), (row.publisher or "").casefold(), (row.architecture or "").casefold()) + for row in db.query(InstalledSoftware).filter(InstalledSoftware.asset_id == target.id).all() + } + for row in db.query(InstalledSoftware).filter(InstalledSoftware.asset_id.in_(source_ids)).all(): + identity = (row.name.casefold(), (row.version or "").casefold(), (row.publisher or "").casefold(), (row.architecture or "").casefold()) + if identity in existing_software: + db.delete(row) + else: + row.asset_id = target.id + existing_software.add(identity) + + db.query(Asset).filter(Asset.parent_asset_id.in_(source_ids)).update( + {Asset.parent_asset_id: target.id}, synchronize_session=False + ) + if target.parent_asset_id in source_ids: + target.parent_asset_id = None + + _record_asset_history( + db, + target, + { + "Duplikate zusammengeführt": { + "old": ", ".join(str(asset_id) for asset_id in source_ids), + "new": str(target.id), + }, + **changes, + }, + source="duplicate-merge", + changed_by=_changed_by(request), + ) + + for source in sources: + db.delete(source) + + try: + db.commit() + except IntegrityError: + db.rollback() + message = _translate_request( + request, + "duplicates.merge_unique_error", + "The duplicates could not be merged because a unique value is already assigned to another asset.", + ) + # Do not redirect back to /assets/merge/preview: that page is POST-only. + # A GET redirect there caused ERR_TOO_MANY_REDIRECTS instead of a toast. + return RedirectResponse(f"/assets?toast_error={quote(message)}", status_code=303) + + return RedirectResponse( + f"/assets/{target.id}?toast_success={quote(f'{len(sources)} Duplikate wurden in Asset {target.id} zusammengeführt')}", + status_code=303, + ) + + + +@app.post("/assets/bulk-edit") +def assets_bulk_edit_form(request: Request, asset_ids: list[str] = Form([]), category_id: int | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + ids = _selected_asset_ids(asset_ids) + if not ids: + raise HTTPException(400, "Bitte mindestens ein Asset markieren.") + assets = (db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id.in_(ids)).order_by(Asset.name).all()) + if len(assets) != len(ids): + raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") + fields = [{"field_name": "category_id", "label": "Kategorie"}] + _bulk_edit_fields_for_assets(assets) + categories = _sort_localized(db.query(Category).all(), "name", request) + return templates.TemplateResponse("assets_bulk_edit.html", { + "request": request, + "assets": assets, + "asset_ids": ids, + "fields": fields, + "status_options": _active_status_options(db, request), + "parent_candidates": _sort_localized(db.query(Asset).filter(~Asset.id.in_(ids)).all(), "name", request), + "categories": categories, + "category_id": category_id, + }) + + +@app.post("/assets/bulk-edit/apply") +def assets_bulk_edit_apply(request: Request, asset_ids: list[str] = Form([]), field_name: str = Form(...), new_value: str = Form(""), category_id: int | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + ids = _selected_asset_ids(asset_ids) + assets = (db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id.in_(ids)).all()) + if not ids or len(assets) != len(ids): + raise HTTPException(400, "Die Auswahl ist leer oder nicht mehr vollständig vorhanden.") + allowed = {"category_id": "Kategorie", **{item["field_name"]: item["label"] for item in _bulk_edit_fields_for_assets(assets)}} + if field_name not in allowed: + raise HTTPException(400, "Das gewählte Feld ist nicht in allen ausgewählten Gerätekategorien zugelassen.") + value = _normalize_optional_text(new_value) + parent_asset = None + target_category = None + if field_name == "category_id": + try: + target_category_id = int(value) if value else None + except (TypeError, ValueError) as exc: + raise HTTPException(400, "Die Kategorie ist ungültig.") from exc + if target_category_id is None: + raise HTTPException(400, "Bitte eine Kategorie auswählen.") + target_category = db.query(Category).filter(Category.id == target_category_id).first() + if not target_category: + raise HTTPException(404, "Die gewählte Kategorie wurde nicht gefunden.") + value = target_category_id + if field_name == "parent_asset_id": + try: + parent_id = int(value) if value else None + except (TypeError, ValueError) as exc: + raise HTTPException(400, "Das übergeordnete Gerät ist ungültig.") from exc + if parent_id in ids: + raise HTTPException(400, "Ein ausgewähltes Asset kann nicht gleichzeitig als übergeordnetes Gerät für die gesamte Auswahl verwendet werden.") + if parent_id is not None: + parent_asset = db.query(Asset).filter(Asset.id == parent_id).first() + if not parent_asset: + raise HTTPException(404, "Das gewählte übergeordnete Gerät wurde nicht gefunden.") + value = parent_id + for asset in assets: + if parent_id is not None and _would_create_parent_cycle(db, asset.id, parent_id): + raise HTTPException(400, f"Die Zuordnung würde beim Asset {asset.name} einen Kreis in der Baumstruktur erzeugen.") + + changed = 0 + for asset in assets: + old_value = getattr(asset, field_name, None) + if old_value == value or _display_value(old_value) == _display_value(value): + continue + if field_name == "parent_asset_id": + old_parent_id = int(old_value) if old_value not in (None, "") else None + old_parent = db.query(Asset).filter(Asset.id == old_parent_id).first() if old_parent_id else None + history_old = old_parent.name if old_parent else "" + history_new = parent_asset.name if parent_asset else "" + elif field_name == "category_id": + history_old = asset.category.name if asset.category else str(old_value or "") + history_new = target_category.name + else: + history_old = _display_value(old_value) + history_new = _display_value(value) + setattr(asset, field_name, value) + _record_asset_history(db, asset, {allowed[field_name]: {"old": history_old, "new": history_new}}, source="bulk-update", changed_by=_changed_by(request)) + changed += 1 + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Die Daten konnten nicht für alle Assets gespeichert werden.") from exc + target = f"/assets?category_id={category_id}" if category_id else "/assets" + return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(changed) + ' Assets aktualisiert')}", status_code=303) + + +@app.post("/assets/bulk-delete") +def assets_bulk_delete(request: Request, asset_ids: list[str] = Form([]), category_id: int | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + ids = _selected_asset_ids(asset_ids) + if not ids: + raise HTTPException(400, "Bitte mindestens ein Asset markieren.") + assets = db.query(Asset).filter(Asset.id.in_(ids)).all() + if len(assets) != len(ids): + raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") + image_paths = [asset.image_path for asset in assets if asset.image_path] + for asset in assets: + db.delete(asset) + db.commit() + for image_path in image_paths: + delete_asset_image(image_path) + target = f"/assets?category_id={category_id}" if category_id else "/assets" + return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(len(assets)) + ' Assets gelöscht')}", status_code=303) + + +@app.post("/assets/{asset_id}/delete") +def asset_delete(asset_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + + image_path = asset.image_path + db.delete(asset) + db.commit() + delete_asset_image(image_path) + return RedirectResponse("/assets", status_code=303) + + +@app.get("/status-options") +def status_options_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + options = db.query(StatusOption).order_by(StatusOption.sort_order, StatusOption.name).all() + return templates.TemplateResponse("status_options.html", {"request": request, "options": options}) + + +@app.post("/status-options") +async def status_options_save(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + existing = db.query(StatusOption).all() + for option in existing: + name = (form.get(f"name_{option.id}") or "").strip() + if not name: + continue + option.name = name + option.active = form.get(f"active_{option.id}") == "on" + try: + option.sort_order = int(form.get(f"sort_{option.id}") or option.sort_order) + except ValueError: + pass + issue_id = int(form.get("issue_status_id") or 0) + return_id = int(form.get("return_status_id") or 0) + for option in existing: + option.use_for_issue = option.id == issue_id + option.use_for_return = option.id == return_id + new_name = (form.get("new_name") or "").strip() + if new_name: + max_order = max((item.sort_order for item in existing), default=-1) + db.add(StatusOption(name=new_name, sort_order=max_order + 1, active=True)) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Statuswerte müssen eindeutig sein.") from exc + return RedirectResponse("/status-options", status_code=303) + + +@app.post("/status-options/{option_id}/delete") +def status_option_delete(option_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + option = db.query(StatusOption).filter(StatusOption.id == option_id).first() + if not option: + raise HTTPException(404, "Statuswert nicht gefunden") + db.delete(option) + db.commit() + return RedirectResponse("/status-options", status_code=303) + + +@app.get("/categories") +def categories_list(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + categories = ( + db.query(Category) + .options(selectinload(Category.visible_fields)) + .order_by(Category.name) + .all() + ) + asset_counts = dict( + db.query(Asset.category_id, func.count(Asset.id)) + .group_by(Asset.category_id) + .all() + ) + return templates.TemplateResponse( + "categories.html", + {"request": request, "categories": categories, "asset_counts": asset_counts, + "definitions": db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()}, + ) + + + +@app.post("/categories/bulk-edit") +async def categories_bulk_edit( + request: Request, + category_ids: list[str] = Form([]), + field_name: str = Form(...), + field_value: str = Form(""), + clear_value: str | None = Form(None), + image: UploadFile | None = File(None), + db: Session = Depends(get_db), +): + _require_admin(request) + ids = _selected_asset_ids(category_ids) + if not ids: + message = _translate_request(request, "categories.bulk.no_selection", "No categories selected") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + + categories = db.query(Category).filter(Category.id.in_(ids)).all() + if not categories: + message = _translate_request(request, "categories.bulk.no_selection", "No categories selected") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + + clear = clear_value == "on" + if field_name == "name": + if len(categories) != 1: + message = _translate_request(request, "categories.bulk.name_single_only", "The name can only be changed for one category at a time") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + value = field_value.strip() + if not value: + message = _translate_request(request, "categories.bulk.name_required", "The category name must not be empty") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + categories[0].name = value + elif field_name == "description": + value = None if clear else (field_value.strip() or None) + for category in categories: + category.description = value + elif field_name == "image": + if clear: + for category in categories: + category.image_path = None + else: + new_image = save_upload(image) + if not new_image: + message = _translate_request(request, "categories.bulk.image_required", "Select an image or choose clear value") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + for category in categories: + category.image_path = new_image + else: + raise HTTPException(400, _translate_request(request, "categories.bulk.unsupported_field", "Unsupported category field")) + + try: + db.commit() + except IntegrityError: + db.rollback() + message = _translate_request(request, "categories.bulk.name_duplicate", "The category name already exists") + return RedirectResponse(f"/categories?toast_error={quote(message)}", status_code=303) + + message = _translate_request(request, "categories.bulk.updated", "Updated {count} categories", count=len(categories)) + return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303) + + +@app.post("/categories/bulk-fields") +async def categories_bulk_fields( + request: Request, + category_ids: list[str] = Form([]), + operation: str = Form(...), + field_ids: list[str] = Form([]), + field_definition_id: str = Form(""), + apply_required: str | None = Form(None), + required_value: str | None = Form(None), + apply_show_in_list: str | None = Form(None), + show_in_list_value: str | None = Form(None), + source_category_id: str = Form(""), + db: Session = Depends(get_db), +): + """Manage category field assignments without touching generic table behavior.""" + _require_admin(request) + ids = _selected_asset_ids(category_ids) + if not ids: + message = _translate_request(request, "categories.fields.no_selection", "No categories selected") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + + categories = ( + db.query(Category) + .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) + .filter(Category.id.in_(ids)) + .all() + ) + if not categories: + message = _translate_request(request, "categories.fields.no_selection", "No categories selected") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + + definitions = {item.id: item for item in db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).all()} + selected_definition_ids = _selected_asset_ids(field_ids) + changed = 0 + + if operation in {"add", "remove"}: + selected_definitions = [definitions[item_id] for item_id in selected_definition_ids if item_id in definitions] + if not selected_definitions: + message = _translate_request(request, "categories.fields.choose_fields", "Select at least one field") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + + for category in categories: + by_definition = {link.field_definition_id: link for link in category.visible_fields if link.field_definition_id} + by_name = {link.field_name: link for link in category.visible_fields} + next_order = max((link.sort_order for link in category.visible_fields), default=-1) + 1 + for definition in selected_definitions: + link = by_definition.get(definition.id) or by_name.get(definition.field_name) + if operation == "add": + if link is None: + link = CategoryField( + category_id=category.id, + field_name=definition.field_name, + label=definition.label, + field_definition_id=definition.id, + active=True, + required=definition.field_name == "name", + show_in_list=definition.field_name == "name", + readonly=definition.readonly, + sort_order=next_order, + ) + next_order += 1 + db.add(link) + category.visible_fields.append(link) + changed += 1 + elif not link.active: + link.active = True + if definition.field_name == "name": + link.required = True + link.show_in_list = True + changed += 1 + else: + if definition.field_name == "name": + continue + if link is not None and (link.active or link.required or link.show_in_list): + link.active = False + link.required = False + link.show_in_list = False + changed += 1 + + elif operation == "settings": + try: + definition_id = int(field_definition_id) + except (TypeError, ValueError): + definition_id = 0 + definition = definitions.get(definition_id) + if not definition: + message = _translate_request(request, "categories.fields.choose_field", "Select a field") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + change_required = apply_required == "on" + change_list = apply_show_in_list == "on" + if not change_required and not change_list: + message = _translate_request(request, "categories.fields.choose_setting", "Select at least one setting to change") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + required = required_value == "on" + show_in_list = show_in_list_value == "on" + for category in categories: + link = next((item for item in category.visible_fields if item.field_definition_id == definition.id or item.field_name == definition.field_name), None) + if link is None or not link.active: + continue + before = (link.required, link.show_in_list) + if change_required and definition.field_name != "name" and not definition.readonly: + link.required = required + if change_list: + link.show_in_list = True if definition.field_name == "name" else show_in_list + if before != (link.required, link.show_in_list): + changed += 1 + + elif operation == "copy": + try: + source_id = int(source_category_id) + except (TypeError, ValueError): + source_id = 0 + source = ( + db.query(Category) + .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) + .filter(Category.id == source_id) + .first() + ) + if not source: + message = _translate_request(request, "categories.fields.choose_source", "Select a source category") + return RedirectResponse(f"/categories?toast_warning={quote(message)}", status_code=303) + source_links = sorted(source.visible_fields, key=lambda item: item.sort_order) + for category in categories: + if category.id == source.id: + continue + target_by_name = {item.field_name: item for item in category.visible_fields} + source_names = {item.field_name for item in source_links} + # A template is an exact field-configuration copy. Existing links are + # retained but deactivated when absent from the source, preserving data. + for target in category.visible_fields: + if target.field_name not in source_names and target.field_name != "name": + target.active = False + target.required = False + target.show_in_list = False + for source_link in source_links: + target = target_by_name.get(source_link.field_name) + if target is None: + target = CategoryField( + category_id=category.id, + field_name=source_link.field_name, + label=source_link.label, + field_definition_id=source_link.field_definition_id, + readonly=source_link.readonly, + ) + db.add(target) + category.visible_fields.append(target) + target.active = source_link.active or source_link.field_name == "name" + target.required = True if source_link.field_name == "name" else source_link.required + target.show_in_list = True if source_link.field_name == "name" else source_link.show_in_list + target.sort_order = source_link.sort_order + changed += 1 + else: + raise HTTPException(400, _translate_request(request, "categories.fields.unsupported_operation", "Unsupported field operation")) + + db.commit() + message = _translate_request(request, "categories.fields.updated", "Field configuration updated for {count} categories", count=changed) + return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303) + +@app.post("/categories/activate-default-fields") +def categories_activate_default_fields( + request: Request, + category_ids: list[str] = Form([]), + db: Session = Depends(get_db), +): + _require_admin(request) + ids = _selected_asset_ids(category_ids) + if not ids: + return RedirectResponse("/categories?toast_warning=Keine Kategorien ausgewählt", status_code=303) + + categories = ( + db.query(Category) + .options(selectinload(Category.visible_fields).joinedload(CategoryField.definition)) + .filter(Category.id.in_(ids)) + .all() + ) + default_definitions = ( + db.query(FieldDefinition) + .filter(FieldDefinition.is_active.is_(True), FieldDefinition.is_default.is_(True)) + .all() + ) + if not default_definitions: + return RedirectResponse("/categories?toast_warning=Es sind keine Standardfelder markiert", status_code=303) + + activated = 0 + created_links = 0 + for category in categories: + by_definition = {link.field_definition_id: link for link in category.visible_fields if link.field_definition_id} + by_name = {link.field_name: link for link in category.visible_fields} + next_order = max((link.sort_order for link in category.visible_fields), default=-1) + 1 + for definition in default_definitions: + link = by_definition.get(definition.id) or by_name.get(definition.field_name) + if link is None: + link = CategoryField( + category_id=category.id, + field_name=definition.field_name, + label=definition.label, + field_definition_id=definition.id, + active=True, + required=definition.field_name == "name", + show_in_list=True, + readonly=definition.readonly, + sort_order=next_order, + ) + next_order += 1 + db.add(link) + created_links += 1 + else: + if not link.active: + link.active = True + activated += 1 + link.show_in_list = True + if definition.field_name == "name": + link.required = True + db.commit() + message = f"Standardfelder aktiviert: {activated} vorhandene und {created_links} neue Zuordnungen" + return RedirectResponse(f"/categories?toast_success={quote(message)}", status_code=303) + + +@app.get("/categories/new") +def category_new(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() + return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "definitions": definitions, "selected_fields": {}}) + + +@app.post("/categories/new") +async def category_create(request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + category = Category(name=name.strip(), description=description or None, image_path=save_upload(image)) + db.add(category) + db.flush() + definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() + for order, definition in enumerate(definitions): + active = form.get(f"active_{definition.id}") == "on" or definition.field_name == "name" + db.add(CategoryField(category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=active, required=True if definition.field_name == "name" else form.get(f"required_{definition.id}") == "on", show_in_list=True if definition.field_name == "name" else form.get(f"list_{definition.id}") == "on", readonly=definition.readonly, sort_order=order)) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc + return RedirectResponse("/categories", status_code=303) + + +@app.get("/categories/{category_id}/edit") +def category_edit(category_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + selected_fields = {f.field_name: f for f in category.visible_fields} + definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() + return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "definitions": definitions, "selected_fields": selected_fields}) + + +@app.post("/categories/{category_id}/edit") +async def category_update(category_id: int, request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + form = await request.form() + category.name = name.strip() + category.description = description.strip() or None + new_image = save_upload(image) + if new_image: + category.image_path = new_image + # Vorhandene Definitionen explizit löschen und flushen. Das verhindert + # UniqueConstraint-Verletzungen bei anschließend identischen Feldnamen. + db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False) + db.flush() + definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() + for order, definition in enumerate(definitions): + active = form.get(f"active_{definition.id}") == "on" or definition.field_name == "name" + db.add(CategoryField( + category_id=category.id, field_name=definition.field_name, label=definition.label, field_definition_id=definition.id, active=active, + required=True if definition.field_name == "name" else form.get(f"required_{definition.id}") == "on", + show_in_list=True if definition.field_name == "name" else form.get(f"list_{definition.id}") == "on", + readonly=definition.readonly, sort_order=order, + )) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc + return RedirectResponse("/categories", status_code=303) + + +@app.post("/categories/{category_id}/duplicate") +def category_duplicate(category_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + category = ( + db.query(Category) + .options(joinedload(Category.visible_fields)) + .filter(Category.id == category_id) + .first() + ) + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + + base_name = f"{category.name} (Kopie)" + new_name = base_name + number = 2 + while db.query(Category.id).filter(Category.name == new_name).first(): + new_name = f"{category.name} (Kopie {number})" + number += 1 + + duplicate = Category( + name=new_name, + description=category.description, + image_path=category.image_path, + ) + db.add(duplicate) + db.flush() + + for field in sorted(category.visible_fields, key=lambda item: item.sort_order): + db.add(CategoryField( + category_id=duplicate.id, + field_name=field.field_name, + label=field.label, + field_definition_id=field.field_definition_id, + active=field.active, + required=field.required, + show_in_list=field.show_in_list, + readonly=field.readonly, + sort_order=field.sort_order, + )) + + db.commit() + return RedirectResponse( + f"/categories/{duplicate.id}/edit?toast_success=Kategorie wurde dupliziert", + status_code=303, + ) + + +@app.post("/categories/{category_id}/delete") +def category_delete(category_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + + asset_count = db.query(Asset).filter(Asset.category_id == category.id).count() + if asset_count > 0: + return RedirectResponse( + f"/categories?toast_error=Kategorie kann nicht gelöscht werden: Sie wird noch von {asset_count} Asset(s) verwendet", + status_code=303, + ) + + db.delete(category) + db.commit() + return RedirectResponse( + "/categories?toast_success=Kategorie wurde gelöscht", + status_code=303, + ) + + + + + + +@app.get("/api/software-callback/health") +def software_callback_health(): + return { + "ok": True, + "service": "assetmanager-software-callback", + "version": application_version(), + "time": datetime.utcnow().isoformat() + "Z", + } + + +def _software_overview_statistics(db: Session) -> dict[str, int]: + """Return only the counters required by the software overview. + + Do not load every InstalledSoftware ORM row merely to display three numbers. + PostgreSQL performs the counting and grouping and returns only scalar values. + """ + total_entries, initialized_assets = ( + db.query( + func.count(InstalledSoftware.id), + func.count(func.distinct(InstalledSoftware.asset_id)), + ) + .one() + ) + + # Fetch only the three short grouping columns. The final Python casefold + # intentionally preserves the exact grouping semantics used by the existing + # detailed summary (Unicode casefold is stronger than SQL LOWER). + summary_rows = ( + db.query( + InstalledSoftware.name, + InstalledSoftware.publisher, + InstalledSoftware.platform, + ) + .distinct() + .all() + ) + summary_count = len({ + ( + (name or "").strip().casefold(), + (publisher or "").strip().casefold(), + (platform or "unknown").strip().casefold(), + ) + for name, publisher, platform in summary_rows + }) + + return { + "software_entry_count": int(total_entries or 0), + "software_summary_count": int(summary_count or 0), + "initialized_assets": int(initialized_assets or 0), + } + + +def _software_group_expressions(): + """Return normalized SQL expressions used by all software groupings.""" + name_key = func.lower(func.trim(func.coalesce(InstalledSoftware.name, ""))) + publisher_key = func.lower(func.trim(func.coalesce(InstalledSoftware.publisher, ""))) + platform_key = func.lower(func.trim(func.coalesce(InstalledSoftware.platform, "unknown"))) + version_key = func.lower(func.trim(func.coalesce(InstalledSoftware.version, ""))) + return name_key, publisher_key, platform_key, version_key + + +def _software_summary_rows(db: Session, grouping: str) -> list[dict[str, Any]]: + """Build the aggregated software list inside PostgreSQL. + + The query returns only already aggregated result rows. ``grouping=name_platform`` + combines changing publisher names and all versions by software name and + platform. ``grouping=software`` combines all versions by name, publisher and + platform. ``grouping=version`` keeps one row per version. + """ + name_key, publisher_key, platform_key, version_key = _software_group_expressions() + + common_columns = ( + name_key.label("name_key"), + publisher_key.label("publisher_key"), + platform_key.label("platform_key"), + func.min(func.coalesce(InstalledSoftware.name, "")).label("name"), + func.min(func.coalesce(InstalledSoftware.publisher, "")).label("publisher"), + func.min(func.coalesce(InstalledSoftware.platform, "unknown")).label("platform"), + ) + + if grouping == "name_platform": + publishers_array = func.array_agg( + distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.publisher, "")), "")) + ) + versions_array = func.array_agg( + distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), "")) + ) + query_rows = ( + db.query( + name_key.label("name_key"), + platform_key.label("platform_key"), + func.min(func.coalesce(InstalledSoftware.name, "")).label("name"), + func.min(func.coalesce(InstalledSoftware.platform, "unknown")).label("platform"), + publishers_array.label("publishers"), + versions_array.label("versions"), + func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"), + func.count(InstalledSoftware.id).label("entries"), + func.max(InstalledSoftware.last_seen_at).label("last_seen_at"), + ) + .group_by(name_key, platform_key) + .order_by(platform_key, name_key) + .all() + ) + + result: list[dict[str, Any]] = [] + for row in query_rows: + publishers = sorted( + {str(value).strip() for value in (row.publishers or []) if value and str(value).strip()}, + key=str.casefold, + ) + versions = sorted( + {str(value).strip() for value in (row.versions or []) if value and str(value).strip()}, + key=str.casefold, + ) + result.append({ + "name": row.name or "", + "publisher": ", ".join(publishers), + "platform": row.platform or "unknown", + "versions": ", ".join(versions), + "version_count": len(versions), + "asset_count": int(row.asset_count or 0), + "entries": int(row.entries or 0), + "last_seen_at": row.last_seen_at, + }) + return result + + if grouping == "version": + query_rows = ( + db.query( + *common_columns, + version_key.label("version_key"), + func.min(func.coalesce(InstalledSoftware.version, "")).label("version"), + func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"), + func.count(InstalledSoftware.id).label("entries"), + func.max(InstalledSoftware.last_seen_at).label("last_seen_at"), + ) + .group_by(name_key, publisher_key, platform_key, version_key) + .order_by(platform_key, name_key, publisher_key, version_key) + .all() + ) + return [ + { + "name": row.name or "", + "publisher": row.publisher or "", + "platform": row.platform or "unknown", + "versions": row.version or "", + "version_count": 1 if (row.version or "").strip() else 0, + "asset_count": int(row.asset_count or 0), + "entries": int(row.entries or 0), + "last_seen_at": row.last_seen_at, + } + for row in query_rows + ] + + # PostgreSQL performs the three-column grouping and returns the distinct + # version values as a compact array. Assets are counted distinctly across + # all versions, so the value remains correct even if an asset contains more + # than one version record for the same software. + versions_array = func.array_agg( + distinct(func.nullif(func.trim(func.coalesce(InstalledSoftware.version, "")), "")) + ) + query_rows = ( + db.query( + *common_columns, + versions_array.label("versions"), + func.count(func.distinct(InstalledSoftware.asset_id)).label("asset_count"), + func.count(InstalledSoftware.id).label("entries"), + func.max(InstalledSoftware.last_seen_at).label("last_seen_at"), + ) + .group_by(name_key, publisher_key, platform_key) + .order_by(platform_key, name_key, publisher_key) + .all() + ) + + result: list[dict[str, Any]] = [] + for row in query_rows: + versions = sorted( + {str(value).strip() for value in (row.versions or []) if value and str(value).strip()}, + key=str.casefold, + ) + result.append({ + "name": row.name or "", + "publisher": row.publisher or "", + "platform": row.platform or "unknown", + "versions": ", ".join(versions), + "version_count": len(versions), + "asset_count": int(row.asset_count or 0), + "entries": int(row.entries or 0), + "last_seen_at": row.last_seen_at, + }) + return result + + +def _software_installation_rows(db: Session): + """Return only columns rendered by the detailed installation table.""" + return ( + db.query( + InstalledSoftware.id.label("id"), + InstalledSoftware.asset_id.label("asset_id"), + Asset.name.label("asset_name"), + InstalledSoftware.name.label("name"), + InstalledSoftware.version.label("version"), + InstalledSoftware.publisher.label("publisher"), + InstalledSoftware.architecture.label("architecture"), + InstalledSoftware.platform.label("platform"), + InstalledSoftware.install_date.label("install_date"), + InstalledSoftware.last_seen_at.label("last_seen_at"), + ) + .join(Asset, InstalledSoftware.asset_id == Asset.id) + .order_by( + InstalledSoftware.name.asc(), + Asset.name.asc(), + InstalledSoftware.version.asc(), + InstalledSoftware.id.asc(), + ) + .all() + ) + + + +_SOFTWARE_JOB_FILTER_KEYS = ( + "job_id", + "created_at", + "asset", + "job_type", + "package", + "action", + "priority", + "platform", + "status", +) + + +def _software_job_filter_values(request: Request) -> dict[str, str]: + """Read and normalize the server-side filters of the recent-job table.""" + return { + key: str(request.query_params.get(f"job_filter_{key}") or "").strip()[:200] + for key in _SOFTWARE_JOB_FILTER_KEYS + } + + +def _sql_text_contains(column: Any, value: str): + """Case-insensitive literal substring search for SQL text expressions.""" + escaped = ( + str(value or "") + .casefold() + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + return func.lower(func.coalesce(cast(column, String), "")).like( + f"%{escaped}%", + escape="\\", + ) + + +def _translated_filter_codes( + request: Request, + prefix: str, + codes: tuple[str, ...], + search_value: str, +) -> list[str]: + """Map translated, partially entered labels back to their internal codes. + + The translation dictionaries are cached on the request so one filter + request does not open a database session for every possible status value. + """ + needle = str(search_value or "").strip().casefold() + if not needle: + return [] + + cache = getattr(request.state, "software_job_filter_translations", None) + if cache is None: + user = _session_user(request) or {} + language_code = ( + user.get("language_code") + or request.session.get("language_code") + or load_config().get("general", {}).get("default_language", "en") + ) + translation_db = SessionLocal() + try: + cache = { + "own": translation_dictionary(translation_db, language_code), + "en": translation_dictionary(translation_db, "en"), + } + finally: + translation_db.close() + request.state.software_job_filter_translations = cache + + matches: list[str] = [] + for code in codes: + key = f"{prefix}.{code}" + label = cache["own"].get(key) or cache["en"].get(key) or code + if needle in code.casefold() or needle in str(label).casefold(): + matches.append(code) + return matches + + +def _software_job_date_filter(column: Any, search_value: str): + """Support ISO/database text and the common German date formats.""" + value = str(search_value or "").strip() + conditions = [_sql_text_contains(column, value)] + for date_format in ("%d.%m.%Y", "%d.%m.%y", "%Y-%m-%d"): + try: + parsed = datetime.strptime(value, date_format) + except ValueError: + continue + conditions.append(and_(column >= parsed, column < parsed + timedelta(days=1))) + break + return or_(*conditions) + + +def _apply_software_job_filters(query: Any, request: Request, filters: dict[str, str]): + """Apply all recent-job filters before LIMIT is evaluated. + + This keeps an old job searchable even when the visible result size is only + 25 rows. The large script, result and log columns are still excluded from + the final SELECT by ``load_only`` below. + """ + parameters_text = cast(SoftwareJob.parameters, String) + + if filters["job_id"]: + value = filters["job_id"] + if value.isdigit(): + query = query.filter(SoftwareJob.id == int(value)) + else: + query = query.filter(_sql_text_contains(SoftwareJob.id, value)) + + if filters["created_at"]: + query = query.filter( + _software_job_date_filter(SoftwareJob.created_at, filters["created_at"]) + ) + + if filters["asset"]: + query = query.filter(_sql_text_contains(Asset.name, filters["asset"])) + + if filters["job_type"]: + value = filters["job_type"] + conditions = [ + _sql_text_contains(SoftwareJob.job_type, value), + _sql_text_contains(parameters_text, value), + ] + job_type_codes = _translated_filter_codes( + request, + "jobs.type", + ("software_inventory", "job_definition"), + value, + ) + if job_type_codes: + conditions.append(SoftwareJob.job_type.in_(job_type_codes)) + job_kind_codes = _translated_filter_codes( + request, + "jobdefs.kind", + ("script", "registry"), + value, + ) + for kind in job_kind_codes: + conditions.append( + and_( + SoftwareJob.job_type == "job_definition", + _sql_text_contains(parameters_text, kind), + ) + ) + query = query.filter(or_(*conditions)) + + if filters["package"]: + value = filters["package"] + query = query.filter( + or_( + _sql_text_contains(SoftwarePackage.name, value), + _sql_text_contains(parameters_text, value), + ) + ) + + if filters["action"]: + value = filters["action"] + conditions = [_sql_text_contains(SoftwareJob.action, value)] + action_codes = _translated_filter_codes( + request, + "jobs.action", + ("run", "install", "uninstall", "update"), + value, + ) + if action_codes: + conditions.append(SoftwareJob.action.in_(action_codes)) + query = query.filter(or_(*conditions)) + + if filters["priority"]: + value = filters["priority"] + if re.fullmatch(r"[-+]?\d+", value): + query = query.filter(SoftwareJob.priority == int(value)) + else: + query = query.filter(_sql_text_contains(SoftwareJob.priority, value)) + + if filters["platform"]: + query = query.filter( + _sql_text_contains(SoftwareJob.platform, filters["platform"]) + ) + + if filters["status"]: + value = filters["status"] + conditions = [_sql_text_contains(SoftwareJob.status, value)] + status_codes = _translated_filter_codes( + request, + "software.status", + ( + "created", + "sending", + "sent", + "running", + "success", + "partial", + "failed", + "timeout", + "cancelled", + ), + value, + ) + if status_codes: + conditions.append(SoftwareJob.status.in_(status_codes)) + query = query.filter(or_(*conditions)) + + return query + + +@app.get("/software") +def software_overview(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + page_started = time.perf_counter() + + timeout_started = time.perf_counter() + _expire_stale_software_jobs(db) + timeout_seconds = time.perf_counter() - timeout_started + + allowed_job_limits = (25, 50, 100, 500, 1000) + raw_limit = request.query_params.get("job_limit") + if raw_limit is None: + raw_limit = request.session.get("software_job_limit", 25) + try: + requested_limit = int(raw_limit) + except (TypeError, ValueError): + requested_limit = 25 + job_limit = requested_limit if requested_limit in allowed_job_limits else 25 + request.session["software_job_limit"] = job_limit + + stats_started = time.perf_counter() + statistics = _software_overview_statistics(db) + stats_seconds = time.perf_counter() - stats_started + + jobs_started = time.perf_counter() + job_filters = _software_job_filter_values(request) + filters_active = any(job_filters.values()) + + jobs_query = ( + db.query(SoftwareJob) + .join(Asset, Asset.id == SoftwareJob.asset_id) + .outerjoin(SoftwarePackage, SoftwarePackage.id == SoftwareJob.package_id) + ) + jobs_query = _apply_software_job_filters(jobs_query, request, job_filters) + + # Count after filtering but before LIMIT. This value is only needed while a + # filter is active and therefore adds no overhead to the normal overview. + jobs_filtered_total = ( + int(jobs_query.with_entities(func.count(SoftwareJob.id)).scalar() or 0) + if filters_active + else 0 + ) + + jobs = ( + jobs_query + .options( + load_only( + SoftwareJob.id, + SoftwareJob.asset_id, + SoftwareJob.package_id, + SoftwareJob.status, + SoftwareJob.job_type, + SoftwareJob.action, + SoftwareJob.priority, + SoftwareJob.platform, + SoftwareJob.parameters, + SoftwareJob.created_at, + ), + contains_eager(SoftwareJob.asset).load_only(Asset.id, Asset.name, Asset.mesh_node_id), + contains_eager(SoftwareJob.package).load_only(SoftwarePackage.id, SoftwarePackage.name), + ) + .order_by(SoftwareJob.created_at.desc(), SoftwareJob.id.desc()) + .limit(job_limit) + .all() + ) + _decorate_jobs_for_display(db, jobs) + jobs_seconds = time.perf_counter() - jobs_started + + response = templates.TemplateResponse( + "software.html", + { + "request": request, + "jobs": jobs, + "job_limit": job_limit, + "job_limit_options": allowed_job_limits, + "job_filters": job_filters, + "jobs_filters_active": filters_active, + "jobs_filtered_total": jobs_filtered_total, + **statistics, + }, + ) + total_seconds = time.perf_counter() - page_started + if total_seconds >= 0.5: + logger.info( + "Software overview timing: total=%.3fs timeout=%.3fs statistics=%.3fs jobs=%.3fs rows=%s", + total_seconds, + timeout_seconds, + stats_seconds, + jobs_seconds, + len(jobs), + ) + return response + + +@app.get("/software/job-states") +def software_job_states_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + _expire_stale_software_jobs(db) + rows = ( + db.query(AssetJobState, Asset, JobDefinition) + .join(Asset, Asset.id == AssetJobState.asset_id) + .outerjoin(JobDefinition, JobDefinition.id == AssetJobState.job_definition_id) + .order_by(AssetJobState.updated_at.desc(), func.lower(Asset.name), AssetJobState.state_key) + .all() + ) + job_states: list[dict[str, Any]] = [] + for state, asset, definition in rows: + if state.job_type == "job_definition": + job_label = definition.name if definition is not None else state.state_key + job_type_key = "jobs.type.job_definition" + job_type_fallback = "Jobdefinition" + elif state.job_type == "software_inventory": + job_label = _translate_request(request, "jobs.type.software_inventory", "Software inventory") + job_type_key = "jobs.type.software_inventory" + job_type_fallback = "Software inventory" + else: + job_label = state.state_key + job_type_key = f"jobs.type.{state.job_type}" + job_type_fallback = state.job_type + job_states.append({ + "state": state, + "asset": asset, + "job_label": job_label, + "job_type_key": job_type_key, + "job_type_fallback": job_type_fallback, + "last_execution": state.last_started_at or state.last_sent_at or state.last_status_at or state.last_created_at, + }) + return templates.TemplateResponse( + "software_job_states.html", + { + "request": request, + "job_states": job_states, + }, + ) + + +@app.post("/software/inventory/delete") +async def delete_software_inventory_entries(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + redirect_to = str(form.get("redirect_to") or "/software/installations") + if not redirect_to.startswith("/") or redirect_to.startswith("//"): + redirect_to = "/software/installations" + entry_ids = [] + for value in form.getlist("entry_ids"): + try: + entry_ids.append(int(value)) + except (TypeError, ValueError): + continue + entry_ids = list(dict.fromkeys(entry_ids)) + if not entry_ids: + return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_error=" + quote(_translate_request(request, "software.delete.none_selected", "No software inventory entries were selected.")), status_code=303) + deleted = db.query(InstalledSoftware).filter(InstalledSoftware.id.in_(entry_ids)).delete(synchronize_session=False) + db.commit() + message = _translate_request(request, "software.delete.result", "Deleted {count} software inventory entries.").format(count=deleted) + return RedirectResponse(redirect_to + ("&" if "?" in redirect_to else "?") + "toast_success=" + quote(message), status_code=303) + + +@app.get("/software/installations") +def software_installations_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + started = time.perf_counter() + software_entries = _software_installation_rows(db) + query_seconds = time.perf_counter() - started + response = templates.TemplateResponse( + "software_installations.html", + { + "request": request, + "software_entries": software_entries, + }, + ) + total_seconds = time.perf_counter() - started + if total_seconds >= 0.5: + logger.info( + "Software installations timing: total=%.3fs query=%.3fs rows=%s", + total_seconds, + query_seconds, + len(software_entries), + ) + return response + + +@app.get("/software/summary") +def software_summary_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + started = time.perf_counter() + allowed_groupings = {"name_platform", "software", "version"} + grouping = str( + request.query_params.get("grouping") + or request.session.get("software_summary_grouping") + or "software" + ).strip().lower() + if grouping not in allowed_groupings: + grouping = "software" + request.session["software_summary_grouping"] = grouping + + software_summary = _software_summary_rows(db, grouping) + query_seconds = time.perf_counter() - started + response = templates.TemplateResponse( + "software_summary.html", + { + "request": request, + "software_summary": software_summary, + "grouping": grouping, + }, + ) + total_seconds = time.perf_counter() - started + if total_seconds >= 0.5: + logger.info( + "Software summary timing: total=%.3fs query=%.3fs rows=%s grouping=%s", + total_seconds, + query_seconds, + len(software_summary), + grouping, + ) + return response + + +@app.get("/assets/{asset_id}/software") +def asset_software(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _get_visible_asset(db, request, asset_id) + software = db.query(InstalledSoftware).filter(InstalledSoftware.asset_id == asset.id).order_by(InstalledSoftware.name, InstalledSoftware.version).all() + jobs = db.query(SoftwareJob).options(joinedload(SoftwareJob.package)).filter(SoftwareJob.asset_id == asset.id).order_by(SoftwareJob.created_at.desc()).limit(50).all() + _decorate_jobs_for_display(db, jobs) + return templates.TemplateResponse("asset_software.html", {"request": request, "asset": asset, "software": software, "jobs": jobs, "is_admin": _is_admin(request)}) + + + +@app.post("/assets/{asset_id}/job-parameters/{definition_id}") +async def asset_job_parameter_overrides(asset_id:int, definition_id:int, request:Request, db:Session=Depends(get_db)): + _require_admin(request) + asset=_get_visible_asset(db,request,asset_id); definition=db.get(JobDefinition,definition_id) + if not definition: raise HTTPException(404) + form=await request.form(); valid_ids={x.id:x for x in definition.parameters} + existing={x.definition_parameter_id:x for x in db.query(JobDefinitionAssetParameter).filter(JobDefinitionAssetParameter.asset_id==asset.id, JobDefinitionAssetParameter.definition_parameter_id.in_(list(valid_ids) or [-1])).all()} + for parameter_id,parameter in valid_ids.items(): + use_override=str(form.get(f"override_{parameter_id}") or "") == "on" + row=existing.get(parameter_id) + if use_override: + if row is None: + row=JobDefinitionAssetParameter(asset_id=asset.id,definition_parameter_id=parameter_id);db.add(row) + row.value_text=str(form.get(f"value_{parameter_id}") or "");row.updated_by=_changed_by(request) + elif row is not None: + db.delete(row) + db.commit() + return RedirectResponse(f"/assets/{asset.id}?toast_success="+quote(_translate_request(request,"jobparams.saved","Asset-specific job parameters were saved.")),303) + +def _job_parameter_dict(job: SoftwareJob) -> dict[str, Any]: + return job.parameters if isinstance(job.parameters, dict) else {} + + +def _decorate_jobs_for_display(db: Session, jobs: list[SoftwareJob]) -> None: + """Attach user-facing job metadata without changing technical job fields. + + Job definitions intentionally use the technical database values + ``job_type=job_definition`` and the shared system package + ``Jobdefinition``. Those values are required by the dispatcher but are + not meaningful in the UI. The selected definition and its real kind are + therefore exposed as transient display attributes. + """ + definition_ids: set[int] = set() + definition_revisions: set[int] = set() + for job in jobs: + if job.job_type != "job_definition": + continue + parameters = _job_parameter_dict(job) + try: + definition_ids.add(int(parameters.get("definition_id"))) + except (TypeError, ValueError): + continue + try: + definition_revisions.add(int(parameters.get("definition_revision"))) + except (TypeError, ValueError): + pass + + definitions_by_id: dict[int, JobDefinition] = {} + revision_snapshots: dict[tuple[int, int], dict[str, Any]] = {} + if definition_ids: + definitions_by_id = { + definition.id: definition + for definition in ( + db.query(JobDefinition) + .filter(JobDefinition.id.in_(definition_ids)) + .all() + ) + } + if definition_revisions: + revision_snapshots = { + (row.definition_id, row.revision): (row.snapshot if isinstance(row.snapshot, dict) else {}) + for row in ( + db.query(JobDefinitionRevision) + .filter( + JobDefinitionRevision.definition_id.in_(definition_ids), + JobDefinitionRevision.revision.in_(definition_revisions), + ) + .all() + ) + } + + for job in jobs: + parameters = _job_parameter_dict(job) + package_name = job.package.name if job.package else "" + job.display_job_type_key = f"jobs.type.{job.job_type}" + job.display_job_type_fallback = job.job_type + job.display_package_name = package_name + job.display_definition_revision = None + job.display_creation_mode = str(parameters.get("_creation_mode") or "single") + job.display_bulk_batch_id = str(parameters.get("_bulk_batch_id") or "") + + if job.job_type != "job_definition": + continue + + definition = None + definition_id = None + definition_revision = None + try: + definition_id = int(parameters.get("definition_id")) + definition = definitions_by_id.get(definition_id) + except (TypeError, ValueError): + pass + try: + definition_revision = int(parameters.get("definition_revision")) + except (TypeError, ValueError): + pass + revision_snapshot = ( + revision_snapshots.get((definition_id, definition_revision), {}) + if definition_id is not None and definition_revision is not None + else {} + ) + definition_name = str( + parameters.get("definition_name") + or revision_snapshot.get("name") + or "" + ).strip() + if not definition_name and definition is not None: + definition_name = definition.name + definition_kind = str( + parameters.get("definition_job_kind") + or revision_snapshot.get("job_kind") + or "" + ).strip().lower() + if not definition_kind and definition is not None: + definition_kind = str(definition.job_kind or "script").strip().lower() + if definition_kind not in {"script", "registry"}: + definition_kind = "script" + + job.display_job_type_key = f"jobdefs.kind.{definition_kind}" + job.display_job_type_fallback = definition_kind + job.display_package_name = definition_name or package_name + job.display_definition_revision = ( + parameters.get("definition_revision") + if parameters.get("definition_revision") is not None + else (definition.revision if definition is not None else None) + ) + + +def _job_creation_metadata( + *, + creation_mode: str = "single", + bulk_batch_id: str | None = None, + bulk_position: int | None = None, + bulk_total: int | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = {"_creation_mode": "bulk" if creation_mode == "bulk" else "single"} + if bulk_batch_id: + metadata["_bulk_batch_id"] = bulk_batch_id + if bulk_position is not None: + metadata["_bulk_position"] = int(bulk_position) + if bulk_total is not None: + metadata["_bulk_total"] = int(bulk_total) + return metadata + + +def _create_definition_job( + db: Session, + request: Request, + asset: Asset, + definition: JobDefinition, + *, + creation_mode: str = "single", + bulk_batch_id: str | None = None, + bulk_position: int | None = None, + bulk_total: int | None = None, +) -> SoftwareJob: + if not asset.mesh_node_id: + raise ValueError("missing_node_id") + platform = detect_platform(asset) + if platform == "unknown": + raise ValueError("unknown_platform") + if definition.platform not in {platform, "all"}: + raise ValueError("platform_mismatch") + if not definition.enabled: + raise ValueError("definition_disabled") + package = _seed_generic_job_package(db) + token = secrets.token_urlsafe(32) + job = SoftwareJob( + asset_id=asset.id, package_id=package.id, status="created", + job_type="job_definition", action="run", priority=100, platform=platform, + parameters={ + "definition_id": definition.id, + "definition_name": definition.name, + "definition_revision": definition.revision, + "definition_job_kind": definition.job_kind, + "definition_interpreter": definition.interpreter, + "definition_source_type": definition.source_type, + "effective_parameters": _job_effective_parameters(db, definition, asset), + **_job_creation_metadata( + creation_mode=creation_mode, + bulk_batch_id=bulk_batch_id, + bulk_position=bulk_position, + bulk_total=bulk_total, + ), + }, + result_data={}, attempt_count=0, max_attempts=1, + callback_token_hash=token_hash(token), + callback_expires_at=datetime.utcnow() + timedelta(seconds=max(60, min(int(definition.timeout_seconds or 300) + 120, 86400))), + created_by=_changed_by(request), + ) + db.add(job) + db.flush() + sync_asset_job_state(db, job, increment_execution=True) + _record_job_event( + db, + job, + "created", + "created", + _translate_request( + request, + "jobs.event.created_named", + 'Job "{name}" created', + name=definition.name, + ), + _changed_by(request), + ) + db.commit() + db.refresh(job) + configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") + callback_base = configured or str(request.base_url).rstrip("/") + job_parameters = dict(job.parameters or {}) + job_parameters["_callback_base_url"] = callback_base + job.parameters = job_parameters + db.commit() + threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start() + return job + + +@app.post("/assets/{asset_id}/jobs/run") +def asset_run_job_definition(asset_id: int, request: Request, definition_id: int = Form(...), db: Session = Depends(get_db)): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + definition = db.get(JobDefinition, definition_id) + if not definition or not definition.enabled: + return RedirectResponse(f"/assets/{asset.id}?toast_error=" + quote(_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available.")), status_code=303) + try: + job = _create_definition_job(db, request, asset, definition) + except ValueError as exc: + key = str(exc) + messages = { + "missing_node_id": _translate_request(request, "jobs.asset_missing_node", "The asset has no MeshCentral node ID."), + "unknown_platform": _translate_request(request, "jobs.asset_unknown_platform", "The operating system platform could not be detected."), + "platform_mismatch": _translate_request(request, "jobs.platform_mismatch", "The job definition does not match the asset platform."), + "definition_disabled": _translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available."), + } + return RedirectResponse(f"/assets/{asset.id}?toast_error=" + quote(messages.get(key, key)), status_code=303) + return RedirectResponse(f"/software/jobs/{job.id}?toast_success=" + quote(_translate_request(request, "jobs.started", "Job started.")), status_code=303) + + +def _create_software_inventory_job( + db: Session, + request: Request, + asset: Asset, + *, + creation_mode: str = "single", + bulk_batch_id: str | None = None, + bulk_position: int | None = None, + bulk_total: int | None = None, +) -> SoftwareJob: + if not asset.mesh_node_id: + raise ValueError("missing_node_id") + platform = detect_platform(asset) + if platform == "android": + raise ValueError("unsupported_android") + if platform == "unknown": + raise ValueError("unknown_platform") + package = _seed_software_inventory_package(db) + token = secrets.token_urlsafe(32) + job = SoftwareJob( + asset_id=asset.id, package_id=package.id, status="created", + job_type="software_inventory", action="run", priority=100, platform=platform, + parameters=_job_creation_metadata( + creation_mode=creation_mode, + bulk_batch_id=bulk_batch_id, + bulk_position=bulk_position, + bulk_total=bulk_total, + ), + result_data={}, attempt_count=0, max_attempts=1, + callback_token_hash=token_hash(token), + callback_expires_at=datetime.utcnow() + timedelta( + minutes=max(5, min(int(_software_settings().get("callback_timeout_minutes", package.callback_timeout_minutes or 30) or 30), 240)) + ), + created_by=_changed_by(request), + ) + db.add(job) + db.flush() + sync_asset_job_state(db, job, increment_execution=True) + _record_job_event(db, job, "created", "created", _translate_request(request, "jobs.event.created", "Job created"), _changed_by(request)) + db.commit() + db.refresh(job) + configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") + callback_base = configured or str(request.base_url).rstrip("/") + job_parameters = dict(job.parameters or {}) + job_parameters["_callback_base_url"] = callback_base + job.parameters = job_parameters + db.commit() + threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start() + return job + + +@app.post("/assets/{asset_id}/software-inventory") +def asset_software_inventory( + asset_id: int, + request: Request, + return_to: str = Form("software-page"), + db: Session = Depends(get_db), +): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + try: + _create_software_inventory_job(db, request, asset) + except ValueError as exc: + messages = { + "missing_node_id": "Das Asset besitzt keine MeshCentral Node-ID.", + "unsupported_android": "Der MeshCentral-Android-Agent stellt derzeit keinen vergleichbaren RunCommand-Kanal für diese Inventur bereit.", + "unknown_platform": "Das Betriebssystem konnte nicht sicher erkannt werden.", + } + raise HTTPException(400, messages.get(str(exc), "Die Softwareinventur konnte nicht gestartet werden.")) + target = ( + f"/assets/{asset.id}?inventory_tab=software" + if return_to == "asset-detail" + else f"/assets/{asset.id}/software" + ) + return RedirectResponse( + target + f"&toast_success={quote(_translate_request(request, 'software.inventory_queued', 'Software inventory was submitted to MeshCentral.'))}" + if "?" in target + else target + f"?toast_success={quote(_translate_request(request, 'software.inventory_queued', 'Software inventory was submitted to MeshCentral.'))}", + status_code=303, + ) + + +@app.post("/jobs/bulk-create") +async def bulk_create_jobs(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + job_type = str(form.get("job_type") or "").strip() + definition_id_raw = str(form.get("definition_id") or "").strip() + definition = None + if definition_id_raw: + try: + definition = db.get(JobDefinition, int(definition_id_raw)) + except (TypeError, ValueError): + definition = None + if not definition or not definition.enabled: + return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available.")), status_code=303) + elif job_type != "software_inventory": + return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.bulk.invalid_type", "The selected job type is not available.")), status_code=303) + + asset_ids = [] + for value in form.getlist("asset_ids"): + try: asset_ids.append(int(value)) + except (TypeError, ValueError): continue + asset_ids = list(dict.fromkeys(asset_ids)) + if not asset_ids: + return RedirectResponse("/assets?toast_error=" + quote(_translate_request(request, "jobs.bulk.none_selected", "No assets were selected.")), status_code=303) + + visible_assets = _apply_asset_access(db.query(Asset), request).filter(Asset.id.in_(asset_ids)).all() + visible_by_id = {asset.id: asset for asset in visible_assets} + created = 0 + skipped = 0 + bulk_batch_id = uuid.uuid4().hex + bulk_total = len(asset_ids) + for bulk_position, asset_id in enumerate(asset_ids, start=1): + asset = visible_by_id.get(asset_id) + if not asset: + skipped += 1 + continue + try: + if definition is not None: + _create_definition_job( + db, + request, + asset, + definition, + creation_mode="bulk", + bulk_batch_id=bulk_batch_id, + bulk_position=bulk_position, + bulk_total=bulk_total, + ) + else: + _create_software_inventory_job( + db, + request, + asset, + creation_mode="bulk", + bulk_batch_id=bulk_batch_id, + bulk_position=bulk_position, + bulk_total=bulk_total, + ) + created += 1 + except ValueError: + skipped += 1 + + if created: + message = _translate_request(request, "jobs.bulk.result", "Created {created} jobs; skipped {skipped} assets.").format(created=created, skipped=skipped) + return RedirectResponse("/assets?toast_success=" + quote(message), status_code=303) + message = _translate_request(request, "jobs.bulk.none_created", "No job could be created; skipped {skipped} assets.").format(skipped=skipped) + return RedirectResponse("/assets?toast_error=" + quote(message), status_code=303) + + +@app.post("/assets/job-filter/start") +async def start_job_for_filtered_assets(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + category_id = _optional_form_int(form.get("category_id"), field_name="category_id", minimum=1) + job_filter = _normalize_asset_job_filter( + db, + selection=form.getlist("job_filter"), + excluded_statuses=form.getlist("job_filter_exclude"), + revision_mode=str(form.get("job_filter_revision") or "all"), + ) + if not job_filter: + return RedirectResponse( + "/assets?toast_error=" + quote(_translate_request(request, "assets.job_filter.invalid", "The job filter is invalid.")), + status_code=303, + ) + + start_selection = str(form.get("job_filter_start") or "").strip() + if not start_selection: + start_selection = job_filter["selections"][0] + if start_selection not in job_filter["selections"]: + return RedirectResponse( + _asset_job_filter_url( + job_filter, + category_id, + toast_error=_translate_request(request, "assets.job_filter.start_invalid", "Select one of the filtered jobs to run."), + ), + status_code=303, + ) + start_spec = next((spec for spec in job_filter["specs"] if spec["selection"] == start_selection), None) + if start_spec is None: + return RedirectResponse( + _asset_job_filter_url( + job_filter, + category_id, + toast_error=_translate_request(request, "assets.job_filter.start_invalid", "Select one of the filtered jobs to run."), + ), + status_code=303, + ) + definition = start_spec.get("definition") + if definition is not None and not definition.enabled: + return RedirectResponse( + _asset_job_filter_url( + job_filter, + category_id, + toast_error=_translate_request(request, "jobs.definition_unavailable", "The selected job definition is not available."), + ), + status_code=303, + ) + + query = _apply_asset_access(db.query(Asset), request) + if category_id: + query = query.filter(Asset.category_id == category_id) + candidate_assets = query.order_by(func.lower(Asset.name), Asset.id).all() + assets, _views = _filter_assets_by_job_states(db, candidate_assets, job_filter) + if not assets: + return RedirectResponse( + _asset_job_filter_url( + job_filter, + category_id, + toast_error=_translate_request(request, "assets.job_filter.no_assets", "No assets match this job filter."), + ), + status_code=303, + ) + + created = 0 + skipped = 0 + bulk_batch_id = uuid.uuid4().hex + bulk_total = len(assets) + for bulk_position, asset in enumerate(assets, start=1): + try: + if definition is not None: + _create_definition_job( + db, + request, + asset, + definition, + creation_mode="bulk", + bulk_batch_id=bulk_batch_id, + bulk_position=bulk_position, + bulk_total=bulk_total, + ) + else: + _create_software_inventory_job( + db, + request, + asset, + creation_mode="bulk", + bulk_batch_id=bulk_batch_id, + bulk_position=bulk_position, + bulk_total=bulk_total, + ) + created += 1 + except ValueError: + skipped += 1 + + if created: + message = _translate_request( + request, + "assets.job_filter.started", + "Created {created} jobs for the filtered assets; skipped {skipped} assets.", + created=created, + skipped=skipped, + ) + return RedirectResponse( + _asset_job_filter_url(job_filter, category_id, toast_success=message), + status_code=303, + ) + message = _translate_request( + request, + "jobs.bulk.none_created", + "No job could be created; skipped {skipped} assets.", + skipped=skipped, + ) + return RedirectResponse( + _asset_job_filter_url(job_filter, category_id, toast_error=message), + status_code=303, + ) + + +def _validate_software_callback_token(job_id: int, token: str) -> bool: + """Validate a callback outside the ASGI event loop. + + Returns True when the same valid token belongs to a job that was already + completed. Callback delivery is intentionally idempotent so a client may + safely retry after a lost HTTP response. + """ + db = SessionLocal() + try: + job = db.get(SoftwareJob, job_id) + if not job: + raise HTTPException(404, "Job nicht gefunden") + if not token or not hmac.compare_digest(token_hash(token), job.callback_token_hash): + raise HTTPException(401, "Ungültiger Callback-Token") + if datetime.utcnow() > job.callback_expires_at: + raise HTTPException(410, "Callback-Token ist abgelaufen") + return bool(job.callback_completed_at) + finally: + db.close() + + +def _decode_software_callback_body(raw_body: bytes) -> tuple[str, str, list[str]]: + decoded_body = "" + detected_encoding = "utf-8" + decode_errors: list[str] = [] + candidates: list[tuple[str, bytes]] = [] + if raw_body.startswith(b"\xef\xbb\xbf"): + candidates.append(("utf-8-sig", raw_body)) + elif raw_body.startswith(b"\xff\xfe"): + candidates.append(("utf-16-le", raw_body[2:])) + elif raw_body.startswith(b"\xfe\xff"): + candidates.append(("utf-16-be", raw_body[2:])) + candidates.extend([ + ("utf-8", raw_body), + ("utf-8-sig", raw_body), + ("utf-16-le", raw_body), + ("utf-16-be", raw_body), + ]) + + seen_encodings: set[str] = set() + for encoding, value in candidates: + if encoding in seen_encodings: + continue + seen_encodings.add(encoding) + try: + candidate = value.decode(encoding) + if candidate.lstrip().startswith(("{", "[")): + decoded_body = candidate + detected_encoding = encoding + break + except UnicodeDecodeError as exc: + decode_errors.append(f"{encoding}: {exc}") + + if not decoded_body: + decoded_body = raw_body.decode("utf-8", errors="replace") + detected_encoding = "utf-8-replacement" + return decoded_body, detected_encoding, decode_errors + + +def _process_software_job_callback( + job_id: int, + token: str, + raw_body: bytes, + content_type: str, + client_host: str, + queued_at: float, +) -> dict[str, Any]: + """Process one callback in the dedicated callback worker pool. + + Every worker creates its own SQLAlchemy session. The job row is locked so + duplicate callbacks cannot import the same result concurrently. Software + inventory replacement additionally locks the asset row, serializing only + callbacks that target the same asset while callbacks for other assets run + in parallel. + """ + started_at = time.monotonic() + _software_debug_log( + f"CALLBACK worker started | job={job_id} | client={client_host} | " + f"queue_wait_ms={(started_at - queued_at) * 1000:.1f} | workers={SOFTWARE_CALLBACK_WORKER_COUNT}" + ) + db = SessionLocal() + try: + job = ( + db.query(SoftwareJob) + .filter(SoftwareJob.id == job_id) + .with_for_update() + .first() + ) + if not job: + raise HTTPException(404, "Job nicht gefunden") + if not token or not hmac.compare_digest(token_hash(token), job.callback_token_hash): + raise HTTPException(401, "Ungültiger Callback-Token") + if datetime.utcnow() > job.callback_expires_at: + raise HTTPException(410, "Callback-Token ist abgelaufen") + if job.callback_completed_at: + _software_debug_log(f"CALLBACK duplicate accepted | job={job.id} | status={job.status}") + db.rollback() + return {"ok": True, "job_id": job.id, "status": job.status, "duplicate": True} + + decoded_body, detected_encoding, decode_errors = _decode_software_callback_body(raw_body) + content_length = len(raw_body) + _software_debug_log( + f"CALLBACK raw body | job={job_id} | encoding={detected_encoding} | BEGIN\n" + + decoded_body + + f"\nCALLBACK raw body | job={job_id} | END" + ) + + try: + payload = json.loads(decoded_body) + except Exception as exc: + failure_log = ( + f"JSON parsing failed: {type(exc).__name__}: {exc}\n" + f"Detected encoding: {detected_encoding}\n" + f"Content-Type: {content_type}\n" + f"Body bytes: {content_length}\n" + f"\n--- Complete callback body ---\n{decoded_body}" + ) + job.log_text = ((job.log_text or "") + "\n\n--- Callback parsing error ---\n" + failure_log)[-2_000_000:] + job.message = "Callback empfangen, aber JSON konnte nicht verarbeitet werden." + db.commit() + _software_debug_log( + f"CALLBACK JSON ERROR | job={job_id} | {type(exc).__name__}: {exc}" + + (f" | decode_errors={'; '.join(decode_errors)}" if decode_errors else "") + ) + raise HTTPException(400, "Ungültiges JSON") from exc + + if not isinstance(payload, dict): + job.log_text = ( + (job.log_text or "") + + f"\n\n--- Callback parsing error ---\nJSON root type: {type(payload).__name__}\n" + + f"\n--- Complete callback body ---\n{decoded_body}" + )[-2_000_000:] + job.message = "Callback empfangen, aber kein JSON-Objekt erhalten." + db.commit() + _software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}") + raise HTTPException(400, "JSON-Objekt erwartet") + + _software_debug_log( + f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}" + ) + log_parts: list[str] = [] + for label, key in (("Log", "log"), ("STDOUT", "stdout"), ("STDERR", "stderr")): + value = payload.get(key) + if value is None or value == "": + continue + text_value = str(value) + if len(text_value) > 200000: + raise HTTPException(413, f"{label} ist zu groß") + log_parts.append(f"--- {label} ---\n{text_value}") + + status = str(payload.get("status") or "running").lower() + job.status = status if status in {"running", "success", "failed", "partial"} else "running" + if job.started_at is None: + job.started_at = datetime.utcnow() + try: + job.exit_code = int(payload.get("exit_code")) if payload.get("exit_code") is not None else job.exit_code + except (TypeError, ValueError): + pass + if payload.get("message") is not None: + job.message = str(payload.get("message"))[:4000] + + summary_lines = [ + f"Callback received: {datetime.utcnow().isoformat()}Z", + f"Status: {job.status}", + f"Exit code: {job.exit_code if job.exit_code is not None else '-'}", + ] + if job.message: + summary_lines.append(f"Message: {job.message}") + callback_log = "\n".join(summary_lines + (["", *log_parts] if log_parts else [])) + job.log_text = ((job.log_text or "") + "\n\n--- Client callback ---\n" + callback_log).strip()[-2_000_000:] + job.result_data = { + str(key): value + for key, value in payload.items() + if key not in {"software", "log"} + } + + software = payload.get("software") + stored_software_count = 0 + excluded_software_count = 0 + if isinstance(software, list): + _software_debug_log(f"CALLBACK software processing started | job={job.id} | received={len(software)}") + # Serialize inventory replacement only per asset. Other assets are + # still processed concurrently by the callback worker pool. + db.query(Asset.id).filter(Asset.id == job.asset_id).with_for_update().first() + now = datetime.utcnow() + removed_count = ( + db.query(InstalledSoftware) + .filter(InstalledSoftware.asset_id == job.asset_id) + .delete(synchronize_session=False) + ) + _software_debug_log(f"CALLBACK previous software removed | job={job.id} | rows={removed_count}") + seen: set[tuple[str, str, str, str]] = set() + inventory_rows: list[dict[str, Any]] = [] + inventory_platform = (job.platform or "unknown")[:40].lower() + exclusion_rules = _software_settings().get("inventory_exclusion_rules", []) + for item in software[:50000]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + if _software_name_is_excluded(name, inventory_platform, exclusion_rules): + excluded_software_count += 1 + continue + values = ( + name[:300], + str(item.get("version") or "")[:160], + str(item.get("publisher") or "")[:220], + str(item.get("architecture") or "")[:40], + ) + identity = tuple(value.casefold() for value in values) + if identity in seen: + continue + seen.add(identity) + inventory_rows.append({ + "asset_id": job.asset_id, + "name": values[0], + "version": values[1], + "publisher": values[2], + "architecture": values[3], + "platform": inventory_platform, + "install_date": str(item.get("install_date") or "")[:40] or None, + "source": "meshcentral-inventory", + "first_seen_at": now, + "last_seen_at": now, + }) + if inventory_rows: + db.bulk_insert_mappings(InstalledSoftware, inventory_rows) + stored_software_count = len(inventory_rows) + _software_debug_log( + f"CALLBACK software filtered | job={job.id} | received={len(software)} | " + f"excluded={excluded_software_count} | stored={stored_software_count} | " + f"rules={len(exclusion_rules)}" + ) + if isinstance(job.result_data, dict): + job.result_data["software_inventory"] = { + "received": len(software), + "excluded": excluded_software_count, + "stored": stored_software_count, + } + + if bool(payload.get("completed")) or job.status in {"success", "failed"}: + job.callback_completed_at = datetime.utcnow() + job.finished_at = datetime.utcnow() + language_code = str(load_config().get("general", {}).get("default_language", "de") or "de") + event_message = job.message or translate( + db, + language_code, + "jobs.event.callback", + "Callback received", + ) + _record_job_event(db, job, "callback", job.status, event_message) + sync_asset_job_state(db, job) + db.commit() + processing_ms = (time.monotonic() - started_at) * 1000 + _software_debug_log( + f"CALLBACK database commit successful | job={job.id} | stored_software={stored_software_count} | excluded_software={excluded_software_count} | processing_ms={processing_ms:.1f}" + ) + _software_debug_log( + f"CALLBACK accepted | job={job.id} | status={job.status} | completed={bool(job.callback_completed_at)} | " + f"software_entries={len(software) if isinstance(software, list) else 0}" + ) + return {"ok": True, "job_id": job.id, "status": job.status} + except HTTPException: + db.rollback() + raise + except Exception: + db.rollback() + logger.exception("Software callback worker failed for job %s", job_id) + raise + finally: + db.close() + + +@app.post("/api/software-jobs/{job_id}/callback") +async def software_job_callback(job_id: int, request: Request, token: str = ""): + client_host = request.client.host if request.client else "-" + content_type = request.headers.get("content-type", "-") + _software_debug_log( + f"CALLBACK received | job={job_id} | client={client_host} | content_type={content_type} | " + f"workers={SOFTWARE_CALLBACK_WORKER_COUNT}" + ) + + # The short authentication query runs outside the ASGI event loop. It is + # repeated under a row lock inside the worker to close race conditions. + already_completed = await asyncio.to_thread(_validate_software_callback_token, job_id, token) + if already_completed: + _software_debug_log(f"CALLBACK duplicate accepted before body read | job={job_id} | client={client_host}") + return {"ok": True, "job_id": job_id, "duplicate": True} + try: + raw_body = await request.body() + except ClientDisconnect: + _software_debug_log( + f"CALLBACK client disconnected while uploading body | job={job_id} | client={client_host}" + ) + return Response(status_code=499) + + _software_debug_log(f"CALLBACK body received | job={job_id} | bytes={len(raw_body)}") + queued_at = time.monotonic() + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + SOFTWARE_CALLBACK_EXECUTOR, + _process_software_job_callback, + job_id, + token, + raw_body, + content_type, + client_host, + queued_at, + ) + + +@app.get("/api/software-jobs/{job_id}") +def software_job_status(job_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + _expire_stale_software_jobs(db, job_id) + job = db.get(SoftwareJob, job_id) + if not job: + raise HTTPException(404, "Job not found") + return { + "id": job.id, + "job_type": job.job_type, + "action": job.action, + "priority": job.priority, + "status": job.status, + "status_label": _translate_request(request, f"software.status.{job.status}", job.status), + "message": job.message or "", + "exit_code": job.exit_code, + "attempt_count": job.attempt_count, + "max_attempts": job.max_attempts, + "finished_at": _utc_iso(job.finished_at), + "log_text": job.log_text or "", + } + + +def _prepare_existing_job_restart( + db: Session, + request: Request | None, + job: SoftwareJob, + *, + automatic: bool = False, +) -> str: + """Reset an existing job for another attempt and return its callback token. + + Manual and automatic retries use this same tested reset path. The caller + commits the transaction and starts ``execute_job`` afterwards. + """ + token = secrets.token_urlsafe(32) + package_timeout = getattr(job.package, "callback_timeout_minutes", None) or 30 + timeout_minutes = max( + 5, + min( + int(_software_settings().get("callback_timeout_minutes", package_timeout) or 30), + 240, + ), + ) + job.callback_token_hash = token_hash(token) + job.callback_expires_at = datetime.utcnow() + timedelta(minutes=timeout_minutes) + job.callback_completed_at = None + job.status = "created" + job.sent_at = None + job.started_at = None + job.finished_at = None + job.exit_code = None + job.message = None + job.result_data = {} + job.meshctrl_stdout = None + job.meshctrl_stderr = None + job.command_preview = None + job.log_text = "" + job.scheduled_at = None + job.max_attempts = max(int(job.max_attempts or 1), int(job.attempt_count or 0) + 1) + if automatic: + language_code = str(load_config().get("general", {}).get("default_language", "de") or "de") + event_type = "automatic_retry_requested" + event_message = translate( + db, + language_code, + "jobs.event.automatic_retry_requested", + "Automatic retry requested", + ) + created_by = "system:auto-retry" + else: + event_type = "restart_requested" + event_message = _translate_request(request, "jobs.event.restart_requested", "Manual restart requested") + created_by = _changed_by(request) + _record_job_event( + db, + job, + event_type, + "created", + event_message, + created_by, + ) + sync_asset_job_state(db, job, increment_execution=True) + return token + + +@app.post("/api/software-jobs/statuses") +def software_job_statuses( + request: Request, + job_ids: list[int] = Body(...), + db: Session = Depends(get_db), +): + """Return compact live status data for the visible software-job rows.""" + _require_admin(request) + normalized_ids: list[int] = [] + seen_ids: set[int] = set() + for value in job_ids[:1000]: + try: + job_id = int(value) + except (TypeError, ValueError): + continue + if job_id <= 0 or job_id in seen_ids: + continue + seen_ids.add(job_id) + normalized_ids.append(job_id) + if not normalized_ids: + return {"jobs": []} + + _expire_stale_software_jobs(db) + rows = ( + db.query(SoftwareJob.id, SoftwareJob.status) + .filter(SoftwareJob.id.in_(normalized_ids)) + .all() + ) + user = _session_user(request) or {} + language_code = ( + user.get("language_code") + or request.session.get("language_code") + or load_config().get("general", {}).get("default_language", "en") + ) + status_labels: dict[str, str] = {} + result = [] + for job_id, status in rows: + normalized_status = str(status or "created") + if normalized_status not in status_labels: + status_labels[normalized_status] = translate( + db, + language_code, + f"software.status.{normalized_status}", + normalized_status, + ) + result.append({ + "id": int(job_id), + "status": normalized_status, + "status_label": status_labels[normalized_status], + }) + return {"jobs": result} + + +@app.post("/jobs/{job_id}/restart") +def restart_job(job_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + job = db.query(SoftwareJob).options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)).filter(SoftwareJob.id == job_id).first() + if not job: + raise HTTPException(404, _translate_request(request, "jobs.not_found", "Job not found")) + _expire_stale_software_jobs(db, job.id) + db.refresh(job) + if job.status in {"sending", "running"}: + return RedirectResponse( + f"/jobs/{job.id}?toast_error=" + quote(_translate_request(request, "jobs.restart.active", "An active job cannot be restarted.")), + status_code=303, + ) + if not job.asset or not job.asset.mesh_node_id: + return RedirectResponse( + f"/jobs/{job.id}?toast_error=" + quote(_translate_request(request, "jobs.restart.no_node", "The asset has no MeshCentral node ID.")), + status_code=303, + ) + + token = _prepare_existing_job_restart(db, request, job) + db.commit() + + configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") + callback_base = configured or str(request.base_url).rstrip("/") + threading.Thread(target=execute_job, args=(job.id, token, callback_base), daemon=True).start() + return RedirectResponse( + f"/jobs/{job.id}?toast_success=" + quote(_translate_request(request, "jobs.restart.started", "Job was queued again.")), + status_code=303, + ) + + +@app.post("/jobs/restart-selected") +async def restart_selected_jobs(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + job_ids: list[int] = [] + for value in form.getlist("job_ids"): + try: + job_ids.append(int(value)) + except (TypeError, ValueError): + continue + job_ids = list(dict.fromkeys(job_ids)) + if not job_ids: + return RedirectResponse( + "/software?toast_error=" + quote(_translate_request(request, "jobs.bulk_restart.none_selected", "No jobs were selected.")), + status_code=303, + ) + + _expire_stale_software_jobs(db) + jobs = ( + db.query(SoftwareJob) + .options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)) + .filter(SoftwareJob.id.in_(job_ids)) + .all() + ) + by_id = {job.id: job for job in jobs} + queued: list[tuple[int, str]] = [] + skipped = 0 + for job_id in job_ids: + job = by_id.get(job_id) + if ( + job is None + or job.status in {"success", "sending", "running"} + or not job.asset + or not job.asset.mesh_node_id + ): + skipped += 1 + continue + token = _prepare_existing_job_restart(db, request, job) + queued.append((job.id, token)) + + if not queued: + db.rollback() + return RedirectResponse( + "/software?toast_error=" + quote(_translate_request(request, "jobs.bulk_restart.none_eligible", "None of the selected jobs can be restarted.")), + status_code=303, + ) + + db.commit() + configured = str(_software_settings().get("callback_base_url", "") or "").strip().rstrip("/") + callback_base = configured or str(request.base_url).rstrip("/") + for queued_job_id, token in queued: + threading.Thread(target=execute_job, args=(queued_job_id, token, callback_base), daemon=True).start() + + message = _translate_request( + request, + "jobs.bulk_restart.started", + "{count} jobs were queued again; {skipped} were skipped.", + count=len(queued), + skipped=skipped, + ) + return RedirectResponse( + "/software?toast_success=" + quote(message), + status_code=303, + ) + + +@app.get("/jobs/{job_id}") +def job_detail(job_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + job = db.query(SoftwareJob).options(joinedload(SoftwareJob.asset), joinedload(SoftwareJob.package)).filter(SoftwareJob.id == job_id).first() + if not job: + raise HTTPException(404, _translate_request(request, "jobs.not_found", "Job not found")) + _expire_stale_software_jobs(db, job.id) + db.refresh(job) + _decorate_jobs_for_display(db, [job]) + events = db.query(JobEvent).filter(JobEvent.job_id == job.id).order_by(JobEvent.created_at.desc(), JobEvent.id.desc()).all() + can_restart = job.status not in {"sending", "running"} + return templates.TemplateResponse("software_job.html", {"request": request, "job": job, "events": events, "can_restart": can_restart}) + + +@app.get("/software/jobs/{job_id}") +def software_job_detail_legacy(job_id: int, request: Request): + _require_admin(request) + return RedirectResponse(f"/jobs/{job_id}", status_code=307) + + +@app.get("/login") +def login_page(request: Request): + if _session_user(request): + return RedirectResponse("/profile", status_code=303) + mode = load_config().get("authentication", {}).get("mode", "none") + return templates.TemplateResponse("login.html", {"request": request, "mode": mode}) + + +@app.post("/login") +def login_submit(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): + username = username.strip() + auth_config = load_config().get("authentication", {}) + mode = str(auth_config.get("mode", "none")).lower() + user = db.query(User).filter(func.lower(User.username) == username.casefold()).first() + valid = False + + # Ein geschütztes lokales Konto wird auch im LDAP-Modus ausschließlich + # gegen sein lokales Passwort geprüft. Bei falschem Passwort gibt es + # bewusst keinen LDAP-Fallback für denselben Benutzernamen. + protected_local = bool( + user + and getattr(user, "is_protected", False) + and user.auth_source == "local" + ) + if protected_local and mode in {"local", "ldap"}: + valid = bool(user.is_active and _verify_password(password, user.password_hash)) + elif mode == "local": + valid = bool(user and user.is_active and user.auth_source == "local" and _verify_password(password, user.password_hash)) + elif mode == "ldap": + ldap_user = _ldap_authenticate(username, password, auth_config) + if ldap_user: + if not user: + user = User(username=username, auth_source="ldap", is_active=True) + db.add(user) + if user.is_active: + user.auth_source = "ldap" + user.display_name = ldap_user.get("display_name") or username + user.email = ldap_user.get("email") or None + valid = True + elif mode == "none": + return RedirectResponse("/?toast_warning=Die Anmeldung ist derzeit deaktiviert", status_code=303) + if not valid or not user: + logger.warning("Fehlgeschlagene Anmeldung für Benutzer %s im Modus %s", username, mode) + return RedirectResponse("/login?toast_error=Benutzername oder Passwort ist ungültig", status_code=303) + user.last_login = datetime.utcnow() + db.commit() + db.refresh(user) + _login_user(request, user) + return RedirectResponse("/?toast_success=Anmeldung erfolgreich", status_code=303) + + +@app.get("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/?toast_success=Sie wurden abgemeldet", status_code=303) + + +@app.get("/profile") +def profile(request: Request, db: Session = Depends(get_db)): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user: + request.session.clear() + return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303) + return templates.TemplateResponse("profile.html", {"request": request, "user": user, "languages": i18n_languages(db)}) + + +@app.post("/profile/language") +def profile_language(request: Request, language_code: str = Form(...), next_url: str = Form("/profile"), db: Session = Depends(get_db)): + available = {item.code for item in i18n_languages(db)} + if language_code not in available: + raise HTTPException(400, "Unsupported language") + session_user = _session_user(request) + if session_user: + user = db.query(User).filter(User.id == session_user.get("id")).first() + if user: + user.language_code = language_code + db.commit() + _login_user(request, user) + request.session["language_code"] = language_code + safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/" + return RedirectResponse(f"{safe_next}?toast_success=Language changed", status_code=303) + +@app.post("/profile/browser-state") +def profile_browser_state( + request: Request, + persist_browser_state: str | None = Form(None), + db: Session = Depends(get_db), +): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user: + request.session.clear() + return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303) + user.persist_browser_state = persist_browser_state == "on" + db.commit() + _login_user(request, user) + message = "Browserspeicherung aktiviert" if user.persist_browser_state else "Browserspeicherung deaktiviert" + return RedirectResponse(f"/profile?toast_success={quote(message)}", status_code=303) + + +@app.post("/profile/toast-settings") +def profile_toast_settings( + request: Request, + toast_position: str = Form("top-right"), + toast_duration_seconds: int = Form(6), + db: Session = Depends(get_db), +): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user: + request.session.clear() + return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303) + allowed_positions = {"top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"} + user.toast_position = toast_position if toast_position in allowed_positions else "top-right" + user.toast_duration_seconds = max(1, min(int(toast_duration_seconds or 6), 120)) + db.commit() + _login_user(request, user) + return RedirectResponse("/profile?toast_success=Toast-Einstellungen gespeichert", status_code=303) + + +@app.post("/profile/theme") +def profile_theme( + request: Request, + theme_mode: str = Form("light"), + next_url: str = Form("/profile"), + db: Session = Depends(get_db), +): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user: + request.session.clear() + return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303) + + user.theme_mode = theme_mode if theme_mode in {"light", "dark"} else "light" + db.commit() + _login_user(request, user) + + safe_next = next_url if next_url.startswith("/") and not next_url.startswith("//") else "/profile" + message = _translate_request(request, "appearance.theme_saved", "Display mode saved") + return RedirectResponse(f"{safe_next}?toast_success={quote(message)}", status_code=303) + + +@app.post("/profile/password") +def profile_password(request: Request, current_password: str = Form(...), new_password: str = Form(...), db: Session = Depends(get_db)): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user or user.auth_source != "local": + return RedirectResponse("/profile?toast_warning=Passwortänderungen sind nur für lokale Konten möglich", status_code=303) + if not _verify_password(current_password, user.password_hash): + return RedirectResponse("/profile?toast_error=Das aktuelle Passwort ist falsch", status_code=303) + if len(new_password) < 8: + return RedirectResponse("/profile?toast_error=Das neue Passwort muss mindestens 8 Zeichen lang sein", status_code=303) + user.password_hash = _hash_password(new_password) + db.commit() + return RedirectResponse("/profile?toast_success=Passwort geändert", status_code=303) + + +@app.get("/users") +def users_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + users = db.query(User).order_by(User.username).all() + mesh_groups = [row[0] for row in db.query(Asset.mesh_group).filter(Asset.mesh_group.isnot(None), Asset.mesh_group != "").distinct().order_by(Asset.mesh_group).all()] + return templates.TemplateResponse("users.html", {"request": request, "users": users, "auth": public_config().get("authentication", {}), "mesh_groups": mesh_groups}) + + +@app.post("/users/new") +def user_create(request: Request, username: str = Form(...), display_name: str = Form(""), email: str = Form(""), password: str = Form(...), is_admin: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + username = username.strip() + if not username: + raise HTTPException(400, "Benutzername fehlt") + if len(password) < 8: + raise HTTPException(400, "Das Passwort muss mindestens 8 Zeichen lang sein") + if db.query(User.id).filter(func.lower(User.username) == username.casefold()).first(): + raise HTTPException(400, "Der Benutzername ist bereits vorhanden") + user = User(username=username, display_name=display_name.strip() or username, email=email.strip() or None, password_hash=_hash_password(password), auth_source="local", is_active=True, is_admin=is_admin == "on", language_code=load_config().get("general", {}).get("default_language", "en")) + db.add(user) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Der Benutzername ist bereits vorhanden") from exc + return RedirectResponse("/users?toast_success=Benutzer angelegt", status_code=303) + + +@app.post("/users/{user_id}/update") +def user_update(user_id: int, request: Request, display_name: str = Form(""), email: str = Form(""), profile_department: str = Form(""), profile_location: str = Form(""), is_active: str | None = Form(None), is_admin: str | None = Form(None), new_password: str = Form(""), mesh_groups: list[str] = Form([]), asset_access_scopes: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "Benutzer nicht gefunden") + user.display_name = display_name.strip() or user.username + user.email = email.strip() or None + if getattr(user, "is_protected", False): + user.is_active = True + user.is_admin = True + user.auth_source = "local" + else: + user.is_active = is_active == "on" + user.is_admin = is_admin == "on" + user.allowed_mesh_groups = sorted({value.strip() for value in mesh_groups if value.strip()}) + user.asset_access_scopes = sorted({value for value in asset_access_scopes if value in ASSET_ACCESS_SCOPES}) + user.profile_department = profile_department.strip() or None + user.profile_location = profile_location.strip() or None + if new_password: + if user.auth_source != "local": + raise HTTPException(400, "LDAP-Passwörter können hier nicht geändert werden") + if len(new_password) < 8: + raise HTTPException(400, "Das neue Passwort muss mindestens 8 Zeichen lang sein") + user.password_hash = _hash_password(new_password) + db.commit() + return RedirectResponse("/users?toast_success=Benutzer gespeichert", status_code=303) + + + +@app.post("/users/{user_id}/delete") +def user_delete(user_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + user = db.get(User, user_id) + if not user: + raise HTTPException(404, "Benutzer nicht gefunden") + if getattr(user, "is_protected", False): + raise HTTPException(400, _translate_request( + request, + "users.protected_delete_forbidden", + "Protected local administrators cannot be deleted.", + )) + session_user = _session_user(request) or {} + if int(session_user.get("id") or 0) == user.id: + raise HTTPException(400, "Der aktuell angemeldete Benutzer kann nicht gelöscht werden.") + if user.is_admin and user.is_active: + other_admins = db.query(User).filter(User.id != user.id, User.is_admin.is_(True), User.is_active.is_(True)).count() + if other_admins == 0: + raise HTTPException(400, "Der letzte aktive Administrator kann nicht gelöscht werden.") + db.delete(user) + db.commit() + return RedirectResponse("/users?toast_success=Benutzer gelöscht", status_code=303) + + +def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]: + """Listet AD-Benutzer über das konfigurierte LDAP-Dienstkonto auf. + + Eine leere Suche liefert alle aktiven Benutzerkonten. Bei einem Suchtext + werden Benutzername, Anzeigename und E-Mail durchsucht. + """ + from ldap3 import ALL, Connection, Server, SUBTREE + from ldap3.core.exceptions import LDAPException + from ldap3.utils.conv import escape_filter_chars + + ldap = auth_config.get("ldap", {}) + server_name = str(ldap.get("server", "")).strip() + use_ssl = bool(ldap.get("use_ssl", False)) + start_tls = bool(ldap.get("start_tls", False)) + port = int(ldap.get("port") or (636 if use_ssl else 389)) + bind_dn = str(ldap.get("bind_dn", "")).strip() + env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip() + bind_password = os.getenv(env_name, "") or str(ldap.get("bind_password", "")) + base_dn = str(ldap.get("user_base_dn", "")).strip() + + if not server_name: + raise HTTPException(400, "LDAP-Server ist nicht konfiguriert") + if not base_dn: + raise HTTPException(400, "LDAP Benutzer-Basis-DN ist nicht konfiguriert") + if bind_dn and not bind_password: + raise HTTPException(400, f"LDAP-Bind-Passwort fehlt (Umgebungsvariable {env_name})") + + raw_query = query_text.strip() + + def ldap_wildcard_pattern(value: str) -> str: + # Benutzer-Wildcards zulassen, sonstige LDAP-Sonderzeichen aber sicher maskieren. + # * = beliebig viele Zeichen. + parts = [] + literal = [] + for char in value: + if char == "*": + if literal: + parts.append(escape_filter_chars("".join(literal))) + literal = [] + parts.append("*") + else: + literal.append(char) + if literal: + parts.append(escape_filter_chars("".join(literal))) + pattern = "".join(parts) + # Ohne explizite Wildcard bleibt die bisherige Teilstringsuche erhalten. + if "*" not in value: + pattern = f"*{pattern}*" + return pattern + + base_filter = "(&(objectCategory=person)(objectClass=user)(sAMAccountName=*)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))" + if raw_query: + q = ldap_wildcard_pattern(raw_query) + search_filter = ( + "(&(objectCategory=person)(objectClass=user)(sAMAccountName=*)" + "(!(userAccountControl:1.2.840.113556.1.4.803:=2))" + f"(|(sAMAccountName={q})(displayName={q})(mail={q})))" + ) + else: + search_filter = base_filter + + server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10) + conn = Connection( + server, + user=bind_dn or None, + password=bind_password or None, + auto_bind=False, + receive_timeout=30, + raise_exceptions=False, + ) + try: + # ldap3.Connection.open() liefert je nach Version keinen verlässlichen + # booleschen Rückgabewert. Daher nur öffnen und anschließend binden. + if start_tls and not use_ssl: + conn.open() + if not conn.start_tls(): + raise HTTPException(400, f"LDAP StartTLS fehlgeschlagen: {conn.last_error}") + if not conn.bind(): + raise HTTPException(400, f"LDAP Bind fehlgeschlagen: {conn.last_error}") + + attributes = ["sAMAccountName", "displayName", "mail", "distinguishedName"] + entries = conn.extend.standard.paged_search( + search_base=base_dn, + search_filter=search_filter, + search_scope=SUBTREE, + attributes=attributes, + paged_size=500, + generator=False, + ) + result = [] + for entry in entries: + if entry.get("type") != "searchResEntry": + continue + attrs = entry.get("attributes", {}) + username = str(attrs.get("sAMAccountName") or "").strip() + if not username: + continue + result.append({ + "username": username, + "display_name": str(attrs.get("displayName") or username), + "email": str(attrs.get("mail") or ""), + "dn": str(entry.get("dn") or attrs.get("distinguishedName") or ""), + }) + result.sort(key=lambda item: ((item.get("display_name") or item["username"]).casefold(), item["username"].casefold())) + ldap_logger.info("LDAP-Benutzerabfrage erfolgreich: Filter=%r Treffer=%s", search_filter, len(result)) + return result + except HTTPException: + raise + except LDAPException as exc: + ldap_logger.exception("LDAP-Ausnahme bei Benutzerabfrage: %s", exc) + raise HTTPException(400, f"LDAP-Abfrage fehlgeschlagen: {exc}") from exc + except Exception as exc: + ldap_logger.exception("Unerwarteter LDAP-Fehler bei Benutzerabfrage: %s", exc) + raise HTTPException(400, f"LDAP-Abfrage fehlgeschlagen: {exc}") from exc + finally: + try: + conn.unbind() + except Exception: + pass + + +def _mark_existing_ldap_users(results: list[dict], db: Session) -> list[dict]: + usernames = [item["username"].casefold() for item in results] + existing = {} + if usernames: + existing = { + user.username.casefold(): user + for user in db.query(User).filter(func.lower(User.username).in_(usernames)).all() + } + for item in results: + user = existing.get(item["username"].casefold()) + item["existing"] = bool(user) + item["existing_source"] = user.auth_source if user else "" + return results + + +@app.get("/settings/ldap-search") +def ldap_search_form(request: Request, show_all: int = 0, db: Session = Depends(get_db)): + _require_admin(request) + results = [] + loaded = False + if show_all: + cfg = load_config() + results = _mark_existing_ldap_users(_ldap_search_users("", cfg.get("authentication", {})), db) + loaded = True + return templates.TemplateResponse("ldap_search.html", { + "request": request, "query": "", "results": results, "loaded": loaded, + }) + + +@app.post("/settings/ldap-search") +def ldap_search_page(request: Request, ldap_query: str = Form(""), db: Session = Depends(get_db)): + _require_admin(request) + cfg = load_config() + results = _mark_existing_ldap_users(_ldap_search_users(ldap_query, cfg.get("authentication", {})), db) + return templates.TemplateResponse("ldap_search.html", { + "request": request, "query": ldap_query, "results": results, "loaded": True, + }) + + +@app.post("/settings/ldap-search/import") +def ldap_users_import( + request: Request, + usernames: list[str] = Form([]), + db: Session = Depends(get_db), +): + _require_admin(request) + selected = {value.strip().casefold() for value in usernames if value.strip()} + if not selected: + return RedirectResponse("/settings/ldap-search?toast_warning=Keine Benutzer ausgewählt", status_code=303) + + cfg = load_config() + ldap_users = _ldap_search_users("", cfg.get("authentication", {})) + ldap_by_name = {item["username"].casefold(): item for item in ldap_users} + created = 0 + updated = 0 + skipped = 0 + for normalized in selected: + item = ldap_by_name.get(normalized) + if not item: + skipped += 1 + continue + user = db.query(User).filter(func.lower(User.username) == item["username"].casefold()).first() + if user: + # Lokale Konten werden nicht automatisch in LDAP-Konten umgewandelt. + if user.auth_source != "ldap": + skipped += 1 + continue + user.display_name = item["display_name"] or item["username"] + user.email = item["email"] or None + updated += 1 + else: + db.add(User( + username=item["username"], + display_name=item["display_name"] or item["username"], + email=item["email"] or None, + password_hash=None, + auth_source="ldap", + is_active=True, + is_admin=False, + allowed_mesh_groups=[], + language_code=load_config().get("general", {}).get("default_language", "en"), + )) + created += 1 + db.commit() + return RedirectResponse( + f"/users?toast_success={created} LDAP-Benutzer angelegt, {updated} aktualisiert, {skipped} übersprungen", + status_code=303, + ) + +@app.get("/settings/translations") +def translations_page(request: Request, missing_only: int = 0, db: Session = Depends(get_db)): + _require_admin(request) + langs = i18n_languages(db, active_only=False) + rows = {} + for item in db.query(Translation).order_by(Translation.translation_key, Translation.language_code).all(): + rows.setdefault(item.translation_key, {})[item.language_code] = item.text_value + if missing_only: + rows = {key: values for key, values in rows.items() if any(not values.get(lang.code) for lang in langs)} + return templates.TemplateResponse("translations.html", {"request": request, "languages": langs, "rows": rows, "missing_only": bool(missing_only)}) + +@app.post("/settings/translations/save") +async def translations_save(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + langs = {lang.code for lang in i18n_languages(db, active_only=False)} + for field, value in form.multi_items(): + if not field.startswith("tr__"): + continue + _, code, key = field.split("__", 2) + if code not in langs: + continue + row = db.query(Translation).filter(Translation.translation_key == key, Translation.language_code == code).first() + if row: + row.text_value = str(value) + elif str(value).strip(): + db.add(Translation(translation_key=key, language_code=code, text_value=str(value))) + db.commit(); clear_translation_cache() + return RedirectResponse("/settings/translations?toast_success=Translations updated", status_code=303) + +@app.get("/settings/translations/export") +def translations_export(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + langs = i18n_languages(db, active_only=False) + data = {} + for item in db.query(Translation).all(): + data.setdefault(item.translation_key, {})[item.language_code] = item.text_value + headers = ["Key"] + [lang.code for lang in langs] + rows = [[key] + [values.get(lang.code, "") for lang in langs] for key, values in sorted(data.items())] + return _xlsx_response("assetmanager-translations.xlsx", headers, rows) + +@app.post("/settings/translations/import") +async def translations_import(request: Request, file: UploadFile = File(...), ignore_empty: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + payload = await file.read() + workbook = load_workbook(io.BytesIO(payload), data_only=True) + sheet = workbook.active + headers = [str(cell.value or "").strip() for cell in sheet[1]] + if not headers or headers[0].casefold() != "key": + raise HTTPException(400, "The first Excel column must be Key") + known = {lang.code for lang in i18n_languages(db, active_only=False)} + columns = [(idx, code) for idx, code in enumerate(headers[1:], start=2) if code in known] + changed = 0 + for values in sheet.iter_rows(min_row=2, values_only=True): + key = str(values[0] or "").strip() + if not key: + continue + for idx, code in columns: + value = values[idx-1] + text_value = "" if value is None else str(value) + if not text_value and ignore_empty == "on": + continue + row = db.query(Translation).filter(Translation.translation_key == key, Translation.language_code == code).first() + if row: + row.text_value = text_value + else: + db.add(Translation(translation_key=key, language_code=code, text_value=text_value)) + changed += 1 + db.commit(); clear_translation_cache() + return RedirectResponse(f"/settings/translations?toast_success={changed} translations imported", status_code=303) + +@app.get("/settings/appearance") +def settings_appearance_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "settings": public_config(), + "categories": categories, + "languages": i18n_languages(db), + "settings_section": "appearance", + "system_storage": system_storage_information(), + }, + ) + + +@app.get("/settings/authentication") +def settings_authentication_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "settings": public_config(), + "categories": categories, + "languages": i18n_languages(db), + "settings_section": "authentication", + "system_storage": system_storage_information(), + }, + ) + + +@app.get("/settings/system") +def settings_system_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "settings": public_config(), + "categories": categories, + "languages": i18n_languages(db), + "settings_section": "system", + "system_storage": system_storage_information(), + }, + ) + + +@app.get("/settings/import-export") +def settings_import_export_page(request: Request): + _require_admin(request) + return templates.TemplateResponse( + "settings_import_export.html", + {"request": request}, + ) + + +@app.get("/settings/software") +def settings_software_page(request: Request): + _require_admin(request) + settings = _software_settings() + callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") + return templates.TemplateResponse( + "settings_software.html", + { + "request": request, + "software_settings": settings, + "callback_health_url": f"{callback_base}/api/software-callback/health" if callback_base else "", + "debug_log": _software_debug_tail(), + "debug_log_path": str(SOFTWARE_CALLBACK_DEBUG_LOG), + }, + ) + + +@app.post("/settings/software") +def settings_software_save( + request: Request, + dispatch_delay_seconds: int = Form(2), + callback_base_url: str = Form(""), + callback_timeout_minutes: int = Form(30), + callback_worker_count: int = Form(4), + callback_test_timeout_seconds: int = Form(10), + callback_test_verify_tls: str | None = Form(None), + debug_logging: str | None = Form(None), + debug_log_max_lines: int = Form(1000), + automatic_retry_enabled: str | None = Form(None), + automatic_retry_max_age_hours: int = Form(12), + automatic_retry_interval_seconds: int = Form(60), + automatic_retry_max_retries: int = Form(3), + inventory_exclusion_platform: list[str] = Form(default=[]), + inventory_exclusion_name: list[str] = Form(default=[]), +): + _require_admin(request) + callback_base_url = callback_base_url.strip().rstrip("/") + if callback_base_url: + parsed = urllib.parse.urlsplit(callback_base_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return RedirectResponse( + "/settings/software?toast_error=" + quote(_translate_request(request, "software.settings.invalid_url", "Enter a valid HTTP or HTTPS base URL.")), + status_code=303, + ) + + submitted_exclusion_rules = _normalize_software_exclusion_rules([ + {"platform": platform, "name_contains": name} + for platform, name in zip(inventory_exclusion_platform, inventory_exclusion_name) + ]) + + current = load_config() + software = dict(current.get("software", {})) + software.update({ + "dispatch_delay_seconds": max(0, min(int(dispatch_delay_seconds), 60)), + "callback_base_url": callback_base_url, + "callback_timeout_minutes": max(5, min(int(callback_timeout_minutes), 240)), + "callback_worker_count": max(1, min(int(callback_worker_count), 8)), + "callback_test_timeout_seconds": max(2, min(int(callback_test_timeout_seconds), 60)), + "callback_test_verify_tls": callback_test_verify_tls == "on", + "debug_logging": debug_logging == "on", + "debug_log_max_lines": max(100, min(int(debug_log_max_lines), 10000)), + "automatic_retry_enabled": automatic_retry_enabled == "on", + "automatic_retry_max_age_hours": max(1, min(int(automatic_retry_max_age_hours), 168)), + "automatic_retry_interval_seconds": max(10, min(int(automatic_retry_interval_seconds), 86400)), + "automatic_retry_max_retries": max(1, min(int(automatic_retry_max_retries), 10)), + "inventory_exclusion_rules": submitted_exclusion_rules, + }) + save_config({"software": software}) + _software_debug_log( + f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or ''} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}", + force=True, + ) + return RedirectResponse( + "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.saved", "Software settings saved.")), + status_code=303, + ) + + +@app.post("/settings/software/test-callback") +def settings_software_test_callback(request: Request): + _require_admin(request) + settings = _software_settings() + callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") + if not callback_base: + callback_base = str(request.base_url).rstrip("/") + health_url = f"{callback_base}/api/software-callback/health" + timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60)) + verify_tls = bool(settings.get("callback_test_verify_tls", True)) + parsed = urllib.parse.urlsplit(health_url) + + _software_debug_log("=" * 72, force=True) + _software_debug_log("CALLBACK TEST started", force=True) + _software_debug_log(f"Base URL: {callback_base}", force=True) + _software_debug_log(f"Health URL: {health_url}", force=True) + _software_debug_log(f"Scheme: {parsed.scheme} | Host: {parsed.hostname} | Port: {parsed.port or ('443' if parsed.scheme == 'https' else '80')}", force=True) + _software_debug_log(f"Timeout: {timeout}s | Verify TLS: {verify_tls}", force=True) + + try: + dns_started = time.monotonic() + addresses = sorted({item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)}) + _software_debug_log(f"DNS: {', '.join(addresses)} ({(time.monotonic()-dns_started)*1000:.1f} ms)", force=True) + except Exception as exc: + _software_debug_log(f"DNS ERROR: {type(exc).__name__}: {exc}", force=True) + + context = None + if parsed.scheme == "https" and not verify_tls: + context = ssl._create_unverified_context() + + request_started = time.monotonic() + try: + req = urllib.request.Request( + health_url, + headers={"User-Agent": f"AssetManager/{application_version()} CallbackTest"}, + method="GET", + ) + with urllib.request.urlopen(req, timeout=timeout, context=context) as response: + body = response.read(65536).decode("utf-8", errors="replace") + elapsed = (time.monotonic() - request_started) * 1000 + _software_debug_log(f"HTTP STATUS: {response.status} {response.reason} ({elapsed:.1f} ms)", force=True) + _software_debug_log(f"Content-Type: {response.headers.get('Content-Type', '-')}", force=True) + _software_debug_log(f"Response body: {body}", force=True) + payload = json.loads(body) + if response.status == 200 and payload.get("ok") is True: + _software_debug_log("RESULT: Callback health endpoint is reachable.", force=True) + return RedirectResponse( + "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.test_success", "Callback endpoint is reachable.")), + status_code=303, + ) + raise ValueError("Health response did not contain ok=true") + except urllib.error.HTTPError as exc: + body = exc.read(65536).decode("utf-8", errors="replace") + _software_debug_log(f"HTTP ERROR: {exc.code} {exc.reason}", force=True) + _software_debug_log(f"Response body: {body}", force=True) + message = f"HTTP {exc.code}: {exc.reason}" + except Exception as exc: + _software_debug_log(f"REQUEST ERROR: {type(exc).__name__}: {exc}", force=True) + message = f"{type(exc).__name__}: {exc}" + + _software_debug_log("RESULT: Callback health endpoint is not reachable.", force=True) + return RedirectResponse( + "/settings/software?toast_error=" + quote(_translate_request(request, "software.settings.test_failed", "Callback test failed: {error}", error=message)), + status_code=303, + ) + + +@app.get("/settings/software/debug-log") +def settings_software_debug_log(request: Request): + _require_admin(request) + return Response(_software_debug_tail(), media_type="text/plain; charset=utf-8", headers={"Cache-Control": "no-store"}) + + +@app.post("/settings/software/debug-log/clear") +def settings_software_debug_log_clear(request: Request): + _require_admin(request) + try: + SOFTWARE_CALLBACK_DEBUG_LOG.unlink(missing_ok=True) + except OSError: + logger.exception("Software callback debug log could not be cleared") + return RedirectResponse( + "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.log_cleared", "Debug log cleared.")), + status_code=303, + ) + + +@app.get("/settings/backup") +def settings_backup_page(request: Request): + _require_admin(request) + return templates.TemplateResponse( + "settings_backup.html", + { + "request": request, + "backups": list_backups(), + "backup_directory": str(BACKUP_DIR), + "backup_interval_hours": BACKUP_INTERVAL_HOURS, + "backup_retention_days": BACKUP_RETENTION_DAYS, + }, + ) + + +@app.post("/settings/backup/create") +def settings_backup_create(request: Request): + _require_admin(request) + try: + created_by = _changed_by(request) or "admin" + create_backup(created_by=created_by, reason="manual") + return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.created_success", "Backup created")), status_code=303) + except Exception as exc: + logger.exception("Backup creation failed") + return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) + + +@app.get("/settings/backup/{backup_name}/download") +def settings_backup_download(request: Request, backup_name: str): + _require_admin(request) + try: + path = backup_path(backup_name) + if not path.is_file(): + raise HTTPException(status_code=404) + return FileResponse(path, media_type="application/zip", filename=path.name) + except ValueError: + raise HTTPException(status_code=404) + + +@app.post("/settings/backup/{backup_name}/delete") +def settings_backup_delete(request: Request, backup_name: str): + _require_admin(request) + try: + delete_backup(backup_name) + return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.deleted_success", "Backup deleted")), status_code=303) + except Exception as exc: + logger.exception("Backup deletion failed") + return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) + + +@app.post("/settings/backup/upload") +async def settings_backup_upload(request: Request, backup_file: UploadFile = File(...)): + _require_admin(request) + try: + content = await backup_file.read() + store_uploaded_backup(backup_file.filename or "backup.zip", content) + return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.uploaded_success", "Backup uploaded")), status_code=303) + except Exception as exc: + logger.exception("Backup upload failed") + return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) + + +@app.post("/settings/backup/{backup_name}/restore") +def settings_backup_restore(request: Request, backup_name: str, confirmation: str = Form("")): + _require_admin(request) + if confirmation.strip().upper() != "RESTORE": + return RedirectResponse("/settings/backup?toast_warning=" + quote(_translate_request(request, "backup.restore_confirmation_missing", "Restore confirmation is missing")), status_code=303) + try: + # Safety copy immediately before replacing data. + create_backup(created_by="system", reason="before_restore") + restore_backup(backup_name) + clear_translation_cache() + return RedirectResponse("/settings/backup?toast_success=" + quote(_translate_request(request, "backup.restored_success", "Backup restored")), status_code=303) + except Exception as exc: + logger.exception("Backup restore failed") + return RedirectResponse("/settings/backup?toast_error=" + quote(str(exc)), status_code=303) + + +@app.get("/settings") +def settings_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).order_by(Category.name.asc()).all() + return templates.TemplateResponse( + "settings.html", + { + "request": request, + "settings": public_config(), + "categories": categories, + "languages": i18n_languages(db), + "settings_section": "appearance", + "system_storage": system_storage_information(), + }, + ) + + +THEME_COLOR_DEFAULTS = { + "primary_color": "#0878bd", + "primary_hover_color": "#075f94", + "header_color": "#063c66", + "navigation_color": "#ffffff", + "page_background": "#f2f4f6", + "surface_color": "#ffffff", + "text_color": "#1f2933", + "border_color": "#d7dde3", + "success_color": "#198754", + "warning_color": "#d97706", + "danger_color": "#b42318", +} + + +def _safe_hex_color(value: str, fallback: str) -> str: + candidate = (value or "").strip() + return candidate.lower() if re.fullmatch(r"#[0-9a-fA-F]{6}", candidate) else fallback + + +@app.post("/settings") +async def settings_save( + request: Request, + title: str = Form("AssetManager"), + company_name: str = Form(""), + about_author: str = Form(""), + about_organization: str = Form(""), + about_contact: str = Form(""), + about_website: str = Form(""), + about_repository: str = Form(""), + about_license: str = Form(""), + about_copyright: str = Form(""), + callback_base_url: str = Form(""), + default_language: str = Form("en"), + custom_css: str = Form(""), + default_theme: str = Form("light"), + primary_color: str = Form("#0878bd"), + primary_hover_color: str = Form("#075f94"), + header_color: str = Form("#063c66"), + navigation_color: str = Form("#ffffff"), + page_background: str = Form("#f2f4f6"), + surface_color: str = Form("#ffffff"), + text_color: str = Form("#1f2933"), + border_color: str = Form("#d7dde3"), + success_color: str = Form("#198754"), + warning_color: str = Form("#d97706"), + danger_color: str = Form("#b42318"), + mesh_fallback_category_id: int = Form(1), + mesh_category_1: int | None = Form(None), + mesh_category_2: int | None = Form(None), + mesh_category_3: int | None = Form(None), + mesh_category_4: int | None = Form(None), + mesh_category_5: int | None = Form(None), + mesh_category_6: int | None = Form(None), + mesh_category_7: int | None = Form(None), + mesh_category_8: int | None = Form(None), + auth_mode: str = Form("none"), + ldap_server: str = Form(""), + ldap_port: int = Form(389), + ldap_use_ssl: str | None = Form(None), + ldap_start_tls: str | None = Form(None), + ldap_bind_dn: str = Form(""), + ldap_bind_password_env: str = Form("LDAP_BIND_PASSWORD"), + ldap_user_base_dn: str = Form(""), + ldap_user_filter: str = Form("(sAMAccountName={username})"), + ldap_display_name_attribute: str = Form("displayName"), + ldap_email_attribute: str = Form("mail"), + remove_logo: str | None = Form(None), + remove_favicon: str | None = Form(None), + logo: UploadFile | None = File(None), + favicon: UploadFile | None = File(None), +): + _require_admin(request) + + current = load_config() + general = dict(current.get("general", {})) + general["title"] = title.strip() or "AssetManager" + general["company_name"] = company_name.strip() + general["about_author"] = about_author.strip() + general["about_organization"] = about_organization.strip() + general["about_contact"] = about_contact.strip() + general["about_website"] = about_website.strip() + general["about_repository"] = about_repository.strip() + general["about_license"] = about_license.strip() + general["about_copyright"] = about_copyright.strip() + general["callback_base_url"] = callback_base_url.strip().rstrip("/") + general["default_language"] = default_language if default_language in {"en", "de"} else "en" + general["custom_css"] = custom_css.replace("\x00", "").strip() + general["default_theme"] = default_theme if default_theme in {"light", "dark"} else "light" + colors = {} + submitted_colors = { + "primary_color": primary_color, + "primary_hover_color": primary_hover_color, + "header_color": header_color, + "navigation_color": navigation_color, + "page_background": page_background, + "surface_color": surface_color, + "text_color": text_color, + "border_color": border_color, + "success_color": success_color, + "warning_color": warning_color, + "danger_color": danger_color, + } + for color_name, fallback in THEME_COLOR_DEFAULTS.items(): + colors[color_name] = _safe_hex_color(submitted_colors.get(color_name, ""), fallback) + general["colors"] = colors + if remove_logo == "on": + old_logo = general.get("logo") + if old_logo: + delete_asset_image(old_logo) + general["logo"] = "" + if logo and logo.filename: + old_logo = general.get("logo") + new_logo = save_upload(logo) + if old_logo and old_logo != new_logo: + delete_asset_image(old_logo) + general["logo"] = new_logo or "" + + if remove_favicon == "on": + old_favicon = general.get("favicon") + if old_favicon: + delete_asset_image(old_favicon) + general["favicon"] = "" + if favicon and favicon.filename: + old_favicon = general.get("favicon") + new_favicon = save_upload(favicon) + if old_favicon and old_favicon != new_favicon: + delete_asset_image(old_favicon) + general["favicon"] = new_favicon or "" + if auth_mode not in {"none", "local", "ldap"}: + auth_mode = "none" + authentication = dict(current.get("authentication", {})) + authentication["mode"] = auth_mode + ldap = dict(authentication.get("ldap", {})) + ldap.update({ + "server": ldap_server.strip(), + "port": ldap_port, + "use_ssl": ldap_use_ssl == "on", + "start_tls": ldap_start_tls == "on", + "bind_dn": ldap_bind_dn.strip(), + "bind_password_env": ldap_bind_password_env.strip() or "LDAP_BIND_PASSWORD", + "user_base_dn": ldap_user_base_dn.strip(), + "user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})", + "display_name_attribute": ldap_display_name_attribute.strip() or "displayName", + "email_attribute": ldap_email_attribute.strip() or "mail", + }) + authentication["ldap"] = ldap + meshcentral = dict(current.get("meshcentral", {})) + meshcentral["fallback_category_id"] = int(mesh_fallback_category_id or 1) + meshcentral["category_mapping"] = { + "1": mesh_category_1, + "2": mesh_category_2, + "3": mesh_category_3, + "4": mesh_category_4, + "5": mesh_category_5, + "6": mesh_category_6, + "7": mesh_category_7, + "8": mesh_category_8, + } + save_config({"general": general, "authentication": authentication, "meshcentral": meshcentral}) + return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303) + + +@app.get("/sync/meshcentral") +def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + runs = db.query(SyncRun).order_by(SyncRun.started_at.desc()).limit(20).all() + linked = db.query(Asset).filter(Asset.mesh_node_id.isnot(None)).count() + conflicts = db.query(Asset).filter(Asset.mesh_sync_status == "conflict").count() + missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count() + definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() + mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all() + return templates.TemplateResponse("meshcentral_sync.html", { + "request": request, "runs": runs, "config": public_config(), + "linked": linked, "conflicts": conflicts, "missing": missing, + "definitions": definitions, + "mappings": mappings, + "transform_options": [ + "raw", "string", "integer", "decimal", "boolean", "json", + "normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac", + "sum_memory_gb", "format_memory_modules", "sum_storage_gb", "format_drives", + "gpu_names", "active_antivirus", + ], + }) + + +@app.post("/sync/meshcentral/start") +def meshcentral_sync_start(request: Request): + _require_admin(request) + with SYNC_JOBS_LOCK: + if any(not job.get("finished") for job in SYNC_JOBS.values()): + return JSONResponse({"error": "Es läuft bereits eine Synchronisierung."}, status_code=409) + job_id = uuid.uuid4().hex + SYNC_JOBS[job_id] = {"status": "running", "finished": False, "run_id": None, "logs": [], "created_at": datetime.now().isoformat()} + _job_log(job_id, "info", "Synchronisierungsauftrag angelegt.") + threading.Thread(target=_run_sync_job, args=(job_id,), daemon=True).start() + return {"job_id": job_id} + + +@app.get("/sync/meshcentral/jobs/{job_id}") +def meshcentral_sync_job(job_id: str, offset: int = 0): + with SYNC_JOBS_LOCK: + job = SYNC_JOBS.get(job_id) + if not job: + raise HTTPException(404, "Synchronisierungsauftrag nicht gefunden") + logs = list(job["logs"][max(0, offset):]) + return { + "status": job["status"], + "finished": job["finished"], + "run_id": job["run_id"], + "logs": logs, + "next_offset": max(0, offset) + len(logs), + } + + +@app.post("/sync/meshcentral") +def meshcentral_sync_run(): + return RedirectResponse("/sync/meshcentral", status_code=303) + +# --------------------------------------------------------------------------- +# v0.3.8.0: central field catalog and MeshCentral synchronization settings +# --------------------------------------------------------------------------- + +@app.get('/settings/fields') +def field_definitions_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + definitions = db.query(FieldDefinition).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() + value_counts = dict(db.query(AssetFieldValue.field_definition_id, __import__('sqlalchemy').func.count(AssetFieldValue.id)).group_by(AssetFieldValue.field_definition_id).all()) + return templates.TemplateResponse('field_definitions.html', {'request': request, 'definitions': definitions, 'data_types': FIELD_DATA_TYPES, 'value_counts': value_counts}) + +@app.get('/settings/fields/new') +def field_definition_new(request: Request): + _require_admin(request) + return templates.TemplateResponse('field_definition_form.html', {'request': request, 'definition': None, 'data_types': FIELD_DATA_TYPES}) + +@app.post('/settings/fields/new') +def field_definition_create(request: Request, field_name: str = Form(...), label: str = Form(...), description: str = Form(''), data_type: str = Form('short_text'), readonly: str | None = Form(None), searchable: str | None = Form(None), filterable: str | None = Form(None), chart_enabled: str | None = Form(None), excel_import: str | None = Form(None), excel_export: str | None = Form(None), bulk_edit: str | None = Form(None), is_default: str | None = Form(None), list_width: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + name = re.sub(r'[^a-z0-9_]', '_', field_name.strip().lower()).strip('_') + if not name or name in ASSET_FIELDS: + raise HTTPException(400, 'The field name is invalid or already reserved.') + if data_type not in FIELD_DATA_TYPES: + raise HTTPException(400, 'Invalid data type.') + definition = FieldDefinition(field_name=name, label=label.strip(), description=description.strip() or None, data_type=data_type, readonly=readonly == 'on', is_system=False, is_active=True, translation_key=f'field.{name}', searchable=searchable == 'on', filterable=filterable == 'on', chart_enabled=chart_enabled == 'on', excel_import=excel_import == 'on', excel_export=excel_export == 'on', bulk_edit=bulk_edit == 'on', is_default=is_default == 'on', list_width=_optional_form_int(list_width, field_name="list_width", minimum=40, maximum=800), sort_order=(db.query(FieldDefinition).count()+1)) + db.add(definition) + try: + db.flush() + for category in db.query(Category).all(): + db.add(CategoryField(category_id=category.id, field_name=name, label=definition.label, field_definition_id=definition.id, active=False, required=False, show_in_list=False, readonly=definition.readonly, sort_order=definition.sort_order)) + # Add English fallback translation; German can be maintained in translation editor. + db.add(Translation(translation_key=definition.translation_key, language_code='en', text_value=definition.label)) + db.commit(); clear_translation_cache() + except IntegrityError as exc: + db.rollback(); raise HTTPException(400, 'A field with this name already exists.') from exc + return RedirectResponse('/settings/fields?toast_success=Field created', status_code=303) + +@app.get('/settings/fields/{definition_id}/edit') +def field_definition_edit(definition_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + definition = db.get(FieldDefinition, definition_id) + if not definition: raise HTTPException(404, 'Field not found') + return templates.TemplateResponse('field_definition_form.html', {'request': request, 'definition': definition, 'data_types': FIELD_DATA_TYPES}) + +@app.post('/settings/fields/{definition_id}/edit') +def field_definition_update(definition_id: int, request: Request, label: str = Form(...), description: str = Form(''), data_type: str = Form('short_text'), readonly: str | None = Form(None), is_active: str | None = Form(None), searchable: str | None = Form(None), filterable: str | None = Form(None), chart_enabled: str | None = Form(None), excel_import: str | None = Form(None), excel_export: str | None = Form(None), bulk_edit: str | None = Form(None), is_default: str | None = Form(None), list_width: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + definition = db.get(FieldDefinition, definition_id) + if not definition: raise HTTPException(404, 'Field not found') + if data_type not in FIELD_DATA_TYPES: raise HTTPException(400, 'Invalid data type') + if not definition.is_system and definition.data_type != data_type and db.query(AssetFieldValue).filter(AssetFieldValue.field_definition_id == definition.id).count(): + raise HTTPException(400, 'The data type can only be changed while the custom field has no stored values.') + definition.label=label.strip(); definition.description=description.strip() or None; definition.data_type=data_type + definition.readonly=True if definition.field_name in SYSTEM_READONLY_FIELDS else readonly == 'on' + definition.is_active=True if definition.is_system else is_active == 'on' + definition.searchable=searchable == 'on'; definition.filterable=filterable == 'on'; definition.chart_enabled=chart_enabled == 'on' + definition.excel_import=False if definition.readonly else excel_import == 'on'; definition.excel_export=excel_export == 'on'; definition.bulk_edit=False if definition.readonly else bulk_edit == 'on'; definition.is_default=is_default == 'on'; definition.list_width=_optional_form_int(list_width, field_name="list_width", minimum=40, maximum=800) + for link in definition.category_links: + link.label=definition.label; link.readonly=definition.readonly + db.commit(); clear_translation_cache() + return RedirectResponse('/settings/fields?toast_success=Field saved', status_code=303) + +@app.post('/settings/fields/{definition_id}/delete') +def field_definition_delete(definition_id: int, request: Request, confirm_values: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + definition = db.get(FieldDefinition, definition_id) + if not definition: raise HTTPException(404, 'Field not found') + if definition.is_system: raise HTTPException(400, 'System fields cannot be deleted.') + count = db.query(AssetFieldValue).filter(AssetFieldValue.field_definition_id == definition.id).count() + if count and confirm_values != 'on': raise HTTPException(400, f'This field contains values for {count} assets. Confirm deletion of all values.') + db.delete(definition); db.commit() + return RedirectResponse('/settings/fields?toast_success=Field deleted', status_code=303) + +@app.post('/sync/meshcentral/settings') +async def meshcentral_settings_save(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + current = load_config() + mesh = dict(current.get('meshcentral', {})) + try: + timeout_seconds = max(10, min(int(form.get('timeout_seconds') or 600), 3600)) + except (TypeError, ValueError): + timeout_seconds = 600 + mesh.update({ + 'enabled': form.get('enabled') == 'on', + 'url': (form.get('url') or '').strip(), + 'username': (form.get('username') or '').strip(), + 'password_env': (form.get('password_env') or 'MESHCENTRAL_PASSWORD').strip(), + 'tenant': (form.get('tenant') or '').strip(), + 'meshctrl_path': (form.get('meshctrl_path') or '/opt/meshcentral/node_modules/meshcentral/meshctrl.js').strip(), + 'verify_tls': form.get('verify_tls') == 'on', + 'include_details': form.get('include_details') == 'on', + 'timeout_seconds': timeout_seconds, + 'create_missing_assets': form.get('create_missing_assets') == 'on', + 'mark_missing_devices': form.get('mark_missing_devices') == 'on', + 'store_source_json': form.get('store_source_json') == 'on', + }) + mesh['match_order'] = form.getlist('match_order') or ['node_id', 'serial_number'] + # Klartextpasswort aus Altversionen nicht weiter speichern. + mesh.pop('password', None) + mesh.pop('field_mappings', None) + mesh.pop('field_rules', None) + save_config({'meshcentral': mesh}) + return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303) + + +@app.post('/sync/meshcentral/mappings/save') +async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + allowed_transforms = { + 'raw','string','integer','decimal','boolean','json','normalize_serial','normalize_mac', + 'active_ipv4_address','active_ipv4_mac','sum_memory_gb','format_memory_modules', + 'sum_storage_gb','format_drives','gpu_names','active_antivirus' + } + allowed_multi = {'first','join','json','count'} + allowed_rules = {'fill_empty','meshcentral_wins','local_wins'} + for row in db.query(MeshFieldMapping).all(): + prefix = f'mapping_{row.id}_' + source = str(form.get(prefix + 'source_path') or '').strip() + target = str(form.get(prefix + 'target_field_name') or '').strip() + if not source or not target: + continue + row.source_path = source + row.target_field_name = target + transform = str(form.get(prefix + 'transform') or 'raw') + row.transform = transform if transform in allowed_transforms else 'raw' + multi = str(form.get(prefix + 'multi_value_mode') or 'first') + row.multi_value_mode = multi if multi in allowed_multi else 'first' + row.separator = str(form.get(prefix + 'separator') or '\\n').replace('\\n', '\n').replace('\\t', '\t') + row.enabled = form.get(prefix + 'enabled') == 'on' + try: row.priority = int(form.get(prefix + 'priority') or 0) + except (TypeError, ValueError): row.priority = 0 + rule = str(form.get(prefix + 'update_rule') or 'fill_empty') + row.update_rule = rule if rule in allowed_rules else 'fill_empty' + row.description = str(form.get(prefix + 'description') or '').strip() or None + db.commit() + return RedirectResponse('/sync/meshcentral?toast_success=Mapping-Regeln gespeichert', status_code=303) + + +@app.post('/sync/meshcentral/mappings/new') +async def meshcentral_mapping_new(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + source = str(form.get('source_path') or '').strip() + target = str(form.get('target_field_name') or '').strip() + if not source or not target: + return RedirectResponse('/sync/meshcentral?toast_error=Quellpfad und Zielfeld sind erforderlich', status_code=303) + definition = db.query(FieldDefinition).filter(FieldDefinition.field_name == target, FieldDefinition.is_active.is_(True)).first() + if not definition: + return RedirectResponse('/sync/meshcentral?toast_error=Das Zielfeld existiert nicht oder ist inaktiv', status_code=303) + max_priority = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority.desc()).first() + row = MeshFieldMapping( + source_path=source, + target_field_name=target, + transform=str(form.get('transform') or 'raw'), + multi_value_mode=str(form.get('multi_value_mode') or 'first'), + separator=str(form.get('separator') or '\\n').replace('\\n', '\n').replace('\\t', '\t'), + enabled=form.get('enabled') == 'on', + priority=(max_priority.priority + 10) if max_priority else 10, + description=str(form.get('description') or '').strip() or None, + update_rule=str(form.get('update_rule') or 'fill_empty'), + is_system=False, + ) + db.add(row) + db.commit() + return RedirectResponse('/sync/meshcentral?toast_success=Neue Mapping-Regel angelegt', status_code=303) + + +@app.post('/sync/meshcentral/mappings/{mapping_id}/delete') +def meshcentral_mapping_delete(mapping_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + row = db.query(MeshFieldMapping).filter(MeshFieldMapping.id == mapping_id).first() + if not row: + raise HTTPException(status_code=404, detail='Mapping-Regel nicht gefunden') + if row.is_system: + return RedirectResponse('/sync/meshcentral?toast_error=System-Mappings können deaktiviert, aber nicht gelöscht werden', status_code=303) + db.delete(row) + db.commit() + return RedirectResponse('/sync/meshcentral?toast_success=Mapping-Regel gelöscht', status_code=303) diff --git a/app/main_old.py b/app/main_old.py new file mode 100644 index 0000000..78aa096 --- /dev/null +++ b/app/main_old.py @@ -0,0 +1,1351 @@ +import os +import shutil +import uuid +import threading +import io +import logging +import traceback +import hashlib +import hmac +import secrets +from urllib.parse import quote +from datetime import datetime, date +from pathlib import Path +from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse, JSONResponse, StreamingResponse, Response +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.middleware.sessions import SessionMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session, joinedload +from sqlalchemy.exc import IntegrityError + +from .database import Base, engine, get_db, SessionLocal +from .fields import ASSET_FIELDS, COMPUTER_FIELDS, DEFAULT_FIELDS +from .models import Asset, AssetHistory, Category, CategoryField, StatusOption, SyncRun, User +from .config import load_config, public_config, save_config +from .meshcentral import synchronize +from .migrations import apply_lightweight_migrations +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font, PatternFill, Alignment + +BASE_DIR = Path(__file__).resolve().parent +UPLOAD_DIR = BASE_DIR / "static" / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync")) +LOG_DIR.mkdir(parents=True, exist_ok=True) +APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) +APP_LOG_DIR.mkdir(parents=True, exist_ok=True) + +logger = logging.getLogger("assetmanager") +logger.setLevel(logging.INFO) +if not logger.handlers: + file_handler = logging.FileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8") + file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) + logger.addHandler(file_handler) + +ldap_logger = logging.getLogger("assetmanager.ldap") +ldap_logger.setLevel(logging.DEBUG) +ldap_logger.propagate = False +if not ldap_logger.handlers: + ldap_file_handler = logging.FileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8") + ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) + ldap_logger.addHandler(ldap_file_handler) + ldap_console_handler = logging.StreamHandler() + ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s")) + ldap_logger.addHandler(ldap_console_handler) + +app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager")) +_session_cfg = load_config().get("authentication", {}) +_session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET") +_session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret" +app.add_middleware(SessionMiddleware, secret_key=_session_secret, same_site="lax", https_only=False) +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") +templates = Jinja2Templates(directory=BASE_DIR / "templates") +templates.env.globals["application_config"] = load_config +SYNC_JOBS: dict[str, dict] = {} +SYNC_JOBS_LOCK = threading.Lock() + + +def _hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + iterations = 310_000 + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}" + + +def _verify_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + algorithm, iterations_text, salt_hex, digest_hex = encoded.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + candidate = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), bytes.fromhex(salt_hex), int(iterations_text) + ) + return hmac.compare_digest(candidate.hex(), digest_hex) + except (ValueError, TypeError): + return False + + +def _session_user(request: Request) -> dict | None: + value = request.session.get("user") + return value if isinstance(value, dict) else None + + +def _changed_by(request: Request) -> str | None: + user = _session_user(request) + return (user.get("display_name") or user.get("username")) if user else None + + +def _login_user(request: Request, user: User) -> None: + request.session["user"] = { + "id": user.id, + "username": user.username, + "display_name": user.display_name or user.username, + "email": user.email or "", + "auth_source": user.auth_source, + "is_admin": bool(user.is_admin), + "allowed_mesh_groups": user.allowed_mesh_groups or [], + } + + + +def _is_admin(request: Request) -> bool: + user = _session_user(request) + return bool(user and user.get("is_admin")) + + +def _require_admin(request: Request) -> None: + if not _is_admin(request): + raise HTTPException(403, "Diese Funktion ist nur für Administratoren verfügbar.") + + +def _allowed_groups(request: Request) -> list[str] | None: + user = _session_user(request) + if not user or user.get("is_admin"): + return None + return [str(x) for x in (user.get("allowed_mesh_groups") or []) if str(x).strip()] + + +def _apply_asset_access(query, request: Request): + groups = _allowed_groups(request) + return query if groups is None else query.filter(Asset.mesh_group.in_(groups)) + + +def _get_visible_asset(db: Session, request: Request, asset_id: int) -> Asset: + query = _apply_asset_access(db.query(Asset), request) + asset = query.filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden oder keine Berechtigung") + return asset + + +def _action_status(db: Session, action: str) -> StatusOption | None: + column = StatusOption.use_for_issue if action == "issue" else StatusOption.use_for_return + return db.query(StatusOption).filter(StatusOption.active.is_(True), column.is_(True)).first() + + +def _ldap_authenticate(username: str, password: str, auth_config: dict) -> dict | None: + from ldap3 import ALL, Connection, Server, SUBTREE + from ldap3.core.exceptions import LDAPException + from ldap3.utils.conv import escape_filter_chars + + ldap = auth_config.get("ldap", {}) + server_name = str(ldap.get("server", "")).strip() + base_dn = str(ldap.get("user_base_dn", "")).strip() + use_ssl = bool(ldap.get("use_ssl", False)) + start_tls = bool(ldap.get("start_tls", False)) + port = int(ldap.get("port") or (636 if use_ssl else 389)) + bind_dn = str(ldap.get("bind_dn", "")).strip() + env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip() + bind_password = os.getenv(env_name, "") if env_name else "" + if not bind_password: + bind_password = str(ldap.get("bind_password", "")) + + ldap_logger.info( + "Anmeldeversuch Benutzer=%r Server=%s Port=%s SSL=%s StartTLS=%s BaseDN=%r BindDN=%r Passwortvariable=%r gesetzt=%s", + username, server_name, port, use_ssl, start_tls, base_dn, bind_dn, env_name, bool(bind_password), + ) + + if not server_name: + ldap_logger.error("LDAP-Server ist nicht konfiguriert") + return None + if not base_dn: + ldap_logger.error("Benutzer-Basis-DN ist nicht konfiguriert") + return None + if not password: + ldap_logger.warning("Leeres Benutzerpasswort für Benutzer=%r", username) + return None + if bind_dn and not bind_password: + ldap_logger.error("Bind-DN ist gesetzt, aber das Bind-Passwort fehlt. Erwartete Umgebungsvariable=%r", env_name) + return None + + server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10) + search_connection = None + user_connection = None + try: + search_connection = Connection( + server, + user=bind_dn or None, + password=bind_password or None, + auto_bind=False, + receive_timeout=15, + raise_exceptions=False, + ) + if not search_connection.open(): + ldap_logger.error("Verbindung zum LDAP-Server fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) + return None + ldap_logger.debug("TCP/LDAP-Verbindung geöffnet") + + if start_tls and not use_ssl: + if not search_connection.start_tls(): + ldap_logger.error("StartTLS fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) + return None + ldap_logger.debug("StartTLS erfolgreich") + + if not search_connection.bind(): + ldap_logger.error("Dienstkonto-Bind fehlgeschlagen: result=%r last_error=%r", search_connection.result, search_connection.last_error) + return None + ldap_logger.info("Dienstkonto-Bind erfolgreich") + + raw_filter = str(ldap.get("user_filter", "(sAMAccountName={username})")) + escaped_username = escape_filter_chars(username) + user_filter = raw_filter.replace("{username}", escaped_username).replace("{{username}}", escaped_username) + display_attr = str(ldap.get("display_name_attribute", "displayName")) + email_attr = str(ldap.get("email_attribute", "mail")) + attributes = list(dict.fromkeys([display_attr, email_attr, "distinguishedName"])) + ldap_logger.info("Benutzersuche BaseDN=%r Filter=%r Attribute=%r", base_dn, user_filter, attributes) + + search_ok = search_connection.search( + base_dn, + user_filter, + search_scope=SUBTREE, + attributes=attributes, + ) + ldap_logger.info( + "Benutzersuche beendet: success=%s Treffer=%s result=%r last_error=%r", + search_ok, len(search_connection.entries), search_connection.result, search_connection.last_error, + ) + if len(search_connection.entries) != 1: + if len(search_connection.entries) == 0: + ldap_logger.warning("Kein LDAP-Benutzer für Benutzer=%r gefunden", username) + else: + ldap_logger.warning("Mehrere LDAP-Benutzer für Benutzer=%r gefunden: %s", username, len(search_connection.entries)) + return None + + entry = search_connection.entries[0] + user_dn = entry.entry_dn + display_name = str(getattr(entry, display_attr, "") or username) + email = str(getattr(entry, email_attr, "") or "") + ldap_logger.info("Benutzer gefunden: DN=%r Anzeigename=%r E-Mail=%r", user_dn, display_name, email) + + user_connection = Connection( + server, + user=user_dn, + password=password, + auto_bind=False, + receive_timeout=15, + raise_exceptions=False, + ) + if not user_connection.open(): + ldap_logger.error("Verbindung für Benutzer-Bind fehlgeschlagen: result=%r last_error=%r", user_connection.result, user_connection.last_error) + return None + if start_tls and not use_ssl: + if not user_connection.start_tls(): + ldap_logger.error("StartTLS beim Benutzer-Bind fehlgeschlagen: result=%r last_error=%r", user_connection.result, user_connection.last_error) + return None + if not user_connection.bind(): + ldap_logger.warning("Benutzer-Bind fehlgeschlagen für DN=%r: result=%r last_error=%r", user_dn, user_connection.result, user_connection.last_error) + return None + + ldap_logger.info("LDAP-Anmeldung erfolgreich für Benutzer=%r DN=%r", username, user_dn) + return {"username": username, "display_name": display_name, "email": email} + except LDAPException as exc: + ldap_logger.exception("LDAP-Ausnahme für Benutzer=%r: %s", username, exc) + return None + except Exception as exc: + ldap_logger.exception("Unerwarteter LDAP-Fehler für Benutzer=%r: %s", username, exc) + return None + finally: + for connection in (user_connection, search_connection): + if connection is not None: + try: + connection.unbind() + except Exception: + pass + + +@app.middleware("http") +async def authentication_gate(request: Request, call_next): + mode = str(load_config().get("authentication", {}).get("mode", "none")) + public_paths = {"/login", "/logout"} + if mode in {"local", "ldap"} and not request.session.get("user") and request.url.path not in public_paths and not request.url.path.startswith("/static/"): + if request.method == "GET": + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + return JSONResponse({"detail": "Anmeldung erforderlich"}, status_code=401) + return await call_next(request) + + +def _is_browser_request(request: Request) -> bool: + accept = request.headers.get("accept", "") + return "text/html" in accept or request.method != "GET" + + +def _toast_redirect(request: Request, message: str, level: str = "error") -> RedirectResponse: + referer = request.headers.get("referer") + target = referer if referer and referer.startswith(str(request.base_url).rstrip("/")) else "/" + separator = "&" if "?" in target else "?" + return RedirectResponse(f"{target}{separator}toast_{level}={quote(message)}", status_code=303) + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + message = str(exc.detail) if exc.detail else f"HTTP-Fehler {exc.status_code}" + logger.error("HTTP %s | %s %s | %s", exc.status_code, request.method, request.url.path, message) + if _is_browser_request(request): + return _toast_redirect(request, message) + return JSONResponse({"detail": message}, status_code=exc.status_code) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.error( + "Unerwarteter Fehler | %s %s | %s\n%s", + request.method, + request.url.path, + exc, + traceback.format_exc(), + ) + if _is_browser_request(request): + return _toast_redirect(request, "Ein unerwarteter Fehler ist aufgetreten. Details wurden protokolliert.") + return JSONResponse({"detail": "Interner Serverfehler"}, status_code=500) + +def _job_log(job_id: str, level: str, message: str) -> None: + entry = { + "time": datetime.now().strftime("%H:%M:%S"), + "level": level, + "message": str(message), + } + with SYNC_JOBS_LOCK: + job = SYNC_JOBS.get(job_id) + if job is not None: + job["logs"].append(entry) + job["logs"] = job["logs"][-10000:] + try: + with (LOG_DIR / f"{job_id}.jsonl").open("a", encoding="utf-8") as handle: + import json + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + except OSError: + pass + +def _run_sync_job(job_id: str) -> None: + db = SessionLocal() + try: + result = synchronize(db, lambda level, message: _job_log(job_id, level, message)) + with SYNC_JOBS_LOCK: + job = SYNC_JOBS[job_id] + job["status"] = result.run.status + job["run_id"] = result.run.id + job["finished"] = True + except Exception as exc: + _job_log(job_id, "error", f"Unerwarteter Fehler: {exc}") + with SYNC_JOBS_LOCK: + job = SYNC_JOBS[job_id] + job["status"] = "error" + job["finished"] = True + finally: + db.close() + + + +@app.on_event("startup") +def startup(): + Base.metadata.create_all(bind=engine) + apply_lightweight_migrations() + from .database import SessionLocal + db = SessionLocal() + try: + default_statuses = ["Aktiv", "Defekt", "im Lager", "Ausgegeben", "Verschrottet"] + if db.query(StatusOption).count() == 0: + for order, status_name in enumerate(default_statuses): + db.add(StatusOption(name=status_name, sort_order=order, active=True)) + db.commit() + if not db.query(StatusOption).filter(StatusOption.use_for_issue.is_(True)).first(): + item = db.query(StatusOption).filter(StatusOption.name == "Ausgegeben").first() + if item: item.use_for_issue = True + if not db.query(StatusOption).filter(StatusOption.use_for_return.is_(True)).first(): + item = db.query(StatusOption).filter(StatusOption.name == "im Lager").first() + if item: item.use_for_return = True + db.commit() + if db.query(Category).count() == 0: + computer = Category(name="Computer", description="Computer und Notebooks aus MeshCentral oder manueller Erfassung") + db.add(computer) + db.flush() + for order, field_name in enumerate(COMPUTER_FIELDS): + db.add(CategoryField( + category_id=computer.id, + field_name=field_name, + label=ASSET_FIELDS[field_name], + active=True, + required=field_name == "name", + show_in_list=field_name in {"asset_tag", "name", "hostname", "operating_system", "os_patch_level", "mac_address", "status"}, + sort_order=order, + )) + db.commit() + else: + computer = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.name == "Computer").first() + if computer: + existing = {field.field_name for field in computer.visible_fields} + next_order = max((field.sort_order for field in computer.visible_fields), default=-1) + 1 + important_list_fields = {"hostname", "manufacturer", "model", "serial_number", "operating_system", "ip_address", "mac_address", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group"} + changed = False + for field_name in COMPUTER_FIELDS: + if field_name not in existing: + db.add(CategoryField( + category_id=computer.id, field_name=field_name, label=ASSET_FIELDS[field_name], + active=True, required=False, show_in_list=field_name in important_list_fields, + sort_order=next_order, + )) + next_order += 1 + changed = True + if changed: + db.commit() + finally: + db.close() + + + + +def delete_asset_image(image_path: str | None) -> None: + """Löscht nur individuell hochgeladene Asset-Bilder aus dem Upload-Ordner.""" + if not image_path or not image_path.startswith("/static/uploads/"): + return + filename = Path(image_path).name + target = UPLOAD_DIR / filename + try: + target.unlink(missing_ok=True) + except OSError: + pass + + +def save_upload(upload: UploadFile | None) -> str | None: + if not upload or not upload.filename: + return None + suffix = Path(upload.filename).suffix.lower() + if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + raise HTTPException(400, "Nur PNG, JPG, WEBP oder GIF sind erlaubt.") + filename = f"{uuid.uuid4().hex}{suffix}" + target = UPLOAD_DIR / filename + with target.open("wb") as buffer: + shutil.copyfileobj(upload.file, buffer) + return f"/static/uploads/{filename}" + + +def _display_value(value) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + import json + return json.dumps(value, ensure_ascii=False, sort_keys=True) + return str(value) + + +def _record_asset_history(db: Session, asset: Asset, changes: dict, source: str = "manual", changed_by: str | None = None) -> None: + if not changes: + return + db.add(AssetHistory(asset_id=asset.id, source=source, changed_by=changed_by, changes=changes)) + + +def _xlsx_response(filename: str, headers: list[str], rows: list[list]) -> Response: + """Erzeugt eine echte XLSX-Datei und liefert vollständige Bytes statt eines Streams. + + Das verhindert beschädigte Downloads bei einzelnen Reverse-Proxies und Browsern. + """ + workbook = Workbook() + sheet = workbook.active + sheet.title = "Export" + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = f"A1:{sheet.cell(row=1, column=max(1, len(headers))).column_letter}1" + sheet.append(headers) + header_fill = PatternFill("solid", fgColor="D9EAF7") + for cell in sheet[1]: + cell.font = Font(bold=True) + cell.fill = header_fill + cell.alignment = Alignment(vertical="top") + for row in rows: + sheet.append([_display_value(value) for value in row]) + for column in sheet.columns: + max_length = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60) + sheet.column_dimensions[column[0].column_letter].width = max_length + output = io.BytesIO() + workbook.save(output) + payload = output.getvalue() + ascii_name = "".join(ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in filename) + disposition = f"attachment; filename={ascii_name}; filename*=UTF-8''{quote(filename)}" + return Response( + content=payload, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": disposition, + "Content-Length": str(len(payload)), + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }, + ) + + +def _import_field_map(category: Category) -> dict[str, str]: + result: dict[str, str] = {"bezeichnung": "name", "name": "name", "kategorie": "category"} + for field in category.visible_fields: + if field.active and hasattr(Asset, field.field_name): + result[field.label.strip().casefold()] = field.field_name + result[field.field_name.strip().casefold()] = field.field_name + return result + + +def _excel_cell_value(value) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, date): + return value.isoformat() + text = str(value).strip() + return text or None + + +def _active_status_options(db: Session) -> list[StatusOption]: + return db.query(StatusOption).filter(StatusOption.active.is_(True)).order_by(StatusOption.sort_order, StatusOption.name).all() + + +def _asset_action_changes(asset: Asset, action: str, status_name: str, assigned_to: str | None = None, assigned_on: str | None = None) -> dict[str, dict[str, str]]: + changes: dict[str, dict[str, str]] = {} + if _display_value(asset.status) != status_name: + changes[ASSET_FIELDS["status"]] = {"old": _display_value(asset.status), "new": status_name} + asset.status = status_name + if action == "issue": + new_name = (assigned_to or "").strip() or None + new_date = (assigned_on or date.today().isoformat()).strip() + if _display_value(asset.assigned_to) != _display_value(new_name): + changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": _display_value(new_name)} + asset.assigned_to = new_name + if _display_value(asset.assigned_on) != new_date: + changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": new_date} + asset.assigned_on = new_date + else: + if asset.assigned_to: + changes[ASSET_FIELDS["assigned_to"]] = {"old": _display_value(asset.assigned_to), "new": ""} + asset.assigned_to = None + if asset.assigned_on: + changes[ASSET_FIELDS["assigned_on"]] = {"old": _display_value(asset.assigned_on), "new": ""} + asset.assigned_on = None + return changes + + +def _list_fields_for_categories(categories: list[Category]) -> list[CategoryField]: + """Bildet eine eindeutige, sortierte Vereinigungsmenge der konfigurierten Listenspalten.""" + result: list[CategoryField] = [] + seen: set[str] = set() + for category in categories: + for field in sorted(category.visible_fields, key=lambda item: item.sort_order): + if not field.active or not field.show_in_list or field.field_name in seen: + continue + result.append(field) + seen.add(field.field_name) + return result + + +@app.get("/") +def dashboard(request: Request, db: Session = Depends(get_db)): + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + assets = ( + _apply_asset_access(db.query(Asset), request) + .options(joinedload(Asset.category).joinedload(Category.visible_fields)) + .order_by(Asset.updated_at.desc()) + .limit(10) + .all() + ) + recent_categories = [] + seen_category_ids = set() + for asset in assets: + if asset.category_id not in seen_category_ids: + recent_categories.append(asset.category) + seen_category_ids.add(asset.category_id) + fields = _list_fields_for_categories(recent_categories) + return templates.TemplateResponse( + "dashboard.html", + {"request": request, "categories": categories, "assets": assets, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None)}, + ) + + +@app.get("/assets") +def assets_list(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + query = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category).joinedload(Category.visible_fields)) + selected = None + if category_id: + selected = next((category for category in categories if category.id == category_id), None) + if not selected: + raise HTTPException(404, "Kategorie nicht gefunden") + query = query.filter(Asset.category_id == category_id) + assets = query.order_by(Asset.name).all() + fields = ( + [field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] + if selected + else _list_fields_for_categories(categories) + ) + return templates.TemplateResponse( + "assets.html", + {"request": request, "assets": assets, "categories": categories, "selected": selected, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None)}, + ) + + +@app.get("/assets/new") +def asset_new(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None) + return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db), "duplicate_mode": False}) + + +@app.get("/assets/{asset_id}/duplicate") +def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + original = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() + if not original: + raise HTTPException(404, "Asset nicht gefunden") + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + class DuplicateValues: + pass + duplicate = DuplicateValues() + for field_name in ASSET_FIELDS: + setattr(duplicate, field_name, getattr(original, field_name, None)) + duplicate.asset_tag = None + duplicate.mesh_node_id = None + duplicate.name = f"{original.name} – Kopie" + return templates.TemplateResponse("asset_form.html", { + "request": request, "asset": duplicate, "categories": categories, "selected": original.category, + "fields": ASSET_FIELDS, "status_options": _active_status_options(db), "duplicate_mode": True, + }) + + +@app.post("/assets/new") +async def asset_create(request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + data = {f.field_name: form.get(f.field_name) or None for f in category.visible_fields if f.active and hasattr(Asset, f.field_name)} + if not data.get("name"): + raise HTTPException(400, "Bezeichnung fehlt") + asset = Asset(category_id=category_id, image_path=save_upload(image), **data) + db.add(asset) + try: + db.flush() + created_changes = {field.label: {"old": "", "new": _display_value(getattr(asset, field.field_name, None))} for field in category.visible_fields if field.active and getattr(asset, field.field_name, None) not in (None, "")} + _record_asset_history(db, asset, created_changes, source="manual-create", changed_by=_changed_by(request)) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc + return RedirectResponse(f"/assets/{asset.id}", status_code=303) + + +@app.get("/assets/import") +def assets_import_form(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + categories = db.query(Category).options(joinedload(Category.visible_fields)).order_by(Category.name).all() + selected = next((category for category in categories if category.id == category_id), categories[0] if categories else None) + return templates.TemplateResponse( + "assets_import.html", + {"request": request, "categories": categories, "selected": selected, "result": None}, + ) + + +@app.get("/assets/import/template.xlsx") +def assets_import_template(request: Request, category_id: int, db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + fields = [field for field in sorted(category.visible_fields, key=lambda item: item.sort_order) if field.active and hasattr(Asset, field.field_name)] + if not any(field.field_name == "name" for field in fields): + headers = ["Bezeichnung"] + [field.label for field in fields] + else: + headers = [field.label for field in fields] + return _xlsx_response(f"asset-import-{category.name}.xlsx", headers, []) + + +@app.post("/assets/import") +async def assets_import( + request: Request, + category_id: int = Form(...), + file: UploadFile = File(...), + duplicate_action: str = Form("skip"), + db: Session = Depends(get_db), +): + _require_admin(request) + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + categories = db.query(Category).options(joinedload(Category.visible_fields)).order_by(Category.name).all() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + if not file.filename or Path(file.filename).suffix.lower() != ".xlsx": + raise HTTPException(400, "Bitte eine XLSX-Datei auswählen.") + try: + raw = await file.read() + workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) + sheet = workbook.active + except Exception as exc: + logger.exception("Excel-Import konnte nicht geöffnet werden") + raise HTTPException(400, "Die Excel-Datei ist ungültig oder beschädigt.") from exc + + rows_iter = sheet.iter_rows(values_only=True) + header_row = next(rows_iter, None) + if not header_row: + raise HTTPException(400, "Die Excel-Datei enthält keine Kopfzeile.") + field_map = _import_field_map(category) + columns: list[str | None] = [] + unknown_headers: list[str] = [] + for header in header_row: + label = _excel_cell_value(header) or "" + field_name = field_map.get(label.casefold()) + columns.append(field_name) + if label and not field_name: + unknown_headers.append(label) + if "name" not in columns: + raise HTTPException(400, "Die Spalte ‚Bezeichnung‘ bzw. ‚Name‘ fehlt.") + + created = 0 + updated = 0 + skipped = 0 + errors: list[str] = [] + changed_by = _changed_by(request) + allowed_fields = {field.field_name for field in category.visible_fields if field.active and hasattr(Asset, field.field_name)} | {"name"} + for excel_row_number, values in enumerate(rows_iter, start=2): + data: dict[str, str | None] = {} + for index, value in enumerate(values): + if index >= len(columns): + break + field_name = columns[index] + if field_name and field_name in allowed_fields: + data[field_name] = _excel_cell_value(value) + if not any(value not in (None, "") for value in data.values()): + continue + if not data.get("name"): + errors.append(f"Zeile {excel_row_number}: Bezeichnung fehlt") + continue + + existing = None + if data.get("asset_tag"): + existing = db.query(Asset).filter(Asset.asset_tag == data["asset_tag"]).first() + if existing is None and data.get("serial_number"): + existing = db.query(Asset).filter(Asset.serial_number == data["serial_number"]).first() + if existing: + if duplicate_action != "update": + skipped += 1 + continue + changes: dict[str, dict[str, str]] = {} + existing.category_id = category.id + for field_name, value in data.items(): + old_value = getattr(existing, field_name, None) + if _display_value(old_value) != _display_value(value): + changes[ASSET_FIELDS.get(field_name, field_name)] = {"old": _display_value(old_value), "new": _display_value(value)} + setattr(existing, field_name, value) + _record_asset_history(db, existing, changes, source="excel-import-update", changed_by=changed_by) + updated += 1 + else: + try: + with db.begin_nested(): + asset = Asset(category_id=category.id, **data) + db.add(asset) + db.flush() + created_changes = {ASSET_FIELDS.get(name, name): {"old": "", "new": _display_value(value)} for name, value in data.items() if value not in (None, "")} + _record_asset_history(db, asset, created_changes, source="excel-import-create", changed_by=changed_by) + created += 1 + except IntegrityError: + errors.append(f"Zeile {excel_row_number}: Inventarnummer, Seriennummer oder MeshCentral Node-ID ist bereits vergeben") + continue + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Der Import konnte wegen doppelter eindeutiger Werte nicht gespeichert werden.") from exc + result = {"created": created, "updated": updated, "skipped": skipped, "errors": errors, "unknown_headers": unknown_headers} + return templates.TemplateResponse( + "assets_import.html", + {"request": request, "categories": categories, "selected": category, "result": result}, + ) + + +@app.get("/assets/{asset_id}") +def asset_detail(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _apply_asset_access(db.query(Asset), request).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + active_fields = [f for f in asset.category.visible_fields if f.active] + history_count = db.query(AssetHistory).filter(AssetHistory.asset_id == asset.id).count() + return templates.TemplateResponse("asset_detail.html", {"request": request, "asset": asset, "active_fields": active_fields, "history_count": history_count, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), "today": date.today().isoformat()}) + + +@app.get("/assets/{asset_id}/edit") +def asset_edit(asset_id: int, request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + _require_admin(request) + asset = db.query(Asset).options(joinedload(Asset.category).joinedload(Category.visible_fields)).filter(Asset.id == asset_id).first() + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category + if selected is None: + selected = asset.category + return templates.TemplateResponse("asset_form.html", {"request": request, "asset": asset, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db), "duplicate_mode": False}) + + +@app.post("/assets/{asset_id}/edit") +async def asset_update(asset_id: int, request: Request, category_id: int = Form(...), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + asset = db.query(Asset).filter(Asset.id == asset_id).first() + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not asset or not category: + raise HTTPException(404, "Asset oder Kategorie nicht gefunden") + form = await request.form() + changes: dict[str, dict[str, str]] = {} + old_category_id = asset.category_id + if old_category_id != category_id: + old_category = db.query(Category).filter(Category.id == old_category_id).first() + changes["Kategorie"] = {"old": old_category.name if old_category else str(old_category_id), "new": category.name} + asset.category_id = category_id + for field in category.visible_fields: + if field.active and hasattr(asset, field.field_name): + old_value = getattr(asset, field.field_name) + new_value = form.get(field.field_name) or None + if _display_value(old_value) != _display_value(new_value): + changes[field.label] = {"old": _display_value(old_value), "new": _display_value(new_value)} + setattr(asset, field.field_name, new_value) + new_image = save_upload(image) + if new_image: + if asset.image_path != new_image: + changes["Bild"] = {"old": asset.image_path or "", "new": new_image} + asset.image_path = new_image + try: + _record_asset_history(db, asset, changes, source="manual-update", changed_by=_changed_by(request)) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Asset konnte nicht gespeichert werden. Inventarnummer, Seriennummer oder MeshCentral Node-ID ist möglicherweise bereits vergeben.") from exc + return RedirectResponse(f"/assets/{asset.id}", status_code=303) + + +@app.get("/assets/{asset_id}/history") +def asset_history(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _get_visible_asset(db, request, asset_id) + entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all() + return templates.TemplateResponse("asset_history.html", {"request": request, "asset": asset, "entries": entries}) + + +@app.get("/assets/{asset_id}/history/export.xlsx") +def asset_history_export(asset_id: int, request: Request, db: Session = Depends(get_db)): + asset = _get_visible_asset(db, request, asset_id) + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + entries = db.query(AssetHistory).filter(AssetHistory.asset_id == asset_id).order_by(AssetHistory.changed_at.desc()).all() + rows = [] + for entry in entries: + for label, change in (entry.changes or {}).items(): + rows.append([entry.changed_at, entry.source, entry.changed_by or "", label, change.get("old", ""), change.get("new", "")]) + return _xlsx_response(f"asset-{asset.id}-historie.xlsx", ["Zeitpunkt", "Quelle", "Benutzer", "Feld", "Alter Wert", "Neuer Wert"], rows) + + +@app.get("/assets/export.xlsx") +def assets_export(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + selected = next((category for category in categories if category.id == category_id), None) if category_id else None + query = _apply_asset_access(db.query(Asset), request) + if selected: + query = query.filter(Asset.category_id == selected.id) + assets = query.order_by(Asset.name).all() + fields = ([field for field in sorted(selected.visible_fields, key=lambda item: item.sort_order) if field.active and field.show_in_list] if selected else _list_fields_for_categories(categories)) + headers = (["Kategorie"] if not selected else []) + [field.label for field in fields] + if not any(field.field_name == "name" for field in fields): + headers.insert(0, "Bezeichnung") + rows = [] + for asset in assets: + row = ([asset.category.name] if not selected else []) + [getattr(asset, field.field_name, "") for field in fields] + if not any(field.field_name == "name" for field in fields): + row.insert(0, asset.name) + rows.append(row) + suffix = selected.name if selected else "alle" + return _xlsx_response(f"assets-{suffix}.xlsx", headers, rows) + + +@app.get("/export/assets.xlsx") +def assets_export_static(request: Request, category_id: int | None = None, db: Session = Depends(get_db)): + return assets_export(request=request, category_id=category_id, db=db) + + +@app.post("/assets/{asset_id}/issue") +def asset_issue(asset_id: int, request: Request, return_to: str = Form("detail"), assigned_to: str = Form(""), assigned_on: str = Form(""), db: Session = Depends(get_db)): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + status = _action_status(db, "issue") + if not status: + raise HTTPException(400, "Kein Status für die Aktion Ausgeben konfiguriert") + changes = _asset_action_changes(asset, "issue", status.name, assigned_to, assigned_on) + _record_asset_history(db, asset, changes, source="manual-issue", changed_by=_changed_by(request)) + db.commit() + return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303) + + +@app.post("/assets/{asset_id}/return") +def asset_return(asset_id: int, request: Request, return_to: str = Form("detail"), db: Session = Depends(get_db)): + _require_admin(request) + asset = _get_visible_asset(db, request, asset_id) + status = _action_status(db, "return") + if not status: + raise HTTPException(400, "Kein Status für die Aktion Zurücknehmen konfiguriert") + changes = _asset_action_changes(asset, "return", status.name) + _record_asset_history(db, asset, changes, source="manual-return", changed_by=_changed_by(request)) + db.commit() + return RedirectResponse("/" if return_to == "dashboard" else ("/assets" if return_to == "list" else f"/assets/{asset.id}"), status_code=303) + + +@app.post("/assets/{asset_id}/delete") +def asset_delete(asset_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + asset = db.query(Asset).filter(Asset.id == asset_id).first() + if not asset: + raise HTTPException(404, "Asset nicht gefunden") + + image_path = asset.image_path + db.delete(asset) + db.commit() + delete_asset_image(image_path) + return RedirectResponse("/assets", status_code=303) + + +@app.post("/assets/delete-all") +def assets_delete_all(request: Request, category_id: int | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + query = db.query(Asset) + redirect_url = "/assets" + if category_id is not None: + query = query.filter(Asset.category_id == category_id) + redirect_url = f"/assets?category_id={category_id}" + + assets = query.all() + image_paths = [asset.image_path for asset in assets if asset.image_path] + for asset in assets: + db.delete(asset) + db.commit() + + for image_path in image_paths: + delete_asset_image(image_path) + + return RedirectResponse(redirect_url, status_code=303) + + +@app.get("/status-options") +def status_options_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + options = db.query(StatusOption).order_by(StatusOption.sort_order, StatusOption.name).all() + return templates.TemplateResponse("status_options.html", {"request": request, "options": options}) + + +@app.post("/status-options") +async def status_options_save(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + existing = db.query(StatusOption).all() + for option in existing: + name = (form.get(f"name_{option.id}") or "").strip() + if not name: + continue + option.name = name + option.active = form.get(f"active_{option.id}") == "on" + try: + option.sort_order = int(form.get(f"sort_{option.id}") or option.sort_order) + except ValueError: + pass + issue_id = int(form.get("issue_status_id") or 0) + return_id = int(form.get("return_status_id") or 0) + for option in existing: + option.use_for_issue = option.id == issue_id + option.use_for_return = option.id == return_id + new_name = (form.get("new_name") or "").strip() + if new_name: + max_order = max((item.sort_order for item in existing), default=-1) + db.add(StatusOption(name=new_name, sort_order=max_order + 1, active=True)) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Statuswerte müssen eindeutig sein.") from exc + return RedirectResponse("/status-options", status_code=303) + + +@app.post("/status-options/{option_id}/delete") +def status_option_delete(option_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + option = db.query(StatusOption).filter(StatusOption.id == option_id).first() + if not option: + raise HTTPException(404, "Statuswert nicht gefunden") + db.delete(option) + db.commit() + return RedirectResponse("/status-options", status_code=303) + + +@app.get("/categories") +def categories_list(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + categories = db.query(Category).options(joinedload(Category.visible_fields)).order_by(Category.name).all() + return templates.TemplateResponse("categories.html", {"request": request, "categories": categories}) + + +@app.get("/categories/new") +def category_new(request: Request): + _require_admin(request) + return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "field_defs": ASSET_FIELDS, "selected_fields": DEFAULT_FIELDS}) + + +@app.post("/categories/new") +async def category_create(request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + form = await request.form() + category = Category(name=name.strip(), description=description or None, image_path=save_upload(image)) + db.add(category) + db.flush() + order = 0 + for field_name, default_label in ASSET_FIELDS.items(): + active = form.get(f"active_{field_name}") == "on" + if active: + db.add(CategoryField(category_id=category.id, field_name=field_name, label=form.get(f"label_{field_name}") or default_label, active=True, required=form.get(f"required_{field_name}") == "on", show_in_list=form.get(f"list_{field_name}") == "on", sort_order=order)) + order += 1 + if form.get("active_name") != "on": + db.add(CategoryField(category_id=category.id, field_name="name", label="Bezeichnung", active=True, required=True, show_in_list=True, sort_order=0)) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc + return RedirectResponse("/categories", status_code=303) + + +@app.get("/categories/{category_id}/edit") +def category_edit(category_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + selected_fields = {f.field_name: f for f in category.visible_fields} + return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "field_defs": ASSET_FIELDS, "selected_fields": selected_fields}) + + +@app.post("/categories/{category_id}/edit") +async def category_update(category_id: int, request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): + _require_admin(request) + category = db.query(Category).options(joinedload(Category.visible_fields)).filter(Category.id == category_id).first() + if not category: + raise HTTPException(404, "Kategorie nicht gefunden") + form = await request.form() + category.name = name.strip() + category.description = description or None + new_image = save_upload(image) + if new_image: + category.image_path = new_image + # Vorhandene Definitionen explizit löschen und flushen. Das verhindert + # UniqueConstraint-Verletzungen bei anschließend identischen Feldnamen. + db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False) + db.flush() + selected_names = [field_name for field_name in ASSET_FIELDS if form.get(f"active_{field_name}") == "on"] + if "name" not in selected_names: + selected_names.insert(0, "name") + for order, field_name in enumerate(selected_names): + default_label = ASSET_FIELDS[field_name] + db.add(CategoryField( + category_id=category.id, field_name=field_name, + label=form.get(f"label_{field_name}") or default_label, active=True, + required=True if field_name == "name" else form.get(f"required_{field_name}") == "on", + show_in_list=True if field_name == "name" else form.get(f"list_{field_name}") == "on", + sort_order=order, + )) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Kategorie konnte nicht gespeichert werden. Der Kategoriename ist möglicherweise bereits vorhanden.") from exc + return RedirectResponse("/categories", status_code=303) + + + + +@app.get("/login") +def login_page(request: Request): + if _session_user(request): + return RedirectResponse("/profile", status_code=303) + mode = load_config().get("authentication", {}).get("mode", "none") + return templates.TemplateResponse("login.html", {"request": request, "mode": mode}) + + +@app.post("/login") +def login_submit(request: Request, username: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): + username = username.strip() + auth_config = load_config().get("authentication", {}) + mode = str(auth_config.get("mode", "none")).lower() + user = db.query(User).filter(User.username == username).first() + valid = False + if mode == "local": + valid = bool(user and user.is_active and user.auth_source == "local" and _verify_password(password, user.password_hash)) + elif mode == "ldap": + ldap_user = _ldap_authenticate(username, password, auth_config) + if ldap_user: + if not user: + user = User(username=username, auth_source="ldap", is_active=True) + db.add(user) + if user.is_active: + user.auth_source = "ldap" + user.display_name = ldap_user.get("display_name") or username + user.email = ldap_user.get("email") or None + valid = True + elif mode == "none": + return RedirectResponse("/?toast_warning=Die Anmeldung ist derzeit deaktiviert", status_code=303) + if not valid or not user: + logger.warning("Fehlgeschlagene Anmeldung für Benutzer %s im Modus %s", username, mode) + return RedirectResponse("/login?toast_error=Benutzername oder Passwort ist ungültig", status_code=303) + user.last_login = datetime.utcnow() + db.commit() + db.refresh(user) + _login_user(request, user) + return RedirectResponse("/?toast_success=Anmeldung erfolgreich", status_code=303) + + +@app.get("/logout") +def logout(request: Request): + request.session.clear() + return RedirectResponse("/?toast_success=Sie wurden abgemeldet", status_code=303) + + +@app.get("/profile") +def profile(request: Request, db: Session = Depends(get_db)): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login?toast_warning=Bitte zuerst anmelden", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user: + request.session.clear() + return RedirectResponse("/login?toast_error=Benutzerkonto wurde nicht gefunden", status_code=303) + return templates.TemplateResponse("profile.html", {"request": request, "user": user}) + + +@app.post("/profile/password") +def profile_password(request: Request, current_password: str = Form(...), new_password: str = Form(...), db: Session = Depends(get_db)): + session_user = _session_user(request) + if not session_user: + return RedirectResponse("/login", status_code=303) + user = db.query(User).filter(User.id == session_user.get("id")).first() + if not user or user.auth_source != "local": + return RedirectResponse("/profile?toast_warning=Passwortänderungen sind nur für lokale Konten möglich", status_code=303) + if not _verify_password(current_password, user.password_hash): + return RedirectResponse("/profile?toast_error=Das aktuelle Passwort ist falsch", status_code=303) + if len(new_password) < 8: + return RedirectResponse("/profile?toast_error=Das neue Passwort muss mindestens 8 Zeichen lang sein", status_code=303) + user.password_hash = _hash_password(new_password) + db.commit() + return RedirectResponse("/profile?toast_success=Passwort geändert", status_code=303) + + +@app.get("/users") +def users_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + users = db.query(User).order_by(User.username).all() + mesh_groups = [row[0] for row in db.query(Asset.mesh_group).filter(Asset.mesh_group.isnot(None), Asset.mesh_group != "").distinct().order_by(Asset.mesh_group).all()] + return templates.TemplateResponse("users.html", {"request": request, "users": users, "auth": public_config().get("authentication", {}), "mesh_groups": mesh_groups}) + + +@app.post("/users/new") +def user_create(request: Request, username: str = Form(...), display_name: str = Form(""), email: str = Form(""), password: str = Form(...), is_admin: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + username = username.strip() + if not username: + raise HTTPException(400, "Benutzername fehlt") + if len(password) < 8: + raise HTTPException(400, "Das Passwort muss mindestens 8 Zeichen lang sein") + user = User(username=username, display_name=display_name.strip() or username, email=email.strip() or None, password_hash=_hash_password(password), auth_source="local", is_active=True, is_admin=is_admin == "on") + db.add(user) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(400, "Der Benutzername ist bereits vorhanden") from exc + return RedirectResponse("/users?toast_success=Benutzer angelegt", status_code=303) + + +@app.post("/users/{user_id}/update") +def user_update(user_id: int, request: Request, display_name: str = Form(""), email: str = Form(""), is_active: str | None = Form(None), is_admin: str | None = Form(None), new_password: str = Form(""), mesh_groups: list[str] = Form([]), db: Session = Depends(get_db)): + _require_admin(request) + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(404, "Benutzer nicht gefunden") + user.display_name = display_name.strip() or user.username + user.email = email.strip() or None + user.is_active = is_active == "on" + user.is_admin = is_admin == "on" + user.allowed_mesh_groups = sorted(set(mesh_groups)) + if new_password: + if user.auth_source != "local": + raise HTTPException(400, "LDAP-Passwörter können hier nicht geändert werden") + if len(new_password) < 8: + raise HTTPException(400, "Das neue Passwort muss mindestens 8 Zeichen lang sein") + user.password_hash = _hash_password(new_password) + db.commit() + return RedirectResponse("/users?toast_success=Benutzer gespeichert", status_code=303) + + + + +def _ldap_search_users(query_text: str, auth_config: dict) -> list[dict]: + from ldap3 import ALL, Connection, Server, SUBTREE + from ldap3.utils.conv import escape_filter_chars + ldap = auth_config.get("ldap", {}) + server_name = str(ldap.get("server", "")).strip() + use_ssl = bool(ldap.get("use_ssl", False)); start_tls = bool(ldap.get("start_tls", False)) + port = int(ldap.get("port") or (636 if use_ssl else 389)) + bind_dn = str(ldap.get("bind_dn", "")).strip() + env_name = str(ldap.get("bind_password_env", "LDAP_BIND_PASSWORD")).strip() + bind_password = os.getenv(env_name, "") or str(ldap.get("bind_password", "")) + base_dn = str(ldap.get("user_base_dn", "")).strip() + q = escape_filter_chars(query_text.strip()) + search_filter = f"(&(objectCategory=person)(objectClass=user)(|(sAMAccountName=*{q}*)(displayName=*{q}*)(mail=*{q}*)))" + server = Server(server_name, port=port, use_ssl=use_ssl, get_info=ALL, connect_timeout=10) + conn = Connection(server, user=bind_dn or None, password=bind_password or None, auto_bind=False, receive_timeout=15) + if not conn.open(): raise HTTPException(400, "LDAP-Verbindung fehlgeschlagen") + if start_tls and not use_ssl and not conn.start_tls(): raise HTTPException(400, "LDAP StartTLS fehlgeschlagen") + if not conn.bind(): raise HTTPException(400, f"LDAP Bind fehlgeschlagen: {conn.last_error}") + conn.search(base_dn, search_filter, search_scope=SUBTREE, attributes=["sAMAccountName", "displayName", "mail", "distinguishedName"], size_limit=100) + result=[] + for e in conn.entries: + result.append({"username": str(getattr(e,"sAMAccountName","") or ""), "display_name": str(getattr(e,"displayName","") or ""), "email": str(getattr(e,"mail","") or ""), "dn": e.entry_dn}) + conn.unbind(); return result + + +@app.get("/settings/ldap-search") +def ldap_search_form(request: Request): + _require_admin(request) + return templates.TemplateResponse("ldap_search.html", {"request": request, "query": "", "results": []}) + + +@app.post("/settings/ldap-search") +def ldap_search_page(request: Request, ldap_query: str = Form("")): + _require_admin(request) + cfg = public_config() + results = _ldap_search_users(ldap_query, cfg.get("authentication", {})) if ldap_query.strip() else [] + return templates.TemplateResponse("ldap_search.html", {"request": request, "query": ldap_query, "results": results}) + +@app.get("/settings") +def settings_page(request: Request): + _require_admin(request) + return templates.TemplateResponse("settings.html", {"request": request, "settings": public_config()}) + + +@app.post("/settings") +async def settings_save( + request: Request, + title: str = Form("AssetManager"), + company_name: str = Form(""), + auth_mode: str = Form("none"), + ldap_server: str = Form(""), + ldap_port: int = Form(389), + ldap_use_ssl: str | None = Form(None), + ldap_start_tls: str | None = Form(None), + ldap_bind_dn: str = Form(""), + ldap_bind_password_env: str = Form("LDAP_BIND_PASSWORD"), + ldap_user_base_dn: str = Form(""), + ldap_user_filter: str = Form("(sAMAccountName={username})"), + ldap_display_name_attribute: str = Form("displayName"), + ldap_email_attribute: str = Form("mail"), + remove_logo: str | None = Form(None), + logo: UploadFile | None = File(None), +): + _require_admin(request) + + current = load_config() + general = dict(current.get("general", {})) + general["title"] = title.strip() or "AssetManager" + general["company_name"] = company_name.strip() + if remove_logo == "on": + old_logo = general.get("logo") + if old_logo: + delete_asset_image(old_logo) + general["logo"] = "" + if logo and logo.filename: + old_logo = general.get("logo") + new_logo = save_upload(logo) + if old_logo and old_logo != new_logo: + delete_asset_image(old_logo) + general["logo"] = new_logo or "" + if auth_mode not in {"none", "local", "ldap"}: + auth_mode = "none" + authentication = dict(current.get("authentication", {})) + authentication["mode"] = auth_mode + ldap = dict(authentication.get("ldap", {})) + ldap.update({ + "server": ldap_server.strip(), + "port": ldap_port, + "use_ssl": ldap_use_ssl == "on", + "start_tls": ldap_start_tls == "on", + "bind_dn": ldap_bind_dn.strip(), + "bind_password_env": ldap_bind_password_env.strip() or "LDAP_BIND_PASSWORD", + "user_base_dn": ldap_user_base_dn.strip(), + "user_filter": ldap_user_filter.strip() or "(sAMAccountName={username})", + "display_name_attribute": ldap_display_name_attribute.strip() or "displayName", + "email_attribute": ldap_email_attribute.strip() or "mail", + }) + authentication["ldap"] = ldap + save_config({"general": general, "authentication": authentication}) + return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303) + + +@app.get("/sync/meshcentral") +def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)): + _require_admin(request) + runs = db.query(SyncRun).order_by(SyncRun.started_at.desc()).limit(20).all() + linked = db.query(Asset).filter(Asset.mesh_node_id.isnot(None)).count() + conflicts = db.query(Asset).filter(Asset.mesh_sync_status == "conflict").count() + missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count() + return templates.TemplateResponse("meshcentral_sync.html", { + "request": request, "runs": runs, "config": public_config(), + "linked": linked, "conflicts": conflicts, "missing": missing + }) + + +@app.post("/sync/meshcentral/start") +def meshcentral_sync_start(request: Request): + _require_admin(request) + with SYNC_JOBS_LOCK: + if any(not job.get("finished") for job in SYNC_JOBS.values()): + return JSONResponse({"error": "Es läuft bereits eine Synchronisierung."}, status_code=409) + job_id = uuid.uuid4().hex + SYNC_JOBS[job_id] = {"status": "running", "finished": False, "run_id": None, "logs": [], "created_at": datetime.now().isoformat()} + _job_log(job_id, "info", "Synchronisierungsauftrag angelegt.") + threading.Thread(target=_run_sync_job, args=(job_id,), daemon=True).start() + return {"job_id": job_id} + + +@app.get("/sync/meshcentral/jobs/{job_id}") +def meshcentral_sync_job(job_id: str, offset: int = 0): + with SYNC_JOBS_LOCK: + job = SYNC_JOBS.get(job_id) + if not job: + raise HTTPException(404, "Synchronisierungsauftrag nicht gefunden") + logs = list(job["logs"][max(0, offset):]) + return { + "status": job["status"], + "finished": job["finished"], + "run_id": job["run_id"], + "logs": logs, + "next_offset": max(0, offset) + len(logs), + } + + +@app.post("/sync/meshcentral") +def meshcentral_sync_run(): + return RedirectResponse("/sync/meshcentral", status_code=303) diff --git a/app/meshcentral.py b/app/meshcentral.py new file mode 100644 index 0000000..23fdc3d --- /dev/null +++ b/app/meshcentral.py @@ -0,0 +1,1068 @@ +import json +import os +import re +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Callable +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .config import load_config +from .models import Asset, AssetHistory, Category, SyncRun, FieldDefinition, AssetFieldValue, MeshFieldMapping +from .fields import ASSET_FIELDS + + +@dataclass +class SyncResult: + run: SyncRun + + +def _first(data: dict[str, Any], *paths: str) -> Any: + for path in paths: + value: Any = data + for part in path.split("."): + if isinstance(value, dict): + value = value.get(part) + else: + value = None + if value is None: + break + if value not in (None, "", [], {}): + return value + return None + + + +def _find_key_recursive(data: Any, keys: set[str]) -> Any: + if isinstance(data, dict): + for key, value in data.items(): + if key.lower() in keys and value not in (None, "", [], {}): + return value + for value in data.values(): + found = _find_key_recursive(value, keys) + if found not in (None, "", [], {}): + return found + elif isinstance(data, list): + for item in data: + found = _find_key_recursive(item, keys) + if found not in (None, "", [], {}): + return found + return None + + +def _normalize_serial(value: Any) -> str | None: + if value is None: + return None + result = str(value).strip() + return result or None + + +def _normalize_mac(value: Any) -> str | None: + if not value: + return None + if isinstance(value, list): + value = value[0] if value else None + if isinstance(value, dict): + value = value.get("mac") or value.get("address") + if not value: + return None + raw = re.sub(r"[^0-9A-Fa-f]", "", str(value)) + if len(raw) == 12: + return ":".join(raw[i:i+2] for i in range(0, 12, 2)).upper() + return str(value).strip() + + +def _extract_devices(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + direct = [item for item in payload if isinstance(item, dict)] + # MeshCtrl kann mehrere vollständige JSON-Blöcke nacheinander ausgeben. + nested: list[dict[str, Any]] = [] + for item in payload: + if isinstance(item, (list, dict)): + try: + nested.extend(_extract_devices(item)) + except ValueError: + pass + return nested or direct + if isinstance(payload, dict): + for key in ("nodes", "devices", "result"): + value = payload.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + if isinstance(value, dict): + flattened = [] + for group in value.values(): + if isinstance(group, list): + flattened.extend(x for x in group if isinstance(x, dict)) + elif isinstance(group, dict): + flattened.append(group) + if flattened: + return flattened + if any(key in payload for key in ("_id", "nodeid", "name")): + return [payload] + raise ValueError("MeshCtrl-Ausgabe enthält keine erkennbare Geräteliste.") + + +def _strip_terminal_noise(output: str) -> str: + """Entfernt BOM, ANSI-Steuersequenzen und NUL-Zeichen aus MeshCtrl-Ausgaben.""" + output = output.lstrip("\ufeff") + output = output.replace("\x00", "") + return re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output) + + +def _parse_json_output(output: str) -> Any: + """Liest auch JSON mit vor-/nachgelagerten Hinweisen oder mehreren JSON-Blöcken.""" + cleaned = _strip_terminal_noise(output).strip() + if not cleaned: + raise ValueError("MeshCtrl lieferte eine leere Ausgabe.") + + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + + decoder = json.JSONDecoder() + values: list[Any] = [] + position = 0 + + while position < len(cleaned): + starts = [i for i in (cleaned.find("[", position), cleaned.find("{", position)) if i >= 0] + if not starts: + break + start = min(starts) + try: + value, end = decoder.raw_decode(cleaned, start) + values.append(value) + position = end + except json.JSONDecodeError as exc: + # Ein nicht valider Detailblock darf nicht zu einer irreführenden Standardmeldung führen. + context_start = max(0, exc.pos - 120) + context_end = min(len(cleaned), exc.pos + 120) + context = cleaned[context_start:context_end].replace("\n", "\\n") + raise ValueError( + f"Ungültige MeshCtrl-JSON-Ausgabe bei Zeichen {exc.pos}. " + f"Ausschnitt: {context}" + ) from exc + + if not values: + raise ValueError(f"MeshCtrl lieferte kein erkennbares JSON: {cleaned[:300]}") + if len(values) == 1: + return values[0] + return values + + +def _run_meshctrl( + cfg: dict[str, Any], + action: str = "ListDevices", + include_details: bool = False, + extra_args: list[str] | None = None, + retain_stdout: bool = True, +) -> subprocess.CompletedProcess[str]: + command = [ + "node", cfg["meshctrl_path"], action, + "--url", cfg["url"], "--loginuser", cfg["username"], + "--loginpass", cfg["password"], "--json" + ] + if include_details: + command.append("--details") + if extra_args: + command.extend(extra_args) + if cfg.get("tenant"): + command += ["--tenant", cfg["tenant"]] + if not cfg.get("verify_tls", True): + command.append("--noverify") + + # stdout/stderr bewusst direkt in Dateien schreiben. Dadurch umgehen wir + # mögliche Pipe-/Puffergrenzen bei sehr großen MeshCtrl-JSON-Ausgaben. + diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics")) + diagnostic_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + mode = "details" if include_details else "basic" + safe_action = "".join(ch for ch in action if ch.isalnum() or ch in "-_" ) + stdout_path = diagnostic_dir / f"meshctrl-{timestamp}-{safe_action}-{mode}.stdout.json" + stderr_path = diagnostic_dir / f"meshctrl-{timestamp}-{safe_action}-{mode}.stderr.log" + + with stdout_path.open("wb") as stdout_file, stderr_path.open("wb") as stderr_file: + completed = subprocess.run( + command, + stdout=stdout_file, + stderr=stderr_file, + timeout=int(cfg.get("timeout_seconds", 600)), + env=os.environ.copy(), + ) + + stdout_bytes = stdout_path.read_bytes() + stderr_bytes = stderr_path.read_bytes() + stdout_text = stdout_bytes.decode("utf-8", errors="replace") + stderr_text = stderr_bytes.decode("utf-8", errors="replace") + + # Leere stderr-Dateien entfernen. Erfolgreiche, häufige Abfragen wie die + # Präsenzprüfung können ihre stdout-Rohdatei nach dem Einlesen verwerfen. + # Bei MeshCtrl-Fehlern bleibt die Datei unabhängig von retain_stdout erhalten. + if not stderr_bytes: + stderr_path.unlink(missing_ok=True) + if not retain_stdout and completed.returncode == 0: + stdout_path.unlink(missing_ok=True) + + return subprocess.CompletedProcess( + args=command, + returncode=completed.returncode, + stdout=stdout_text, + stderr=stderr_text, + ) + + + + +def _looks_truncated(output: str) -> bool: + cleaned = _strip_terminal_noise(output).rstrip() + if not cleaned: + return False + # Der beobachtete Fehler endet nahe 64 KiB mitten in einem JSON-Objekt. + return len(cleaned) >= 65000 and cleaned[-1] not in "]}" + + +def _extract_group_ids(payload: Any) -> list[tuple[str, str]]: + groups: list[tuple[str, str]] = [] + items = payload if isinstance(payload, list) else list(payload.values()) if isinstance(payload, dict) else [] + for item in items: + if not isinstance(item, dict): + continue + raw_id = item.get("_id") or item.get("id") or item.get("meshid") + name = item.get("name") or item.get("meshname") or str(raw_id or "Gruppe") + if not raw_id: + continue + group_id = str(raw_id) + if group_id.startswith("mesh//"): + group_id = group_id.split("/", 2)[-1] + groups.append((group_id, str(name))) + return groups + + +def _fetch_devices_by_group( + cfg: dict[str, Any], + include_details: bool, + log: Callable[[str, str], None], + *, + retain_stdout: bool = True, +) -> list[dict[str, Any]]: + log("warning", "Gesamtausgabe ist abgeschnitten. Geräte werden deshalb gruppenweise abgerufen.") + group_result = _run_meshctrl(cfg, "ListDeviceGroups", retain_stdout=retain_stdout) + if group_result.returncode != 0: + raise RuntimeError((group_result.stderr or group_result.stdout or "ListDeviceGroups fehlgeschlagen").strip()) + groups = _extract_group_ids(_parse_json_output(group_result.stdout)) + if not groups: + raise RuntimeError("MeshCtrl lieferte keine auswertbaren Gerätegruppen.") + log("info", f"{len(groups)} Gerätegruppe(n) gefunden.") + devices: list[dict[str, Any]] = [] + seen: set[str] = set() + for index, (group_id, group_name) in enumerate(groups, start=1): + log("info", f"Gruppe {index}/{len(groups)} wird gelesen: {group_name}") + result = _run_meshctrl( + cfg, + "ListDevices", + include_details, + ["--id", group_id], + retain_stdout=retain_stdout, + ) + if result.returncode != 0: + raise RuntimeError(f"Gruppe {group_name}: {(result.stderr or result.stdout or 'MeshCtrl-Fehler').strip()}") + if _looks_truncated(result.stdout): + raise RuntimeError(f"Auch die Ausgabe der Gruppe {group_name} wurde bei etwa 64 KiB abgeschnitten.") + group_devices = _extract_devices(_parse_json_output(result.stdout)) + log("success", f"Gruppe {group_name}: {len(group_devices)} Gerät(e) gelesen.") + for device in group_devices: + node_id = str(device.get("_id") or device.get("nodeid") or device.get("nodeId") or "") + key = node_id or json.dumps(device, sort_keys=True, default=str) + if key not in seen: + seen.add(key) + devices.append(device) + return devices + + +def _device_summary(raw: dict[str, Any]) -> dict[str, str]: + """Return a compact, robust device description for manual linking. + + MeshCtrl output differs slightly between versions and between basic and + detail mode. The summary therefore checks the known paths first and then + uses conservative recursive fallbacks for manufacturer and serial number. + """ + node = raw.get("node") if isinstance(raw.get("node"), dict) else {} + sys_data = raw.get("sys") if isinstance(raw.get("sys"), dict) else {} + hardware = sys_data.get("hardware") if isinstance(sys_data.get("hardware"), dict) else {} + identifiers = hardware.get("identifiers") if isinstance(hardware.get("identifiers"), dict) else {} + + node_id = _first( + raw, + "node._id", + "node.nodeid", + "node.nodeId", + "node.id", + "_id", + "nodeid", + "nodeId", + "id", + ) + name = _first(raw, "node.name", "node.rname", "name", "rname", "hostname") + manufacturer = ( + identifiers.get("bios_vendor") + or identifiers.get("board_vendor") + or _first(raw, "sys.hardware.windows.computer.Manufacturer", "node.manufacturer", "manufacturer") + or _find_key_recursive(raw, {"manufacturer", "bios_vendor", "board_vendor", "vendor"}) + ) + serial_number = ( + identifiers.get("bios_serial") + or identifiers.get("board_serial") + or _first(raw, "sys.hardware.windows.bios.SerialNumber", "node.serial_number", "node.serialNumber", "serial_number", "serialNumber") + or _find_key_recursive(raw, {"serialnumber", "serial_number", "bios_serial", "board_serial"}) + ) + group = _first(raw, "node.groupname", "groupname", "meshname", "group") + operating_system = _first(raw, "node.osdesc", "osdesc", "operatingSystem", "os") + + return { + "node_id": str(node_id or "").strip(), + "name": str(name or node_id or "").strip(), + "manufacturer": str(manufacturer or "").strip(), + "serial_number": str(serial_number or "").strip(), + "group": str(group or "").strip(), + "operating_system": str(operating_system or "").strip(), + } + + +def fetch_device_summaries_for_linking() -> list[dict[str, str]]: + """Fetch MeshCentral devices on demand for the administrator link page. + + Successful raw output is deleted immediately so this manual lookup does + not grow the diagnostics directory. Failed MeshCtrl calls are retained by + ``_run_meshctrl`` for troubleshooting. + """ + cfg = load_config()["meshcentral"] + if not cfg.get("enabled"): + raise RuntimeError("MeshCentral-Synchronisierung ist in config.json deaktiviert.") + required = ["url", "username", "password", "meshctrl_path"] + missing = [key for key in required if not cfg.get(key)] + if missing: + raise RuntimeError("Fehlende MeshCentral-Konfiguration: " + ", ".join(missing)) + + attempts: list[str] = [] + # Detail mode is preferred because manufacturer and serial number are + # normally only available there. Basic mode remains a compatibility + # fallback for older MeshCentral/MeshCtrl combinations. + for include_details in (True, False): + result = _run_meshctrl( + cfg, + "ListDevices", + include_details, + retain_stdout=False, + ) + if result.returncode != 0: + attempts.append((result.stderr or result.stdout or "MeshCtrl-Fehler").strip()) + continue + try: + if _looks_truncated(result.stdout): + devices = _fetch_devices_by_group( + cfg, + include_details, + lambda _level, _message: None, + retain_stdout=False, + ) + else: + devices = _extract_devices(_parse_json_output(result.stdout)) + except (ValueError, json.JSONDecodeError, RuntimeError) as exc: + attempts.append(str(exc)) + continue + + summaries: dict[str, dict[str, str]] = {} + for raw in devices: + summary = _device_summary(raw) + node_id = summary["node_id"] + if node_id: + summaries[node_id] = summary + if summaries: + return sorted( + summaries.values(), + key=lambda item: (item["name"].casefold(), item["node_id"].casefold()), + ) + + detail = " | ".join(item for item in attempts if item) or "keine auswertbare Geräteliste" + raise RuntimeError("MeshCentral-Geräteliste konnte nicht geladen werden: " + detail) + + +def fetch_devices(log: Callable[[str, str], None] | None = None) -> list[dict[str, Any]]: + log = log or (lambda level, message: None) + cfg = load_config()["meshcentral"] + if not cfg.get("enabled"): + raise RuntimeError("MeshCentral-Synchronisierung ist in config.json deaktiviert.") + required = ["url", "username", "password", "meshctrl_path"] + missing = [key for key in required if not cfg.get(key)] + if missing: + raise RuntimeError("Fehlende MeshCentral-Konfiguration: " + ", ".join(missing)) + + attempts: list[str] = [] + use_details = cfg.get("include_details", True) + + for include_details in ([True, False] if use_details else [False]): + mode = "mit Detaildaten" if include_details else "mit Basisdaten" + log("info", f"MeshCtrl-Abfrage wird {mode} gestartet.") + result = _run_meshctrl(cfg, "ListDevices", include_details) + log("debug", f"MeshCtrl beendet: Exitcode {result.returncode}, Ausgabe {len(result.stdout or '')} Zeichen.") + if result.returncode != 0: + message = (result.stderr or result.stdout or "MeshCtrl-Fehler").strip() + attempts.append(("mit Details: " if include_details else "ohne Details: ") + message) + log("error", f"MeshCtrl-Fehler: {message[:500]}") + continue + try: + if _looks_truncated(result.stdout): + log("warning", f"MeshCtrl-Ausgabe endet nach {len(result.stdout)} Zeichen mitten im JSON.") + devices = _fetch_devices_by_group(cfg, include_details, log) + else: + devices = _extract_devices(_parse_json_output(result.stdout)) + log("success", f"{len(devices)} Gerät(e) aus MeshCentral gelesen.") + return devices + except (ValueError, json.JSONDecodeError, RuntimeError) as exc: + attempts.append(("mit Details: " if include_details else "ohne Details: ") + str(exc)) + log("warning", f"JSON-Auswertung {mode} fehlgeschlagen: {exc}") + # Manche MeshCentral-/MeshCtrl-Versionen liefern bei --details einen + # beschädigten sehr großen JSON-Block. Dann automatisch Basisdaten abrufen. + continue + + raise RuntimeError("MeshCentral-Import fehlgeschlagen. " + " | ".join(attempts)) + + +def _bytes_to_gb(value: Any, binary: bool = False) -> str | None: + try: + size = int(value) + except (TypeError, ValueError): + return None + divisor = 1024 ** 3 if binary else 1000 ** 3 + number = round(size / divisor, 2) + return f"{number:g}" + + + +def _path_values(data: Any, path: str) -> list[Any]: + """Liest Punktpfade; mehrere Alternativen können mit | getrennt werden.""" + for alternative in [item.strip() for item in str(path or "").split("|") if item.strip()]: + values = [data] + for part in alternative.split('.'): + next_values: list[Any] = [] + list_mode = part.endswith('[]') + key = part[:-2] if list_mode else part + for value in values: + candidate = value.get(key) if isinstance(value, dict) else None + if list_mode and isinstance(candidate, list): + next_values.extend(candidate) + elif candidate is not None: + next_values.append(candidate) + values = next_values + if not values: + break + if values: + return values + return [] + + +def _network_entries(value: Any) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + if isinstance(value, dict): + for group in value.values(): + if isinstance(group, list): + entries.extend(item for item in group if isinstance(item, dict)) + elif isinstance(value, list): + entries.extend(item for item in value if isinstance(item, dict)) + return entries + + +def _convert_mapping_value(values: list[Any], transform: str, separator: str) -> Any: + if not values: + return None + source = values[0] + if transform == 'active_ipv4_address': + for entry in _network_entries(source): + if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback': + return entry.get('address') + return None + if transform == 'active_ipv4_mac': + for entry in _network_entries(source): + if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback': + return _normalize_mac(entry.get('mac')) + return None + if transform == 'normalize_serial': + return _normalize_serial(source) + if transform == 'normalize_mac': + return _normalize_mac(source) + if transform == 'sum_memory_gb': + modules = source if isinstance(source, list) else values + total = 0 + for module in modules: + if isinstance(module, dict): + try: total += int(module.get('Capacity') or 0) + except (TypeError, ValueError): pass + return round(total / (1024 ** 3), 2) if total else None + if transform == 'format_memory_modules': + modules = source if isinstance(source, list) else values + lines: list[str] = [] + for module in modules: + if not isinstance(module, dict): + continue + try: capacity = int(module.get('Capacity') or 0) + except (TypeError, ValueError): capacity = 0 + parts = [ + str(module.get('DeviceLocator') or 'Steckplatz unbekannt'), + str(module.get('Description') or 'Speichermodul'), + f"{_bytes_to_gb(capacity, binary=True)} GB" if capacity else None, + str(module.get('Manufacturer') or '').strip() or None, + str(module.get('PartNumber') or '').strip() or None, + f"{module.get('Speed')} MHz" if module.get('Speed') else None, + ] + lines.append(' | '.join(part for part in parts if part)) + return '\n────────────────────\n'.join(lines) or None + if transform == 'sum_storage_gb': + drives = source if isinstance(source, list) else values + total = 0 + for drive in drives: + if isinstance(drive, dict): + try: total += int(drive.get('Size') or 0) + except (TypeError, ValueError): pass + return round(total / (1000 ** 3), 2) if total else None + if transform == 'format_drives': + drives = source if isinstance(source, list) else values + lines: list[str] = [] + for drive in drives: + if not isinstance(drive, dict): + continue + try: size = int(drive.get('Size') or 0) + except (TypeError, ValueError): size = 0 + model = drive.get('Model') or drive.get('Caption') or 'Unbekannter Datenträger' + size_text = _bytes_to_gb(size) if size else None + lines.append(f"{model} | {size_text} GB" if size_text else str(model)) + return '\n────────────────────\n'.join(lines) or None + if transform == 'gpu_names': + gpu = source if isinstance(source, list) else values + names = [str(item.get('Name')) for item in gpu if isinstance(item, dict) and item.get('Name')] + return separator.join(dict.fromkeys(names)) or None + if transform == 'active_antivirus': + products = source if isinstance(source, list) else values + names = [str(item.get('product')) for item in products if isinstance(item, dict) and item.get('enabled') is True and item.get('product')] + return separator.join(dict.fromkeys(names)) or None + return None + + +def _mapped_value(raw: dict[str, Any], mapping: dict[str, Any]) -> Any: + values = _path_values(raw, str(mapping.get('source_path') or '')) + mode = str(mapping.get('multi_value_mode') or 'first') + transform = str(mapping.get('transform') or 'raw') + separator = str(mapping.get('separator') or '\n') + + special = { + 'active_ipv4_address', 'active_ipv4_mac', 'normalize_serial', 'normalize_mac', + 'sum_memory_gb', 'format_memory_modules', 'sum_storage_gb', 'format_drives', + 'gpu_names', 'active_antivirus' + } + if transform in special: + return _convert_mapping_value(values, transform, separator) + + def convert(value: Any) -> Any: + if transform == 'integer': + try: return int(value) + except (TypeError, ValueError): return None + if transform == 'decimal': + try: return float(value) + except (TypeError, ValueError): return None + if transform == 'boolean': + if isinstance(value, bool): return value + return str(value).strip().lower() in {'1', 'true', 'yes', 'on', 'ja'} + if transform == 'string': return str(value) + if transform == 'json': return value + return value + + converted = [convert(value) for value in values if value not in (None, '', [], {})] + converted = [value for value in converted if value is not None] + if not converted: + return None + if mode == 'json': return converted if len(converted) > 1 else converted[0] + if mode == 'count': return len(converted) + if mode == 'join': + return separator.join(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value) for value in converted) + return converted[0] + + +def _set_custom_value(db: Session, asset: Asset, definition: FieldDefinition, value: Any) -> None: + if asset.id is None: + db.flush() + row = db.query(AssetFieldValue).filter(AssetFieldValue.asset_id==asset.id, AssetFieldValue.field_definition_id==definition.id).first() + if not row: row=AssetFieldValue(asset_id=asset.id, field_definition_id=definition.id); db.add(row) + row.value_text=row.value_number=row.value_date=None; row.value_boolean=None; row.value_datetime=None; row.value_json=None + if definition.data_type == 'json': row.value_json=value + elif definition.data_type == 'boolean': row.value_boolean=bool(value) + elif definition.data_type in {'integer','decimal'}: row.value_number=str(value) + elif definition.data_type == 'date': row.value_date=str(value) + else: row.value_text=str(value) + + +def _is_empty_value(value: Any) -> bool: + return value in (None, "", [], {}) + + +def _display_mapping_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, sort_keys=True) + return str(value) + + +def _values_equal(current: Any, incoming: Any) -> bool: + if _is_empty_value(current) and _is_empty_value(incoming): + return True + if isinstance(current, (dict, list)) or isinstance(incoming, (dict, list)): + return _display_mapping_value(current) == _display_mapping_value(incoming) + return str(current) == str(incoming) + + +def _custom_value(row: AssetFieldValue | None, definition: FieldDefinition) -> Any: + if row is None: + return None + if definition.data_type == "json": + return row.value_json + if definition.data_type in {"integer", "decimal"}: + return row.value_number + if definition.data_type == "boolean": + return row.value_boolean + if definition.data_type == "date": + return row.value_date + if definition.data_type == "datetime": + return row.value_datetime + return row.value_text + + +def _coerce_system_value(target: str, value: Any) -> Any: + # Die meisten Asset-Spalten sind Textfelder. Integer-/Fremdschlüsselfelder + # werden typgerecht geschrieben, damit dynamische Mappings auch dort sicher sind. + if target in {"mesh_mtype", "parent_asset_id", "category_id"}: + try: + return int(value) if value not in (None, "") else None + except (TypeError, ValueError): + return None + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return value if value is None else str(value) + + +def map_device(raw: dict[str, Any], field_mappings: list[dict[str, Any]] | None = None) -> dict[str, Any]: + """Erzeugt Assetwerte ausschließlich aus den aktivierten Datenbank-Mappings.""" + mapped: dict[str, Any] = {} + for mapping in field_mappings or []: + if not isinstance(mapping, dict) or not mapping.get('enabled', True): + continue + target = str(mapping.get('target_field') or '').strip() + if not target: + continue + value = _mapped_value(raw, mapping) + if value not in (None, '', [], {}): + # Niedrigere Priorität wird zuerst verarbeitet; spätere Regeln dürfen überschreiben. + mapped[target] = value + return mapped + +def _find_asset(db: Session, mapped: dict[str, Any], order: list[str]) -> tuple[Asset | None, str | None, str | None]: + for method in order: + if method == "node_id" and mapped.get("mesh_node_id"): + matches = db.query(Asset).filter(Asset.mesh_node_id == mapped["mesh_node_id"]).all() + elif method == "serial_number" and mapped.get("serial_number"): + matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all() + else: + continue + if len(matches) == 1: + return matches[0], method, None + if len(matches) > 1: + return None, method, f"Mehrere Assets stimmen über {method} überein." + return None, None, None + + + + +def _first_nonempty_path(raw: dict[str, Any], paths: list[str]) -> Any: + for path in paths: + value: Any = raw + for part in path.split('.'): + if not isinstance(value, dict) or part not in value: + value = None + break + value = value.get(part) + if value not in (None, '', [], {}): + return value + return None + + +def _inventory_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _inventory_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _network_inventory(raw: dict[str, Any]) -> list[dict[str, Any]]: + net = _inventory_dict(raw.get("net")) + interfaces = _inventory_dict(net.get("netif2")) + result: list[dict[str, Any]] = [] + for adapter_name, entries_value in interfaces.items(): + entries = _inventory_list(entries_value) + if not entries: + continue + non_loopback = [entry for entry in entries if isinstance(entry, dict) and entry.get("type") != "loopback"] + if not non_loopback: + continue + ipv4 = [str(entry.get("address")) for entry in non_loopback if entry.get("family") == "IPv4" and entry.get("address")] + ipv6 = [str(entry.get("address")) for entry in non_loopback if entry.get("family") == "IPv6" and entry.get("address")] + macs = list(dict.fromkeys(str(entry.get("mac")) for entry in non_loopback if entry.get("mac") and entry.get("mac") != "00:00:00:00:00:00")) + gateways = list(dict.fromkeys(str(entry.get("gateway")) for entry in non_loopback if entry.get("gateway"))) + netmasks = list(dict.fromkeys(str(entry.get("netmask")) for entry in non_loopback if entry.get("netmask"))) + fqdns = list(dict.fromkeys(str(entry.get("fqdn")) for entry in non_loopback if entry.get("fqdn"))) + statuses = list(dict.fromkeys(str(entry.get("status")) for entry in non_loopback if entry.get("status"))) + types = list(dict.fromkeys(str(entry.get("type")) for entry in non_loopback if entry.get("type"))) + indices = list(dict.fromkeys(entry.get("index") for entry in non_loopback if entry.get("index") is not None)) + record: dict[str, Any] = {"inventory.property.name": adapter_name} + if statuses: record["inventory.property.status"] = statuses[0] if len(statuses) == 1 else statuses + if types: record["inventory.property.type"] = types[0] if len(types) == 1 else types + if macs: record["inventory.property.mac_address"] = macs[0] if len(macs) == 1 else macs + if ipv4: record["inventory.property.ipv4_address"] = ipv4[0] if len(ipv4) == 1 else ipv4 + if ipv6: record["inventory.property.ipv6_address"] = ipv6[0] if len(ipv6) == 1 else ipv6 + if gateways: record["inventory.property.gateway"] = gateways[0] if len(gateways) == 1 else gateways + if netmasks: record["inventory.property.netmask"] = netmasks[0] if len(netmasks) == 1 else netmasks + if fqdns: record["inventory.property.dns_suffix"] = fqdns[0] if len(fqdns) == 1 else fqdns + if indices: record["inventory.property.interface_index"] = indices[0] if len(indices) == 1 else indices + result.append(record) + return result + + +def _security_inventory(raw: dict[str, Any]) -> dict[str, Any]: + node = _inventory_dict(raw.get("node")) + antivirus_entries = _inventory_list(node.get("av")) + antivirus: list[dict[str, Any]] = [] + seen: set[tuple[Any, Any, Any]] = set() + for item in antivirus_entries: + if not isinstance(item, dict): + continue + key = (item.get("product"), item.get("enabled"), item.get("updated")) + if key in seen: + continue + seen.add(key) + antivirus.append({ + "inventory.property.name": item.get("product") or "inventory.value.unknown", + "inventory.property.enabled": item.get("enabled"), + "inventory.property.up_to_date": item.get("updated"), + }) + result: dict[str, Any] = {} + if antivirus: + result["inventory.group.antivirus"] = antivirus + if isinstance(node.get("wsc"), dict) and node.get("wsc"): + result["inventory.group.windows_security_center"] = node.get("wsc") + if isinstance(node.get("defender"), dict) and node.get("defender"): + result["inventory.group.microsoft_defender"] = node.get("defender") + return result + + +def _build_hardware_inventory(raw: dict[str, Any]) -> dict[str, Any]: + """Build a language-neutral inventory. Known labels are i18n keys.""" + sys_data = _inventory_dict(raw.get("sys")) + hardware = _inventory_dict(sys_data.get("hardware")) + windows = _inventory_dict(hardware.get("windows")) + identifiers = _inventory_dict(hardware.get("identifiers")) + node = _inventory_dict(raw.get("node")) + inventory: dict[str, Any] = {} + + system_overview = { + "inventory.property.device_name": node.get("name") or node.get("rname"), + "inventory.property.manufacturer": identifiers.get("bios_vendor") or identifiers.get("board_vendor"), + "inventory.property.model": identifiers.get("product_name"), + "inventory.property.product_uuid": identifiers.get("product_uuid"), + "inventory.property.serial_number": identifiers.get("bios_serial"), + "inventory.property.mesh_group": node.get("groupname"), + "inventory.property.operating_system": node.get("osdesc"), + } + system_overview = {k:v for k,v in system_overview.items() if v not in (None,"",[],{})} + if system_overview: inventory["inventory.section.system_overview"] = system_overview + + bios = { + "inventory.property.manufacturer": identifiers.get("bios_vendor"), + "inventory.property.version": identifiers.get("bios_version"), + "inventory.property.date": identifiers.get("bios_date"), + "inventory.property.mode": identifiers.get("bios_mode"), + "inventory.property.serial_number": identifiers.get("bios_serial"), + } + bios = {k:v for k,v in bios.items() if v not in (None,"",[],{})} + if bios: inventory["inventory.section.bios"] = bios + + board = { + "inventory.property.manufacturer": identifiers.get("board_vendor"), + "inventory.property.model": identifiers.get("board_name"), + "inventory.property.version": identifiers.get("board_version"), + "inventory.property.serial_number": identifiers.get("board_serial"), + } + board = {k:v for k,v in board.items() if v not in (None,"",[],{})} + if board: inventory["inventory.section.mainboard"] = board + + if windows.get("cpu"): inventory["inventory.section.processors"] = windows.get("cpu") + if windows.get("memory"): inventory["inventory.section.memory"] = windows.get("memory") + + storage: dict[str, Any] = {} + if windows.get("drives"): storage["inventory.group.physical_drives"] = windows.get("drives") + if identifiers.get("storage_devices"): storage["inventory.group.device_identifiers"] = identifiers.get("storage_devices") + if windows.get("partitions"): storage["inventory.group.partitions"] = windows.get("partitions") + if windows.get("volumes"): storage["inventory.group.volumes"] = windows.get("volumes") + if storage: inventory["inventory.section.storage"] = storage + + if windows.get("gpu"): inventory["inventory.section.graphics"] = windows.get("gpu") + + network_details: dict[str, Any] = {} + adapters = _network_inventory(raw) + if adapters: network_details["inventory.group.adapters"] = adapters + hardware_network = _inventory_dict(hardware.get("network")) + if hardware_network.get("dns"): network_details["inventory.group.dns_servers"] = hardware_network.get("dns") + if network_details: inventory["inventory.section.network"] = network_details + + if hardware.get("tpm"): inventory["inventory.section.tpm"] = hardware.get("tpm") + if windows.get("osinfo"): inventory["inventory.section.os_details"] = windows.get("osinfo") + security = _security_inventory(raw) + if security: inventory["inventory.section.security"] = security + + runtime: dict[str, Any] = {} + if node.get("agent"): runtime["inventory.group.mesh_agent"] = node.get("agent") + if hardware.get("agentvers"): runtime["inventory.group.agent_build"] = hardware.get("agentvers") + if node.get("intelamt"): runtime["inventory.group.intel_amt"] = node.get("intelamt") + if node.get("users"): runtime["inventory.group.known_users"] = node.get("users") + if node.get("lusers"): runtime["inventory.group.logged_on_users"] = node.get("lusers") + if node.get("lastbootuptime"): runtime["inventory.property.last_boot_unix_ms"] = node.get("lastbootuptime") + if raw.get("lastConnect"): runtime["inventory.group.last_connection"] = raw.get("lastConnect") + if runtime: inventory["inventory.section.mesh_runtime"] = runtime + + known_hardware_keys = {"agentvers", "identifiers", "network", "tpm", "windows"} + extra_hardware = {k:v for k,v in hardware.items() if k not in known_hardware_keys and v not in (None,"",[],{})} + if extra_hardware: inventory["inventory.section.other_hardware"] = extra_hardware + known_windows_keys = {"cpu", "memory", "drives", "partitions", "volumes", "gpu", "osinfo", "software"} + extra_windows = {k:v for k,v in windows.items() if k not in known_windows_keys and v not in (None,"",[],{})} + if extra_windows: inventory["inventory.section.other_windows_hardware"] = extra_windows + return inventory + +def _inventory_segments(raw: dict[str, Any]) -> tuple[dict[str, Any], list[Any] | dict[str, Any]]: + """Extract readable inventory snapshots while retaining mesh_source_data as raw truth.""" + hardware = _build_hardware_inventory(raw) + software = _first_nonempty_path(raw, [ + 'sys.software', 'sys.hardware.windows.software', 'software', + 'installedSoftware', 'installedApplications', 'node.software' + ]) + if not isinstance(software, (list, dict)): + software = [] + return hardware, software + +def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> SyncResult: + log = log or (lambda level, message: None) + cfg = load_config()["meshcentral"] + run = SyncRun(status="running") + db.add(run) + db.commit() + db.refresh(run) + now = datetime.utcnow() + seen_node_ids: set[str] = set() + mapping_rows = db.query(MeshFieldMapping).filter(MeshFieldMapping.enabled.is_(True)).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all() + field_mappings = [{ + "source_path": row.source_path, "target_field": row.target_field_name, + "transform": row.transform, "multi_value_mode": row.multi_value_mode, + "separator": row.separator, "enabled": row.enabled, "update_rule": row.update_rule, + } for row in mapping_rows] + mapping_rules = {row.target_field_name: row.update_rule for row in mapping_rows} + field_definitions = { + definition.field_name: definition + for definition in db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).all() + } + + try: + log("info", f"{len(mapping_rows)} aktive Feld-Mapping(s) geladen.") + unknown_targets = sorted({row.target_field_name for row in mapping_rows if row.target_field_name not in field_definitions}) + for target in unknown_targets: + log("error", f"Aktives Mapping verweist auf unbekanntes oder inaktives Zielfeld '{target}'.") + run.error_count += 1 + missing_columns = sorted({ + row.target_field_name for row in mapping_rows + if row.target_field_name in field_definitions + and field_definitions[row.target_field_name].is_system + and not hasattr(Asset, row.target_field_name) + }) + for target in missing_columns: + log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.") + run.error_count += 1 + log("info", "Synchronisierung gestartet.") + devices = fetch_devices(log) + run.devices_found = len(devices) + log("info", f"Verarbeite {len(devices)} Gerät(e).") + fallback_id = int(cfg.get("fallback_category_id") or 1) + fallback_category = db.query(Category).filter(Category.id == fallback_id).first() + if fallback_category is None and fallback_id != 1: + fallback_category = db.query(Category).filter(Category.id == 1).first() + if fallback_category is None: + fallback_category = db.query(Category).order_by(Category.id.asc()).first() + if fallback_category is None: + raise RuntimeError("Es ist keine Asset-Kategorie vorhanden. Bitte zuerst eine Kategorie anlegen.") + category_mapping = cfg.get("category_mapping") or {} + log("info", f"Fallback-Kategorie: {fallback_category.name} (ID {fallback_category.id}).") + + for raw in devices: + try: + mapped = map_device(raw, field_mappings) + node_id = mapped.get("mesh_node_id") + if not node_id: + run.error_count += 1 + log("error", "Gerät ohne Node-ID wurde übersprungen.") + continue + seen_node_ids.add(node_id) + asset, match_method, match_error = _find_asset(db, mapped, cfg.get("match_order", ["node_id", "serial_number"])) + if match_error: + run.conflict_count += 1 + log("warning", f"{mapped.get('name', node_id)}: {match_error}") + continue + is_new = asset is None + if asset is None: + if not cfg.get("create_missing_assets", True): + continue + device_type = str(mapped.get("mesh_mtype") or 0) + mapped_category_id = category_mapping.get(device_type) + try: + mapped_category_id = int(mapped_category_id) if mapped_category_id not in (None, "") else None + except (TypeError, ValueError): + mapped_category_id = None + category = db.query(Category).filter(Category.id == mapped_category_id).first() if mapped_category_id else None + if category is None: + category = fallback_category + log("warning", f"{mapped.get('name', node_id)}: keine gültige Kategorie für MeshCentral-Typ {device_type}; Fallback '{category.name}' (ID {category.id}) verwendet.") + asset = Asset(category_id=category.id, name=mapped.get("name", node_id), mesh_node_id=node_id) + db.add(asset) + run.created_count += 1 + log("success", f"{mapped.get('name', node_id)}: neues Asset in Kategorie '{category.name}' (ID {category.id}) angelegt.") + elif match_method == "serial_number" and not asset.mesh_node_id: + asset.mesh_node_id = node_id + log("info", f"{asset.name}: über Seriennummer zugeordnet.") + + conflicts: dict[str, Any] = {} + history_changes: dict[str, dict[str, str]] = {} + changed = False + + # Ausschließlich die aktiven Mapping-Regeln bestimmen, welche + # System- und Custom-Felder synchronisiert werden. Es gibt keine + # feste Feld-Whitelist mehr. + for target, incoming in mapped.items(): + if target == "mesh_device_type" or incoming in (None, "", [], {}): + continue + + definition = field_definitions.get(target) + if definition is None: + # Das Zielfeld muss im zentralen Feldkatalog existieren. + # Die Prüfung wird zusätzlich vor der Geräteschleife protokolliert. + continue + + rule = mapping_rules.get(target, "fill_empty") + label = definition.label or ASSET_FIELDS.get(target, target) + + if definition.is_system: + if not hasattr(asset, target): + log("error", f"{asset.name}: Systemfeld '{target}' besitzt keine Asset-Datenbankspalte.") + run.error_count += 1 + continue + current = getattr(asset, target) + else: + current_row = db.query(AssetFieldValue).filter( + AssetFieldValue.asset_id == asset.id, + AssetFieldValue.field_definition_id == definition.id, + ).first() + current = _custom_value(current_row, definition) + + if _values_equal(current, incoming): + continue + + should_write = _is_empty_value(current) or rule == "meshcentral_wins" + if rule == "local_wins" and not _is_empty_value(current): + should_write = False + + if should_write: + old_display = _display_mapping_value(current) + new_display = _display_mapping_value(incoming) + if definition.is_system: + setattr(asset, target, _coerce_system_value(target, incoming)) + else: + _set_custom_value(db, asset, definition, incoming) + history_changes[label] = {"old": old_display, "new": new_display} + changed = True + log("debug", f"{asset.name}: Mapping '{target}' angewendet ({rule}).") + elif rule == "fill_empty": + conflicts[target] = { + "local": current, + "meshcentral": incoming, + "rule": rule, + } + + if history_changes: + db.flush() + db.add(AssetHistory( + asset_id=asset.id, + source="meshcentral-create" if is_new else "meshcentral-sync", + changed_by="MeshCentral", + changes=history_changes, + )) + + asset.mesh_last_sync = now + reported_last_seen = extract_last_seen(raw) + if reported_last_seen and (asset.mesh_last_seen is None or reported_last_seen > asset.mesh_last_seen): + asset.mesh_last_seen = reported_last_seen + asset.mesh_source_data = raw if cfg.get("store_source_json", True) else {} + hardware_snapshot, software_snapshot = _inventory_segments(raw) + asset.mesh_hardware_data = hardware_snapshot + asset.mesh_software_data = software_snapshot + asset.mesh_inventory_updated_at = now + asset.mesh_conflicts = conflicts + if conflicts: + asset.mesh_sync_status = "conflict" + asset.mesh_sync_message = f"{len(conflicts)} Feldkonflikt(e)" + run.conflict_count += 1 + log("warning", f"{asset.name}: {len(conflicts)} Feldkonflikt(e).") + elif is_new: + asset.mesh_sync_status = "created" + asset.mesh_sync_message = "Neu aus MeshCentral importiert" + else: + asset.mesh_sync_status = "updated" if changed else "synced" + asset.mesh_sync_message = "Über Seriennummer zugeordnet" if match_method == "serial_number" else "Synchronisiert" + if changed: + run.updated_count += 1 + log("info", f"{asset.name}: Daten aktualisiert.") + else: + log("debug", f"{asset.name}: keine Änderungen.") + except Exception as item_error: + run.error_count += 1 + log("error", f"Gerätefehler: {item_error}") + run.message = ((run.message or "") + f"\nGerätefehler: {item_error}").strip() + + if cfg.get("mark_missing_devices", True): + linked = db.query(Asset).filter(Asset.mesh_node_id.isnot(None)).all() + for asset in linked: + if asset.mesh_node_id not in seen_node_ids: + asset.mesh_sync_status = "missing" + asset.mesh_sync_message = "Beim letzten MeshCentral-Import nicht gefunden" + log("warning", f"{asset.name}: in MeshCentral nicht mehr gefunden.") + + run.status = "success" if run.error_count == 0 else "partial" + run.finished_at = datetime.utcnow() + db.commit() + log("success", f"Synchronisierung beendet: {run.created_count} neu, {run.updated_count} geändert, {run.conflict_count} Konflikte, {run.error_count} Fehler.") + except Exception as error: + db.rollback() + run = db.query(SyncRun).filter(SyncRun.id == run.id).first() + run.status = "error" + run.error_count += 1 + run.message = str(error) + run.finished_at = datetime.utcnow() + db.commit() + log("error", f"Synchronisierung abgebrochen: {error}") + return SyncResult(run=run) +from .presence_state import extract_last_seen + diff --git a/app/migrations.py b/app/migrations.py new file mode 100644 index 0000000..3b3bd18 --- /dev/null +++ b/app/migrations.py @@ -0,0 +1,209 @@ +from sqlalchemy import inspect, text +from .database import engine + +USER_COLUMNS = { + "allowed_mesh_groups": "JSON DEFAULT '[]'::json", + "asset_access_scopes": "JSON DEFAULT '[]'::json", + "profile_department": "VARCHAR(150) NULL", + "profile_location": "VARCHAR(150) NULL", + "language_code": "VARCHAR(10) DEFAULT 'en'", + "persist_browser_state": "BOOLEAN DEFAULT FALSE", + "toast_position": "VARCHAR(30) DEFAULT 'top-right'", + "toast_duration_seconds": "INTEGER DEFAULT 6", + "theme_mode": "VARCHAR(20) DEFAULT 'light'", + "is_protected": "BOOLEAN NOT NULL DEFAULT FALSE", +} + +CHART_COLUMNS = { + "value_mode": "VARCHAR(30) DEFAULT 'raw'", + "value_mappings": "JSON DEFAULT '[]'::json", + "excluded_statuses": "JSON DEFAULT '[]'::json", + "is_system": "BOOLEAN DEFAULT FALSE", +} + +FIELD_DEFINITION_COLUMNS = { + "is_default": "BOOLEAN NOT NULL DEFAULT FALSE", + "list_width": "INTEGER NULL", +} + +CATEGORY_FIELD_COLUMNS = { + "readonly": "BOOLEAN DEFAULT FALSE", + "field_definition_id": "INTEGER NULL REFERENCES field_definitions(id) ON DELETE CASCADE", +} + +STATUS_COLUMNS = { + "use_for_issue": "BOOLEAN DEFAULT FALSE", + "use_for_return": "BOOLEAN DEFAULT FALSE", +} + + +MESH_MAPPING_COLUMNS = { + "update_rule": "VARCHAR(30) NOT NULL DEFAULT 'fill_empty'", + "is_system": "BOOLEAN NOT NULL DEFAULT FALSE", +} + + +INSTALLED_SOFTWARE_COLUMNS = { + "platform": "VARCHAR(40) NOT NULL DEFAULT 'unknown'", +} + + +JOB_DEFINITION_COLUMNS = { + "job_kind": "VARCHAR(30) NOT NULL DEFAULT 'script'", + "configuration": "JSON DEFAULT '{}'::json", +} + +SOFTWARE_JOB_COLUMNS = { + "action": "VARCHAR(30) NOT NULL DEFAULT 'run'", + "job_type": "VARCHAR(50) NOT NULL DEFAULT 'software_inventory'", + "priority": "INTEGER NOT NULL DEFAULT 100", + "result_data": "JSON DEFAULT '{}'::json", + "attempt_count": "INTEGER NOT NULL DEFAULT 0", + "max_attempts": "INTEGER NOT NULL DEFAULT 1", + "scheduled_at": "TIMESTAMP NULL", + "resolved_script": "TEXT NULL", +} + +ASSET_COLUMNS = { + "mesh_last_seen": "TIMESTAMP NULL", + "mesh_online": "BOOLEAN NULL", + "mesh_online_state": "VARCHAR(20) NOT NULL DEFAULT 'unknown'", + "mesh_presence_updated_at": "TIMESTAMP NULL", + "mesh_last_online_at": "TIMESTAMP NULL", + "mesh_presence_source": "VARCHAR(40) NULL", + "mesh_sync_status": "VARCHAR(30) NULL", + "mesh_sync_message": "TEXT NULL", + "mesh_conflicts": "JSON DEFAULT '{}'::json", + "mesh_source_data": "JSON DEFAULT '{}'::json", + "mesh_hardware_data": "JSON DEFAULT '{}'::json", + "mesh_software_data": "JSON DEFAULT '[]'::json", + "mesh_inventory_updated_at": "TIMESTAMP NULL", + "storage_details": "TEXT NULL", + "gpu_name": "TEXT NULL", + "tpm_version": "VARCHAR(50) NULL", + "memory_details": "TEXT NULL", + "antivirus": "TEXT NULL", + "mesh_group": "VARCHAR(180) NULL", + "assigned_on": "VARCHAR(20) NULL", + "room_number": "VARCHAR(80) NULL", + "parent_asset_id": "INTEGER NULL REFERENCES assets(id) ON DELETE SET NULL", + "mesh_mtype": "INTEGER NULL", +} + + +def apply_lightweight_migrations() -> None: + inspector = inspect(engine) + if "assets" not in inspector.get_table_names(): + return + existing = {column["name"] for column in inspector.get_columns("assets")} + with engine.begin() as connection: + for name, ddl in ASSET_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE assets ADD COLUMN "{name}" {ddl}')) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_sync_status ON assets (mesh_sync_status)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_online ON assets (mesh_online)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_online_state ON assets (mesh_online_state)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_room_number ON assets (room_number)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_parent_asset_id ON assets (parent_asset_id)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_mtype ON assets (mesh_mtype)")) + inspector = inspect(engine) + with engine.begin() as connection: + if "users" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("users")} + for name, ddl in USER_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE users ADD COLUMN "{name}" {ddl}')) + if "field_definitions" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("field_definitions")} + for name, ddl in FIELD_DEFINITION_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE field_definitions ADD COLUMN "{name}" {ddl}')) + connection.execute(text(""" + UPDATE field_definitions + SET is_default = TRUE + WHERE field_name IN ( + 'asset_tag','name','manufacturer','model','serial_number', + 'status','assigned_to','assigned_on','location','room_number','notes' + ) + AND is_default = FALSE + """)) + if "category_fields" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("category_fields")} + for name, ddl in CATEGORY_FIELD_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE category_fields ADD COLUMN "{name}" {ddl}')) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_category_fields_field_definition_id ON category_fields (field_definition_id)")) + if "status_options" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("status_options")} + for name, ddl in STATUS_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE status_options ADD COLUMN "{name}" {ddl}')) + if "mesh_field_mappings" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("mesh_field_mappings")} + for name, ddl in MESH_MAPPING_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE mesh_field_mappings ADD COLUMN "{name}" {ddl}')) + if "installed_software" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("installed_software")} + for name, ddl in INSTALLED_SOFTWARE_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE installed_software ADD COLUMN "{name}" {ddl}')) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_platform ON installed_software (platform)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_summary ON installed_software (platform, name, publisher)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_summary_version ON installed_software (name, publisher, platform, version)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_list ON installed_software (name, version, asset_id, id)")) + if "software_jobs" in inspector.get_table_names(): + connection.execute(text(""" + UPDATE installed_software AS software + SET platform = COALESCE( + NULLIF(( + SELECT LOWER(software_jobs.platform) + FROM software_jobs + WHERE software_jobs.asset_id = software.asset_id + ORDER BY software_jobs.created_at DESC + LIMIT 1 + ), ''), + 'unknown' + ) + WHERE software.platform IS NULL OR software.platform = '' OR software.platform = 'unknown' + """)) + if "job_definitions" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("job_definitions")} + for name, ddl in JOB_DEFINITION_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE job_definitions ADD COLUMN "{name}" {ddl}')) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_job_definitions_job_kind ON job_definitions (job_kind)")) + connection.execute(text("UPDATE job_definitions SET job_kind = 'script' WHERE job_kind IS NULL OR job_kind = ''")) + if "software_jobs" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("software_jobs")} + for name, ddl in SOFTWARE_JOB_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE software_jobs ADD COLUMN "{name}" {ddl}')) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_action ON software_jobs (action)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_job_type ON software_jobs (job_type)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_priority ON software_jobs (priority)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_scheduled_at ON software_jobs (scheduled_at)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_recent ON software_jobs (created_at DESC, id DESC)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_timeout_scan ON software_jobs (callback_completed_at, callback_expires_at, status)")) + connection.execute(text("UPDATE software_jobs SET job_type = 'software_inventory' WHERE job_type IS NULL OR job_type = ''")) + connection.execute(text("UPDATE software_jobs SET priority = 100 WHERE priority IS NULL")) + connection.execute(text("UPDATE software_jobs SET attempt_count = 0 WHERE attempt_count IS NULL")) + connection.execute(text("UPDATE software_jobs SET max_attempts = 1 WHERE max_attempts IS NULL")) + if "chart_definitions" in inspector.get_table_names(): + existing = {column["name"] for column in inspector.get_columns("chart_definitions")} + for name, ddl in CHART_COLUMNS.items(): + if name not in existing: + connection.execute(text(f'ALTER TABLE chart_definitions ADD COLUMN "{name}" {ddl}')) + # Das vorhandene Standarddiagramm wird nach dem Update sofort sinnvoll zusammengefasst. + connection.execute(text(""" + UPDATE chart_definitions + SET value_mode = 'automatic' + WHERE group_field = 'operating_system' + AND name = 'Betriebssysteme' + AND COALESCE(value_mode, 'raw') = 'raw' + """)) + connection.execute(text(""" + UPDATE chart_definitions + SET is_system = TRUE + WHERE name IN ('Betriebssysteme', 'Geräte nach Kategorie und Status', 'Hersteller', 'Statusübersicht') + """)) diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e167be9 --- /dev/null +++ b/app/models.py @@ -0,0 +1,478 @@ +from datetime import datetime +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .database import Base + + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + image_path: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + assets: Mapped[list["Asset"]] = relationship(back_populates="category") + visible_fields: Mapped[list["CategoryField"]] = relationship( + back_populates="category", cascade="all, delete-orphan", order_by="CategoryField.sort_order" + ) + + +class FieldDefinition(Base): + __tablename__ = "field_definitions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + field_name: Mapped[str] = mapped_column(String(80), unique=True, index=True) + label: Mapped[str] = mapped_column(String(120)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + data_type: Mapped[str] = mapped_column(String(30), default="short_text") + readonly: Mapped[bool] = mapped_column(Boolean, default=False) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + translation_key: Mapped[str | None] = mapped_column(String(180), nullable=True) + searchable: Mapped[bool] = mapped_column(Boolean, default=True) + filterable: Mapped[bool] = mapped_column(Boolean, default=True) + chart_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + excel_import: Mapped[bool] = mapped_column(Boolean, default=True) + excel_export: Mapped[bool] = mapped_column(Boolean, default=True) + bulk_edit: Mapped[bool] = mapped_column(Boolean, default=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + list_width: Mapped[int | None] = mapped_column(Integer, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + category_links: Mapped[list["CategoryField"]] = relationship(back_populates="definition", cascade="all, delete-orphan") + values: Mapped[list["AssetFieldValue"]] = relationship(back_populates="definition", cascade="all, delete-orphan") + + +class CategoryField(Base): + __tablename__ = "category_fields" + __table_args__ = (UniqueConstraint("category_id", "field_name"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE")) + field_name: Mapped[str] = mapped_column(String(80)) + label: Mapped[str] = mapped_column(String(100)) + field_definition_id: Mapped[int | None] = mapped_column(ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=True, index=True) + active: Mapped[bool] = mapped_column(Boolean, default=True) + required: Mapped[bool] = mapped_column(Boolean, default=False) + show_in_list: Mapped[bool] = mapped_column(Boolean, default=False) + readonly: Mapped[bool] = mapped_column(Boolean, default=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + + category: Mapped[Category] = relationship(back_populates="visible_fields") + definition: Mapped[FieldDefinition | None] = relationship(back_populates="category_links") + + @property + def effective_label(self) -> str: + return self.definition.label if self.definition else self.label + + @property + def effective_readonly(self) -> bool: + return bool(self.definition.readonly) if self.definition else bool(getattr(self, "readonly", False)) + + +class Asset(Base): + __tablename__ = "assets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), index=True) + asset_tag: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True, index=True) + name: Mapped[str] = mapped_column(String(150), index=True) + manufacturer: Mapped[str | None] = mapped_column(String(150), nullable=True) + model: Mapped[str | None] = mapped_column(String(150), nullable=True) + serial_number: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True) + status: Mapped[str | None] = mapped_column(String(60), nullable=True) + assigned_to: Mapped[str | None] = mapped_column(String(150), nullable=True) + assigned_on: Mapped[str | None] = mapped_column(String(20), nullable=True) + location: Mapped[str | None] = mapped_column(String(150), nullable=True) + room_number: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True) + parent_asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True) + department: Mapped[str | None] = mapped_column(String(150), nullable=True) + purchase_date: Mapped[str | None] = mapped_column(String(20), nullable=True) + warranty_until: Mapped[str | None] = mapped_column(String(20), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + image_path: Mapped[str | None] = mapped_column(String(255), nullable=True) + + hostname: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True) + operating_system: Mapped[str | None] = mapped_column(String(150), nullable=True) + os_patch_level: Mapped[str | None] = mapped_column(String(150), nullable=True) + mac_address: Mapped[str | None] = mapped_column(String(50), nullable=True) + ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True) + cpu: Mapped[str | None] = mapped_column(String(200), nullable=True) + memory_gb: Mapped[str | None] = mapped_column(String(40), nullable=True) + storage_gb: Mapped[str | None] = mapped_column(String(40), nullable=True) + storage_details: Mapped[str | None] = mapped_column(Text, nullable=True) + gpu_name: Mapped[str | None] = mapped_column(Text, nullable=True) + tpm_version: Mapped[str | None] = mapped_column(String(50), nullable=True) + memory_details: Mapped[str | None] = mapped_column(Text, nullable=True) + antivirus: Mapped[str | None] = mapped_column(Text, nullable=True) + mesh_group: Mapped[str | None] = mapped_column(String(180), nullable=True) + mesh_mtype: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + + mesh_node_id: Mapped[str | None] = mapped_column(String(180), unique=True, nullable=True, index=True) + mesh_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + mesh_last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + mesh_online: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True) + mesh_online_state: Mapped[str] = mapped_column(String(20), default="unknown", index=True) + mesh_presence_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + mesh_last_online_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + mesh_presence_source: Mapped[str | None] = mapped_column(String(40), nullable=True) + mesh_sync_status: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + mesh_sync_message: Mapped[str | None] = mapped_column(Text, nullable=True) + mesh_conflicts: Mapped[dict] = mapped_column(JSON, default=dict) + mesh_source_data: Mapped[dict] = mapped_column(JSON, default=dict) + mesh_hardware_data: Mapped[dict] = mapped_column(JSON, default=dict) + mesh_software_data: Mapped[list | dict] = mapped_column(JSON, default=list) + mesh_inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + extra_data: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + category: Mapped[Category] = relationship(back_populates="assets") + parent_asset: Mapped["Asset | None"] = relationship("Asset", remote_side="Asset.id", back_populates="child_assets", foreign_keys=[parent_asset_id]) + child_assets: Mapped[list["Asset"]] = relationship("Asset", back_populates="parent_asset", foreign_keys=[parent_asset_id]) + + +class AssetFieldValue(Base): + __tablename__ = "asset_field_values" + __table_args__ = (UniqueConstraint("asset_id", "field_definition_id"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + field_definition_id: Mapped[int] = mapped_column(ForeignKey("field_definitions.id", ondelete="CASCADE"), index=True) + value_text: Mapped[str | None] = mapped_column(Text, nullable=True) + value_number: Mapped[str | None] = mapped_column(String(100), nullable=True) + value_boolean: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + value_date: Mapped[str | None] = mapped_column(String(20), nullable=True) + value_datetime: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + value_json: Mapped[dict | list | None] = mapped_column(JSON, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + asset: Mapped[Asset] = relationship() + definition: Mapped[FieldDefinition] = relationship(back_populates="values") + + +class MeshFieldMapping(Base): + __tablename__ = "mesh_field_mappings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source_path: Mapped[str] = mapped_column(String(255), index=True) + target_field_name: Mapped[str] = mapped_column(String(80), index=True) + transform: Mapped[str] = mapped_column(String(40), default="raw") + multi_value_mode: Mapped[str] = mapped_column(String(30), default="first") + separator: Mapped[str] = mapped_column(String(20), default="\n") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + priority: Mapped[int] = mapped_column(Integer, default=0) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + update_rule: Mapped[str] = mapped_column(String(30), default="fill_empty") + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + + +class AssetHistory(Base): + __tablename__ = "asset_history" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + changed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + source: Mapped[str] = mapped_column(String(50), default="manual") + changed_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + changes: Mapped[dict] = mapped_column(JSON, default=dict) + + +class StatusOption(Base): + __tablename__ = "status_options" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(60), unique=True, index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + active: Mapped[bool] = mapped_column(Boolean, default=True) + use_for_issue: Mapped[bool] = mapped_column(Boolean, default=False) + use_for_return: Mapped[bool] = mapped_column(Boolean, default=False) + + +class SyncRun(Base): + __tablename__ = "sync_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source: Mapped[str] = mapped_column(String(50), default="meshcentral") + started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(30), default="running") + devices_found: Mapped[int] = mapped_column(Integer, default=0) + created_count: Mapped[int] = mapped_column(Integer, default=0) + updated_count: Mapped[int] = mapped_column(Integer, default=0) + conflict_count: Mapped[int] = mapped_column(Integer, default=0) + error_count: Mapped[int] = mapped_column(Integer, default=0) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class ChartDefinition(Base): + __tablename__ = "chart_definitions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(150), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + chart_type: Mapped[str] = mapped_column(String(30), default="bar") + group_field: Mapped[str] = mapped_column(String(80), default="category") + stack_field: Mapped[str | None] = mapped_column(String(80), nullable=True) + dashboard_visible: Mapped[bool] = mapped_column(Boolean, default=False) + show_empty: Mapped[bool] = mapped_column(Boolean, default=False) + max_items: Mapped[int] = mapped_column(Integer, default=12) + sort_mode: Mapped[str] = mapped_column(String(30), default="count_desc") + value_mode: Mapped[str] = mapped_column(String(30), default="raw") + value_mappings: Mapped[list] = mapped_column(JSON, default=list) + excluded_statuses: Mapped[list] = mapped_column(JSON, default=list) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + created_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class Language(Base): + __tablename__ = "languages" + + code: Mapped[str] = mapped_column(String(10), primary_key=True) + name: Mapped[str] = mapped_column(String(80)) + native_name: Mapped[str] = mapped_column(String(80)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + + +class Translation(Base): + __tablename__ = "translations" + __table_args__ = (UniqueConstraint("translation_key", "language_code"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + translation_key: Mapped[str] = mapped_column(String(180), index=True) + language_code: Mapped[str] = mapped_column(ForeignKey("languages.code", ondelete="CASCADE"), index=True) + text_value: Mapped[str] = mapped_column(Text) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + username: Mapped[str] = mapped_column(String(120), unique=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(180), nullable=True) + email: Mapped[str | None] = mapped_column(String(180), nullable=True) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + auth_source: Mapped[str] = mapped_column(String(30), default="local") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) + is_protected: Mapped[bool] = mapped_column(Boolean, default=False) + allowed_mesh_groups: Mapped[list] = mapped_column(JSON, default=list) + asset_access_scopes: Mapped[list] = mapped_column(JSON, default=list) + profile_department: Mapped[str | None] = mapped_column(String(150), nullable=True) + profile_location: Mapped[str | None] = mapped_column(String(150), nullable=True) + language_code: Mapped[str] = mapped_column(String(10), default="en") + persist_browser_state: Mapped[bool] = mapped_column(Boolean, default=False) + toast_position: Mapped[str] = mapped_column(String(30), default="top-right") + toast_duration_seconds: Mapped[int] = mapped_column(Integer, default=6) + theme_mode: Mapped[str] = mapped_column(String(20), default="light") + last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class JobDefinition(Base): + __tablename__ = "job_definitions" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(180), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + job_kind: Mapped[str] = mapped_column(String(30), default="script", index=True) + configuration: Mapped[dict] = mapped_column(JSON, default=dict) + system_key: Mapped[str | None] = mapped_column(String(120), unique=True, nullable=True, index=True) + platform: Mapped[str] = mapped_column(String(30), default="windows", index=True) + interpreter: Mapped[str] = mapped_column(String(30), default="powershell") + source_type: Mapped[str] = mapped_column(String(30), default="inline") + source_path: Mapped[str | None] = mapped_column(Text, nullable=True) + inline_script: Mapped[str | None] = mapped_column(Text, nullable=True) + arguments: Mapped[str | None] = mapped_column(Text, nullable=True) + timeout_seconds: Mapped[int] = mapped_column(Integer, default=300) + success_codes: Mapped[str] = mapped_column(String(120), default="0") + enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + revision: Mapped[int] = mapped_column(Integer, default=1) + created_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + revisions: Mapped[list["JobDefinitionRevision"]] = relationship(back_populates="definition", cascade="all, delete-orphan", order_by="JobDefinitionRevision.revision.desc()") + parameters: Mapped[list["JobDefinitionParameter"]] = relationship(back_populates="definition", cascade="all, delete-orphan", order_by="JobDefinitionParameter.sort_order") + + +class JobDefinitionParameter(Base): + __tablename__ = "job_definition_parameters" + __table_args__ = (UniqueConstraint("definition_id", "parameter_key", name="uq_job_definition_parameter_key"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + definition_id: Mapped[int] = mapped_column(ForeignKey("job_definitions.id", ondelete="CASCADE"), index=True) + parameter_key: Mapped[str] = mapped_column(String(80)) + label: Mapped[str] = mapped_column(String(140)) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + data_type: Mapped[str] = mapped_column(String(30), default="text") + default_value: Mapped[str | None] = mapped_column(Text, nullable=True) + required: Mapped[bool] = mapped_column(Boolean, default=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + definition: Mapped["JobDefinition"] = relationship(back_populates="parameters") + + +class JobDefinitionAssetParameter(Base): + __tablename__ = "job_definition_asset_parameters" + __table_args__ = (UniqueConstraint("definition_parameter_id", "asset_id", name="uq_job_definition_asset_parameter"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + definition_parameter_id: Mapped[int] = mapped_column(ForeignKey("job_definition_parameters.id", ondelete="CASCADE"), index=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + value_text: Mapped[str | None] = mapped_column(Text, nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + parameter: Mapped["JobDefinitionParameter"] = relationship() + asset: Mapped["Asset"] = relationship() + + +class JobDefinitionRevision(Base): + __tablename__ = "job_definition_revisions" + __table_args__ = (UniqueConstraint("definition_id", "revision", name="uq_job_definition_revision"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + definition_id: Mapped[int] = mapped_column(ForeignKey("job_definitions.id", ondelete="CASCADE"), index=True) + revision: Mapped[int] = mapped_column(Integer) + snapshot: Mapped[dict] = mapped_column(JSON, default=dict) + changed_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + definition: Mapped["JobDefinition"] = relationship(back_populates="revisions") + + +class SoftwarePackage(Base): + __tablename__ = "software_packages" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(180), unique=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + package_type: Mapped[str] = mapped_column(String(40), default="command") + enabled: Mapped[bool] = mapped_column(Boolean, default=True) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + command_windows: Mapped[str | None] = mapped_column(Text, nullable=True) + command_linux: Mapped[str | None] = mapped_column(Text, nullable=True) + command_macos: Mapped[str | None] = mapped_column(Text, nullable=True) + command_android: Mapped[str | None] = mapped_column(Text, nullable=True) + callback_timeout_minutes: Mapped[int] = mapped_column(Integer, default=30) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + parameters: Mapped[list["SoftwarePackageParameter"]] = relationship(back_populates="package", cascade="all, delete-orphan", order_by="SoftwarePackageParameter.sort_order") + jobs: Mapped[list["SoftwareJob"]] = relationship(back_populates="package") + + +class SoftwarePackageParameter(Base): + __tablename__ = "software_package_parameters" + __table_args__ = (UniqueConstraint("package_id", "name"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + package_id: Mapped[int] = mapped_column(ForeignKey("software_packages.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(80)) + label: Mapped[str] = mapped_column(String(120)) + data_type: Mapped[str] = mapped_column(String(30), default="text") + required: Mapped[bool] = mapped_column(Boolean, default=False) + secret: Mapped[bool] = mapped_column(Boolean, default=False) + default_value: Mapped[str | None] = mapped_column(Text, nullable=True) + validation_regex: Mapped[str | None] = mapped_column(String(255), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + package: Mapped[SoftwarePackage] = relationship(back_populates="parameters") + + +class SoftwareJob(Base): + __tablename__ = "software_jobs" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + package_id: Mapped[int] = mapped_column(ForeignKey("software_packages.id"), index=True) + status: Mapped[str] = mapped_column(String(30), default="created", index=True) + job_type: Mapped[str] = mapped_column(String(50), default="software_inventory", index=True) + action: Mapped[str] = mapped_column(String(30), default="run", index=True) + priority: Mapped[int] = mapped_column(Integer, default=100, index=True) + platform: Mapped[str] = mapped_column(String(30), default="unknown") + parameters: Mapped[dict] = mapped_column(JSON, default=dict) + result_data: Mapped[dict] = mapped_column(JSON, default=dict) + attempt_count: Mapped[int] = mapped_column(Integer, default=0) + max_attempts: Mapped[int] = mapped_column(Integer, default=1) + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) + callback_token_hash: Mapped[str] = mapped_column(String(64), index=True) + callback_expires_at: Mapped[datetime] = mapped_column(DateTime) + callback_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + command_preview: Mapped[str | None] = mapped_column(Text, nullable=True) + resolved_script: Mapped[str | None] = mapped_column(Text, nullable=True) + meshctrl_stdout: Mapped[str | None] = mapped_column(Text, nullable=True) + meshctrl_stderr: Mapped[str | None] = mapped_column(Text, nullable=True) + exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + log_text: Mapped[str] = mapped_column(Text, default="") + created_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + asset: Mapped[Asset] = relationship() + package: Mapped[SoftwarePackage] = relationship(back_populates="jobs") + events: Mapped[list["JobEvent"]] = relationship(back_populates="job", cascade="all, delete-orphan", order_by="JobEvent.created_at") + + +class AssetJobState(Base): + __tablename__ = "asset_job_states" + __table_args__ = ( + UniqueConstraint("asset_id", "state_key", name="uq_asset_job_state_asset_key"), + Index("ix_asset_job_states_key_status", "state_key", "last_status"), + Index("ix_asset_job_states_key_success", "state_key", "last_success_at"), + Index("ix_asset_job_states_asset_key", "asset_id", "state_key"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + state_key: Mapped[str] = mapped_column(String(120), index=True) + job_type: Mapped[str] = mapped_column(String(50), index=True) + job_definition_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + definition_revision: Mapped[int | None] = mapped_column(Integer, nullable=True) + last_success_revision: Mapped[int | None] = mapped_column(Integer, nullable=True) + last_job_id: Mapped[int | None] = mapped_column(ForeignKey("software_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + last_status: Mapped[str] = mapped_column(String(30), default="created", index=True) + last_status_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + last_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_success_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) + execution_count: Mapped[int] = mapped_column(Integer, default=0) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + asset: Mapped[Asset] = relationship() + last_job: Mapped[SoftwareJob | None] = relationship() + + +class JobEvent(Base): + __tablename__ = "job_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + job_id: Mapped[int] = mapped_column(ForeignKey("software_jobs.id", ondelete="CASCADE"), index=True) + attempt: Mapped[int] = mapped_column(Integer, default=0) + event_type: Mapped[str] = mapped_column(String(40), default="status", index=True) + status: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(150), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + job: Mapped["SoftwareJob"] = relationship(back_populates="events") + + +class InstalledSoftware(Base): + __tablename__ = "installed_software" + __table_args__ = (UniqueConstraint("asset_id", "name", "version", "publisher", "architecture", name="uq_installed_software_identity"),) + id: Mapped[int] = mapped_column(Integer, primary_key=True) + asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(300), index=True) + version: Mapped[str] = mapped_column(String(160), default="") + publisher: Mapped[str] = mapped_column(String(220), default="") + architecture: Mapped[str] = mapped_column(String(40), default="") + platform: Mapped[str] = mapped_column(String(40), default="unknown", index=True) + install_date: Mapped[str | None] = mapped_column(String(40), nullable=True) + source: Mapped[str] = mapped_column(String(50), default="meshcentral-inventory") + first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + asset: Mapped[Asset] = relationship() diff --git a/app/presence.py b/app/presence.py new file mode 100644 index 0000000..bda3ebc --- /dev/null +++ b/app/presence.py @@ -0,0 +1,162 @@ +import logging +import os +import threading +from pathlib import Path +from datetime import datetime +from typing import Any + +from .config import load_config +from .database import SessionLocal +from .meshcentral import _extract_devices, _parse_json_output, _run_meshctrl +from .models import Asset +from .presence_state import derive_presence_state, extract_last_address, extract_last_seen + +logger = logging.getLogger("assetmanager.presence") + + +def _normalized_node_id(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.startswith("node//"): + return text[6:] + if text.startswith("node/"): + return text.split("/")[-1] + return text + + +def _device_node_id(device: dict[str, Any]) -> str: + node = device.get("node") if isinstance(device.get("node"), dict) else {} + return str( + device.get("_id") + or device.get("nodeid") + or device.get("nodeId") + or device.get("id") + or node.get("_id") + or node.get("nodeid") + or "" + ).strip() + + +def _device_online(device: dict[str, Any]) -> bool | None: + for key in ("online", "connected", "isOnline"): + if key not in device: + continue + value = device.get(key) + if isinstance(value, bool): + return value + text = str(value or "").strip().casefold() + if text in {"1", "true", "yes", "online", "connected"}: + return True + if text in {"0", "false", "no", "offline", "disconnected"}: + return False + + # MeshCentral exposes a connection bit mask as `conn`. + if "conn" in device: + try: + return int(device.get("conn") or 0) > 0 + except (TypeError, ValueError): + pass + return None + + +def _presence_config() -> dict[str, Any] | None: + config = load_config().get("meshcentral", {}) + if not config.get("enabled") or not config.get("presence_enabled", True): + return None + if not config.get("url") or not config.get("username") or not config.get("password"): + return None + return config + + +def refresh_mesh_presence() -> dict[str, int]: + config = _presence_config() + if not config: + return {"checked": 0, "online": 0, "offline": 0, "unknown": 0} + + # Die Präsenzprüfung läuft standardmäßig alle 30 Sekunden. Ihre erfolgreiche + # ListDevices-Ausgabe wird nach dem Einlesen nicht als Zeitstempeldatei behalten. + # Bei einem Parsefehler speichern wir nur eine einzige überschreibbare Fehlerdatei. + result = _run_meshctrl( + config, + "ListDevices", + include_details=False, + retain_stdout=False, + ) + if result.returncode != 0: + raise RuntimeError((result.stderr or result.stdout or "MeshCentral presence query failed").strip()) + + diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics")) + presence_error_path = diagnostic_dir / "meshctrl-presence-last-error.stdout.json" + try: + devices = _extract_devices(_parse_json_output(result.stdout)) + except Exception: + diagnostic_dir.mkdir(parents=True, exist_ok=True) + presence_error_path.write_text(result.stdout or "", encoding="utf-8", errors="replace") + raise + else: + presence_error_path.unlink(missing_ok=True) + by_exact: dict[str, dict[str, Any]] = {} + by_normalized: dict[str, dict[str, Any]] = {} + for device in devices: + node_id = _device_node_id(device) + if not node_id: + continue + by_exact[node_id] = device + by_normalized[_normalized_node_id(node_id)] = device + + now = datetime.utcnow() + counters = {"checked": 0, "online": 0, "offline": 0, "unknown": 0} + db = SessionLocal() + try: + assets = db.query(Asset).filter(Asset.mesh_node_id.isnot(None), Asset.mesh_node_id != "").all() + for asset in assets: + counters["checked"] += 1 + node_id = str(asset.mesh_node_id or "").strip() + device = by_exact.get(node_id) or by_normalized.get(_normalized_node_id(node_id)) + state_value = _device_online(device) if device else None + reported_last_seen = extract_last_seen(device) + if reported_last_seen and (asset.mesh_last_seen is None or reported_last_seen > asset.mesh_last_seen): + asset.mesh_last_seen = reported_last_seen + + # The live query is authoritative for "online". An offline label is + # only meaningful once MeshCentral has supplied a historical + # connection time. Devices without such history remain "unknown". + effective_last_seen = asset.mesh_last_seen + derived_state = derive_presence_state(state_value, effective_last_seen) + asset.mesh_online_state = derived_state + asset.mesh_online = True if derived_state == "online" else (False if derived_state == "offline" else None) + if derived_state == "online": + asset.mesh_last_online_at = now + counters[derived_state] += 1 + + asset.mesh_presence_updated_at = now + asset.mesh_presence_source = "meshcentral" + logger.debug( + "MeshCentral presence asset=%s node_id=%s live=%s last_seen=%s last_address=%s derived_state=%s", + asset.name, node_id, state_value, effective_last_seen, extract_last_address(device), derived_state, + ) + db.commit() + finally: + db.close() + + logger.info( + "MeshCentral presence refreshed: checked=%s online=%s offline=%s unknown=%s", + counters["checked"], + counters["online"], + counters["offline"], + counters["unknown"], + ) + return counters + + +def mesh_presence_loop(stop_event: threading.Event) -> None: + while not stop_event.is_set(): + config = load_config().get("meshcentral", {}) + interval = max(10, min(int(config.get("presence_interval_seconds", 30) or 30), 600)) + try: + refresh_mesh_presence() + except Exception: + logger.exception("MeshCentral presence refresh failed") + if stop_event.wait(interval): + return diff --git a/app/presence_state.py b/app/presence_state.py new file mode 100644 index 0000000..277938e --- /dev/null +++ b/app/presence_state.py @@ -0,0 +1,114 @@ +"""Shared MeshCentral presence and last-seen helpers. + +The MeshCentral CLI uses slightly different key names and nesting depending on +whether ListDevices is called with --details and on the MeshCentral version. +This module keeps timestamp parsing and state derivation in one place. +""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + + +def _nested(data: Any, *path: str) -> Any: + current = data + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +def parse_mesh_datetime(value: Any) -> datetime | None: + """Parse MeshCentral Unix timestamps (seconds/ms) and ISO strings as UTC-naive.""" + if value is None or isinstance(value, bool): + return None + if isinstance(value, datetime): + if value.tzinfo: + return value.astimezone(timezone.utc).replace(tzinfo=None) + return value + if isinstance(value, dict): + for key in ("time", "timestamp", "value"): + parsed = parse_mesh_datetime(value.get(key)) + if parsed: + return parsed + return None + if isinstance(value, (int, float)): + numeric = float(value) + if numeric <= 0: + return None + # MeshCentral commonly emits JavaScript timestamps in milliseconds. + if numeric > 100_000_000_000: + numeric /= 1000.0 + try: + return datetime.fromtimestamp(numeric, tz=timezone.utc).replace(tzinfo=None) + except (OverflowError, OSError, ValueError): + return None + + text = str(value).strip() + if not text or text in {"0", "-"}: + return None + try: + return parse_mesh_datetime(float(text)) + except ValueError: + pass + try: + normalized = text[:-1] + "+00:00" if text.endswith("Z") else text + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo: + parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) + return parsed + except ValueError: + return None + + +def extract_last_seen(device: dict[str, Any] | None) -> datetime | None: + """Return the best historical connection timestamp exposed by MeshCentral.""" + if not isinstance(device, dict): + return None + candidates = ( + device.get("lastConnect"), + device.get("lastconnect"), + device.get("lastSeen"), + device.get("lastseen"), + device.get("lastOnline"), + device.get("lastonline"), + _nested(device, "node", "lastConnect"), + _nested(device, "node", "lastconnect"), + _nested(device, "node", "lastSeen"), + _nested(device, "node", "lastseen"), + ) + for candidate in candidates: + parsed = parse_mesh_datetime(candidate) + if parsed: + return parsed + return None + + +def extract_last_address(device: dict[str, Any] | None) -> str | None: + if not isinstance(device, dict): + return None + candidates = ( + _nested(device, "lastConnect", "addr"), + _nested(device, "lastconnect", "addr"), + device.get("lastaddr"), + device.get("lastAddress"), + _nested(device, "node", "lastaddr"), + _nested(device, "node", "lastAddress"), + _nested(device, "node", "ip"), + _nested(device, "node", "host"), + ) + for value in candidates: + text = str(value or "").strip() + if text: + return text + return None + + +def derive_presence_state(live_online: bool | None, last_seen: datetime | None) -> str: + """Derive UI state: live true=online; historical connection=offline; else unknown.""" + if live_online is True: + return "online" + if last_seen is not None: + return "offline" + return "unknown" diff --git a/app/software_control.py b/app/software_control.py new file mode 100644 index 0000000..552612e --- /dev/null +++ b/app/software_control.py @@ -0,0 +1,676 @@ +from pathlib import Path + +import base64 +import hashlib +import os +import subprocess +import tempfile +import threading +import time +from datetime import datetime +from urllib.parse import quote + +from fastapi import HTTPException + +from .config import load_config +from .database import SessionLocal +from .models import Asset, SoftwareJob, JobEvent, JobDefinition +from .job_state import sync_asset_job_state + + +_DISPATCH_SLOT_LOCK = threading.Lock() +_NEXT_DISPATCH_MONOTONIC = 0.0 + + +def _wait_for_dispatch_slot() -> tuple[float, float]: + """Reserve the next global MeshCtrl dispatch slot. + + Bulk operations currently create one lightweight worker thread per job. + Reserving slots here prevents all workers from opening database and + MeshCtrl connections at the same time while still allowing already + dispatched client scripts to run in parallel. + """ + global _NEXT_DISPATCH_MONOTONIC + settings = load_config().get("software", {}) + try: + delay_seconds = float(settings.get("dispatch_delay_seconds", 2) or 0) + except (TypeError, ValueError): + delay_seconds = 2.0 + delay_seconds = max(0.0, min(delay_seconds, 60.0)) + if delay_seconds <= 0: + return 0.0, 0.0 + + with _DISPATCH_SLOT_LOCK: + now = time.monotonic() + reserved_at = max(now, _NEXT_DISPATCH_MONOTONIC) + wait_seconds = max(0.0, reserved_at - now) + _NEXT_DISPATCH_MONOTONIC = reserved_at + delay_seconds + + if wait_seconds > 0: + time.sleep(wait_seconds) + return delay_seconds, wait_seconds + + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def detect_platform(asset: Asset) -> str: + text = f"{asset.operating_system or ''} {asset.mesh_source_data or ''}".casefold() + if "windows" in text: + return "windows" + if "macos" in text or "mac os" in text or "darwin" in text: + return "macos" + if "android" in text: + return "android" + if any(x in text for x in ("linux", "ubuntu", "debian", "fedora", "centos", "red hat", "suse", "arch")): + return "linux" + return "unknown" + + +def powershell_payload(script: str) -> str: + """Compatibility helper retained for older callers. + + Since 0.5.5.0 scripts are uploaded as files and are no longer encoded into + the command line. + """ + return str(script or "") + + +def powershell_encoded(script: str) -> str: + return powershell_payload(script) + + +def windows_inventory(callback_url: str, job_id: int) -> str: + script = """ +$ErrorActionPreference='Stop' +$log=Join-Path $env:ProgramData 'AssetManager\\commands.log' +New-Item -ItemType Directory -Force -Path (Split-Path $log)|Out-Null +$lines=New-Object System.Collections.Generic.List[string] +function L([string]$m){$x="$(Get-Date -Format o) job=JOBID $m";Add-Content -Path $log -Value $x -Encoding UTF8;$lines.Add($x)} +function Send-Callback([byte[]]$Payload){ + $lastError=$null + for($attempt=1;$attempt -le 3;$attempt++){ + try{ + Invoke-RestMethod -Method Post -Uri 'CALLBACK' -ContentType 'application/json; charset=utf-8' -Body $Payload -TimeoutSec 60|Out-Null + if($attempt -gt 1){L ("callback accepted on attempt $attempt of 3")} + return + }catch{ + $lastError=$_.Exception + L ("callback attempt $attempt of 3 failed: "+$_.Exception.Message) + if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)} + } + } + throw ("callback failed after 3 attempts: "+$lastError.Message) +} +L 'software inventory started' +try { + $paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*') + $software=Get-ItemProperty $paths -ErrorAction SilentlyContinue|Where-Object {$_.DisplayName}|ForEach-Object { + [PSCustomObject]@{name=[string]$_.DisplayName;version=[string]$_.DisplayVersion;publisher=[string]$_.Publisher;install_date=[string]$_.InstallDate;architecture=$(if($_.PSPath -like '*WOW6432Node*'){'x86'}else{'x64'})} + }|Sort-Object name,version -Unique + L ("software inventory completed; entries="+$software.Count) + $body=@{status='success';exit_code=0;completed=$true;software=$software;log=($lines -join "`n")}|ConvertTo-Json -Depth 6 -Compress + $bodyBytes=[System.Text.Encoding]::UTF8.GetBytes($body) + L 'sending callback' + Send-Callback $bodyBytes + L 'callback accepted' +}catch{ + L ("software inventory failed: "+$_.Exception.Message) + $body=@{status='failed';exit_code=1;completed=$true;message=$_.Exception.Message;log=($lines -join "`n")}|ConvertTo-Json -Depth 4 -Compress + $bodyBytes=[System.Text.Encoding]::UTF8.GetBytes($body) + try{ + Send-Callback $bodyBytes + }catch{ + L ("callback failed: "+$_.Exception.Message) + } + exit 1 +} +""".replace("JOBID", str(job_id)).replace("CALLBACK", callback_url) + return script + + +def unix_python_payload(platform: str, callback_url: str, job_id: int) -> str: + if platform == "macos": + collection = """ +raw=subprocess.check_output(["system_profiler","SPApplicationsDataType","-json"],text=True,errors="replace") +data=json.loads(raw) +for x in data.get("SPApplicationsDataType",[]): + items.append({"name":x.get("_name",""),"version":x.get("version",""),"publisher":str(x.get("signed_by","")),"architecture":x.get("arch_kind",""),"install_date":None}) +""" + log_path = "/Library/Logs/AssetManager-commands.log" + else: + collection = """ +if os.path.exists("/usr/bin/dpkg-query"): + out=subprocess.check_output(["dpkg-query","-W","-f=${binary:Package}\\t${Version}\\t${Maintainer}\\n"],text=True,errors="replace") + for line in out.splitlines(): + p=line.split("\\t");items.append({"name":p[0],"version":p[1] if len(p)>1 else "","publisher":p[2] if len(p)>2 else "","architecture":"","install_date":None}) +elif os.path.exists("/usr/bin/rpm"): + out=subprocess.check_output(["rpm","-qa","--qf","%{NAME}\\t%{VERSION}-%{RELEASE}\\t%{VENDOR}\\t%{ARCH}\\n"],text=True,errors="replace") + for line in out.splitlines(): + p=line.split("\\t");items.append({"name":p[0],"version":p[1] if len(p)>1 else "","publisher":p[2] if len(p)>2 else "","architecture":p[3] if len(p)>3 else "","install_date":None}) +elif os.path.exists("/sbin/apk") or os.path.exists("/usr/sbin/apk"): + out=subprocess.check_output(["apk","info","-v"],text=True,errors="replace") + for line in out.splitlines():items.append({"name":line,"version":"","publisher":"","architecture":"","install_date":None}) +""" + log_path = "/var/log/assetmanager-commands.log" + + py = f"""import json,subprocess,urllib.request,datetime,os,time +log={log_path!r} +lines=[] +def add(m): + x=datetime.datetime.now(datetime.timezone.utc).isoformat()+" job={job_id} "+m + lines.append(x) + try: + with open(log,"a",encoding="utf-8") as f:f.write(x+"\\n") + except Exception:pass +add("software inventory started") +items=[] +try: +{textwrap_indent(collection, 4)} + add("software inventory completed; entries="+str(len(items))) + payload={{"status":"success","exit_code":0,"completed":True,"software":items,"log":"\\n".join(lines)}} +except Exception as e: + add("software inventory failed: "+str(e)) + payload={{"status":"failed","exit_code":1,"completed":True,"message":str(e),"log":"\\n".join(lines)}} +callback_data=json.dumps(payload).encode() +last_error=None +for attempt in range(1,4): + req=urllib.request.Request({callback_url!r},data=callback_data,headers={{"Content-Type":"application/json"}},method="POST") + try: + urllib.request.urlopen(req,timeout=60).read() + break + except Exception as exc: + last_error=exc + add("callback attempt "+str(attempt)+" of 3 failed: "+str(exc)) + if attempt < 3: time.sleep(2*attempt) +else: + raise RuntimeError("callback failed after 3 attempts: "+str(last_error)) +""" + return py + + +def textwrap_indent(value: str, spaces: int) -> str: + prefix = " " * spaces + return "\n".join(prefix + line if line.strip() else line for line in value.strip().splitlines()) + + + +def _replace_job_placeholders(script: str, asset: Asset, callback_url: str, job_id: int, parameters: dict | None = None) -> str: + values={ + 'AssetName':asset.name or '', 'Hostname':asset.hostname or '', 'IPAddress':asset.ip_address or '', + 'SerialNumber':asset.serial_number or '', 'MeshNodeId':asset.mesh_node_id or '', + 'Manufacturer':asset.manufacturer or '', 'Model':asset.model or '', + 'Department':asset.department or '', 'Location':asset.location or '', + 'CurrentUser':asset.assigned_to or '', + 'CallbackUrl':callback_url, 'JobId':str(job_id), + } + for key,value in values.items(): script=script.replace('{{'+key+'}}',str(value)) + for key,value in (parameters or {}).items(): + script=script.replace('{{Param.'+str(key)+'}}',str(value)).replace('{{'+str(key)+'}}',str(value)) + return script + +def build_registry_user_script(configuration: dict | None) -> str: + """Build PowerShell user code for a declarative Windows Registry job.""" + config=configuration or {} + entries=config.get("registry_entries") if isinstance(config,dict) else [] + backup=bool(config.get("registry_backup")) if isinstance(config,dict) else False + hive_map={ + "HKLM":"Registry::HKEY_LOCAL_MACHINE", + "HKCU":"Registry::HKEY_CURRENT_USER", + "HKCR":"Registry::HKEY_CLASSES_ROOT", + "HKU":"Registry::HKEY_USERS", + "HKCC":"Registry::HKEY_CURRENT_CONFIG", + } + type_map={"REG_SZ":"String","REG_EXPAND_SZ":"ExpandString","REG_MULTI_SZ":"MultiString","REG_DWORD":"DWord","REG_QWORD":"QWord","REG_BINARY":"Binary"} + lines=["Write-JobLog 'Registry job started'", "$registryResults = @()"] + if backup: + lines += [ + "$backupRoot = Join-Path $env:ProgramData ('AssetManager\\RegistryBackups\\Job-' + '{{JobId}}')", + "New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null", + "Write-JobLog (\"Registry backup directory: $backupRoot\")", + ] + for index,item in enumerate(entries if isinstance(entries,list) else [],1): + if not isinstance(item,dict): continue + action=str(item.get('action') or 'set') + hive=str(item.get('hive') or 'HKLM').upper() + path=str(item.get('path') or '').strip().strip('\\') + name=str(item.get('name') or '') + value_type=str(item.get('value_type') or 'REG_SZ').upper() + value=str(item.get('value') or '') + set_value=bool(item.get('set_value', True)) + only_if_missing=bool(item.get('only_if_missing')) + permissions_enabled=bool(item.get('permissions_enabled')) + if hive not in hive_map or not path: continue + ps_path=hive_map[hive]+'\\'+path + reg_path=hive+'\\'+path + q=lambda x: "'"+x.replace("'","''")+"'" + lines.append(f"# Registry entry {index}") + lines.append(f"$registryPath = {q(ps_path)}") + lines.append(f"$registryValueName = {q(name)}") + if backup: + safe_name=str(index).zfill(3)+'-'+hive+'-'+path.replace('\\','_').replace(':','_')+'.reg' + lines += [ + f"$backupFile = Join-Path $backupRoot {q(safe_name)}", + f"& reg.exe export {q(reg_path)} $backupFile /y 2>$null | Out-Null", + ] + if action=='delete_key': + lines.append("if (Test-Path -LiteralPath $registryPath) { Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction Stop; Write-JobLog (\"Registry key deleted: $registryPath\"); $registryResults += @{path=$registryPath; action='delete_key'; status='changed'} } else { Write-JobLog (\"Registry key already absent: $registryPath\"); $registryResults += @{path=$registryPath; action='delete_key'; status='unchanged'} }") + continue + if action=='delete_value': + lines += [ + "$existing = Get-ItemProperty -LiteralPath $registryPath -Name $registryValueName -ErrorAction SilentlyContinue", + "if ($null -ne $existing) { Remove-ItemProperty -LiteralPath $registryPath -Name $registryValueName -Force -ErrorAction Stop; Write-JobLog (\"Registry value deleted: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='delete_value'; status='changed'} } else { Write-JobLog (\"Registry value already absent: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='delete_value'; status='unchanged'} }" + ] + elif action=='set': + lines.append("if (-not (Test-Path -LiteralPath $registryPath)) { New-Item -Path $registryPath -Force | Out-Null; Write-JobLog (\"Registry key created: $registryPath\") }") + if set_value: + property_type=type_map.get(value_type,'String') + lines.append(f"$registryRawValue = {q(value)}") + if value_type=='REG_MULTI_SZ': + lines.append("$registryTypedValue = @($registryRawValue -split \"`r?`n\")") + elif value_type=='REG_BINARY': + lines.append("$registryTypedValue = [byte[]](($registryRawValue -replace '[^0-9A-Fa-f]','' -split '(?<=\\G..)(?=.)') | Where-Object { $_ } | ForEach-Object { [Convert]::ToByte($_,16) })") + elif value_type=='REG_DWORD': + lines.append("$registryTypedValue = if ($registryRawValue -match '^0x') {[Convert]::ToUInt32($registryRawValue.Substring(2),16)} else {[Convert]::ToUInt32($registryRawValue)}") + elif value_type=='REG_QWORD': + lines.append("$registryTypedValue = if ($registryRawValue -match '^0x') {[Convert]::ToUInt64($registryRawValue.Substring(2),16)} else {[Convert]::ToUInt64($registryRawValue)}") + else: + lines.append("$registryTypedValue = $registryRawValue") + lines.append("$existingProperty = Get-ItemProperty -LiteralPath $registryPath -Name $registryValueName -ErrorAction SilentlyContinue") + if only_if_missing: + lines.append("if ($null -ne $existingProperty) { Write-JobLog (\"Registry value exists; skipped: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='set'; status='skipped'} } else {") + indent=' ' + else: + indent='' + lines += [ + indent+f"New-ItemProperty -LiteralPath $registryPath -Name $registryValueName -PropertyType {property_type} -Value $registryTypedValue -Force -ErrorAction Stop | Out-Null", + indent+"Write-JobLog (\"Registry value set: $registryPath\\$registryValueName\")", + indent+"$registryResults += @{path=$registryPath; name=$registryValueName; action='set'; status='changed'; type='"+value_type+"'}", + ] + if only_if_missing: lines.append("}") + else: + lines.append("Write-JobLog (\"Registry key ensured without changing a value: $registryPath\")") + lines.append("$registryResults += @{path=$registryPath; action='ensure_key'; status='ready'}") + if permissions_enabled: + principal=str(item.get('principal') or 'S-1-5-32-545').strip() + rights=str(item.get('rights') or 'FullControl').strip() + access_type='Deny' if str(item.get('access_type') or 'Allow').lower()=='deny' else 'Allow' + inherit_subkeys=bool(item.get('inherit_subkeys', True)) + replace_existing=bool(item.get('replace_existing', True)) + lines += [ + "if (-not (Test-Path -LiteralPath $registryPath)) { New-Item -Path $registryPath -Force | Out-Null; Write-JobLog (\"Registry key created for permissions: $registryPath\") }", + f"$registryPrincipal = {q(principal)}", + f"$registryRightsName = {q(rights)}", + f"$registryAccessTypeName = {q(access_type)}", + "try { $registryIdentity = New-Object System.Security.Principal.SecurityIdentifier($registryPrincipal) } catch { $registryIdentity = $registryPrincipal }", + "try { $registryRights = [System.Enum]::Parse([System.Security.AccessControl.RegistryRights], $registryRightsName, $true) } catch { throw \"Invalid RegistryRights '$registryRightsName' for $registryPath\" }", + "$registryAccessType = [System.Enum]::Parse([System.Security.AccessControl.AccessControlType], $registryAccessTypeName, $true)", + "$registryInheritance = " + ("[System.Security.AccessControl.InheritanceFlags]::ContainerInherit" if inherit_subkeys else "[System.Security.AccessControl.InheritanceFlags]::None"), + "$registryPropagation = [System.Security.AccessControl.PropagationFlags]::None", + "Write-JobLog (\"Registry ACL target: $registryPath; exists=$([bool](Test-Path -Path $registryPath))\")", + "$registryAcl = Get-Acl -Path $registryPath -ErrorAction Stop", + "$registryRule = New-Object System.Security.AccessControl.RegistryAccessRule($registryIdentity, $registryRights, $registryInheritance, $registryPropagation, $registryAccessType)", + ("$registryAcl.SetAccessRule($registryRule)" if replace_existing else "$registryAcl.AddAccessRule($registryRule) | Out-Null"), + "Set-Acl -Path $registryPath -AclObject $registryAcl -ErrorAction Stop", + "$registryAclAfter = Get-Acl -Path $registryPath -ErrorAction Stop", + "$registryMatchedRules = @($registryAclAfter.Access | Where-Object { try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $registryIdentity.Value } catch { $_.IdentityReference.Value -eq $registryPrincipal -or $_.IdentityReference.Value -like ('*\\' + $registryPrincipal) } })", + "Write-JobLog (\"Registry permission set: $registryPath; identity=$registryPrincipal; rights=$registryRightsName; type=$registryAccessTypeName; matching_rules=$($registryMatchedRules.Count)\")", + "$registryResults += @{path=$registryPath; action='set_permissions'; status='changed'; principal=$registryPrincipal; rights=$registryRightsName; access_type=$registryAccessTypeName; matching_rules=$registryMatchedRules.Count}", + ] + lines += ["$JobResult['registry_entries'] = $registryResults", "Write-JobLog ('Registry job completed; entries=' + $registryResults.Count)"] + return "\n".join(lines) + + +def _runtime_wrap_script(interpreter: str, user_script: str) -> str: + # Job definitions contain user code only. The technical execution wrapper + # is always generated from the current interpreter template at runtime. + source=str(user_script or "") + root=Path(__file__).resolve().parent / "templates" / "jobs" + files={"powershell":"powershell_wrapper.ps1","python":"python_wrapper.py.txt","bash":"bash_wrapper.sh","cmd":"cmd_wrapper.cmd"} + markers={"powershell":(" # --- Eigene Befehle hier einfügen ---"," # --- Ende eigene Befehle ---"),"python":(" # --- Eigene Befehle hier einfügen ---"," # --- Ende eigene Befehle ---"),"bash":("# --- Eigene Befehle hier einfügen ---","# --- Ende eigene Befehle ---"),"cmd":("rem --- Eigene Befehle hier einfügen ---","rem --- Ende eigene Befehle ---")} + filename=files.get(interpreter) + if not filename: raise RuntimeError(f"Unbekannter Interpreter: {interpreter}") + wrapper=(root/filename).read_text(encoding="utf-8").replace("\r\n","\n") + start,end=markers[interpreter]; block=start+"\n\n"+end + indent={"powershell":" ","python":" ","bash":"","cmd":""}[interpreter] + commands="\n".join(indent+line if line.strip() else line for line in str(user_script or "").splitlines()) + return wrapper.replace(block,start+"\n"+commands+"\n"+end,1) + +def _definition_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]: + """Return (resolved script/command, interpreter, upload_required).""" + if definition_id is not None: + definition=db.query(JobDefinition).filter(JobDefinition.id==definition_id,JobDefinition.enabled.is_(True)).first() + else: + definition=db.query(JobDefinition).filter(JobDefinition.system_key==f'software_inventory_{platform}',JobDefinition.enabled.is_(True)).first() + if not definition: + if definition_id is not None: + raise RuntimeError('Jobdefinition wurde nicht gefunden oder ist deaktiviert.') + return '', '', False + if definition.platform not in {platform, 'all'}: + raise RuntimeError(f'Jobdefinition passt nicht zur Plattform {platform}.') + effective_parameters=((db.get(SoftwareJob,job_id).parameters or {}).get('effective_parameters') or {}) if db.get(SoftwareJob,job_id) else {} + if definition.job_kind=='registry': + script=_runtime_wrap_script('powershell', build_registry_user_script(definition.configuration)) + interpreter='powershell' + elif definition.source_type=='inline': + interpreter=definition.interpreter + script=_runtime_wrap_script(interpreter, definition.inline_script or '') + elif definition.source_type=='mounted': + interpreter=definition.interpreter + path=Path(definition.source_path or '') + if not path.is_file(): raise RuntimeError(f'Skriptdatei nicht gefunden: {path}') + script=_runtime_wrap_script(interpreter, path.read_text(encoding='utf-8',errors='replace')) + elif definition.source_type=='unc': + path=_replace_job_placeholders(definition.source_path or '',asset,callback_url,job_id,effective_parameters) + arguments=_replace_job_placeholders(definition.arguments or '',asset,callback_url,job_id,effective_parameters) + if definition.interpreter=='powershell': command=f'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{path}" {arguments}'.strip() + elif definition.interpreter=='cmd': command=f'cmd.exe /d /s /c ""{path}" {arguments}"'.strip() + elif definition.interpreter=='python': command=f'python3 "{path}" {arguments}'.strip() + else: command=f'bash "{path}" {arguments}'.strip() + return command, definition.interpreter, False + else: + raise RuntimeError(f'Unbekannte Skriptquelle: {definition.source_type}') + script=_replace_job_placeholders(script,asset,callback_url,job_id,effective_parameters) + return script, interpreter, True + + +def _job_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]: + configured=_definition_execution_artifacts(db,platform,callback_url,job_id,asset,definition_id) + if configured[0]: + return configured + if platform == 'windows': + return windows_inventory(callback_url, job_id), 'powershell', True + if platform in {'linux','macos'}: + return unix_python_payload(platform, callback_url, job_id), 'python', True + raise HTTPException(400, 'Für dieses Betriebssystem ist kein sicherer MeshCtrl-Handler verfügbar.') + + +def _script_extension(interpreter: str) -> str: + return {'powershell':'.ps1','cmd':'.cmd','python':'.py','bash':'.sh'}.get(interpreter,'.txt') + + +def _remote_job_paths(platform: str, job_id: int, attempt: int, interpreter: str) -> tuple[str, str]: + suffix=f'{job_id}-{attempt}' + filename='run'+_script_extension(interpreter) + if platform == 'windows': + directory=f'C:\\ProgramData\\AssetManager\\Jobs\\{suffix}' + return directory, directory+'\\'+filename + directory=f'/var/lib/assetmanager/jobs/{suffix}' + return directory, directory+'/'+filename + + +def _meshctrl_base_args(cfg: dict, asset: Asset, password: str) -> list[str]: + args=['node',str(cfg.get('meshctrl_path') or '/opt/meshcentral/node_modules/meshcentral/meshctrl.js')] + args += ['--url',str(cfg.get('url') or ''),'--loginuser',str(cfg.get('username') or ''),'--loginpass',password] + tenant=str(cfg.get('tenant') or '').strip() + if tenant: + args += ['--domain',tenant] + return args + + +def _run_meshctrl(cfg: dict, asset: Asset, password: str, action_args: list[str], timeout: int) -> subprocess.CompletedProcess: + base=_meshctrl_base_args(cfg,asset,password) + args=base[:2]+action_args+base[2:] + return subprocess.run(args,capture_output=True,text=True,timeout=timeout,check=False) + + +def _prepare_remote_directory( + cfg: dict, + asset: Asset, + password: str, + platform: str, + remote_dir: str, + timeout: int, + remote_file: str | None = None, +) -> subprocess.CompletedProcess: + """Create the job directory and remove a stale target file. + + MeshCtrl's Upload action does not overwrite an existing file reliably on + all Windows agents. Failed jobs intentionally retain their directories, so + a repeated dispatch must explicitly remove a previous run.ps1 before the + upload starts. + """ + if platform == 'windows': + command=( + "$p='"+remote_dir.replace("'","''")+"';" + "New-Item -ItemType Directory -Force -Path $p|Out-Null;" + ) + if remote_file: + command += "Remove-Item -LiteralPath '"+remote_file.replace("'","''")+"' -Force -ErrorAction SilentlyContinue;" + command += "& icacls.exe $p /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C|Out-Null" + return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout) + command=f"mkdir -p '{remote_dir}'" + if remote_file: + command += f" && rm -f -- '{remote_file}'" + command += f" && chmod 700 '{remote_dir}'" + return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout) + + +def _upload_script(cfg: dict, asset: Asset, password: str, local_file: str, remote_dir: str, timeout: int) -> subprocess.CompletedProcess: + return _run_meshctrl(cfg,asset,password,['Upload','--id',asset.mesh_node_id,'--file',local_file,'--target',remote_dir],timeout) + + +def _uploaded_script_launch_spec(platform: str, interpreter: str, remote_file: str) -> tuple[str, str, list[str]]: + """Return the exact remote command, shell label and MeshCtrl shell flags. + + Windows PowerShell files are deliberately launched through MeshCtrl's + Windows Command Prompt mode (type 0). The command then starts powershell + itself with ExecutionPolicy Bypass. Adding MeshCtrl's --powershell flag + would switch to type 2 and cause the agent to execute the .ps1 inside the + PowerShell host, which is precisely what must be avoided here. + """ + if platform == 'windows' and interpreter == 'powershell': + # Important: do not add MeshCtrl's --powershell option here. Without + # that option MeshCtrl uses the normal Windows command prompt, which + # then starts PowerShell with the same command that was tested manually. + escaped_file = remote_file.replace('"', '""') + return ( + f'powershell -ExecutionPolicy Bypass -File "{escaped_file}"', + 'Windows Command Prompt', + [], + ) + if platform == 'windows' and interpreter == 'cmd': + return f'cmd.exe /d /s /c ""{remote_file}""', 'Windows Command Prompt', [] + if interpreter == 'python': + return f"python3 '{remote_file}'", 'System shell', [] + return f"chmod 700 '{remote_file}' && bash '{remote_file}'", 'System shell', [] + + +def _launch_uploaded_script(cfg: dict, asset: Asset, password: str, platform: str, interpreter: str, remote_file: str, timeout: int) -> subprocess.CompletedProcess: + command,_shell_label,meshctrl_shell_flags=_uploaded_script_launch_spec(platform,interpreter,remote_file) + action=['RunCommand','--id',asset.mesh_node_id,'--run',command]+meshctrl_shell_flags+['--reply'] + if platform == 'windows' and interpreter == 'powershell' and '--powershell' in action: + raise RuntimeError('Interner Dispatcherfehler: Dateibasierte PowerShell-Jobs dürfen MeshCtrl nicht im PowerShell-Modus starten.') + return _run_meshctrl(cfg,asset,password,action,timeout) + + +def _cleanup_remote_directory(cfg: dict, asset: Asset, password: str, platform: str, remote_dir: str, timeout: int) -> subprocess.CompletedProcess: + if platform == 'windows': + command="Remove-Item -LiteralPath '"+remote_dir.replace("'","''")+"' -Recurse -Force -ErrorAction SilentlyContinue" + action=['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'] + else: + action=['RunCommand','--id',asset.mesh_node_id,'--run',f"rm -rf -- '{remote_dir}'",'--reply'] + return _run_meshctrl(cfg,asset,password,action,timeout) + + +def _add_job_event(db, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None) -> None: + db.add(JobEvent( + job_id=job.id, + attempt=int(job.attempt_count or 0), + event_type=event_type, + status=status if status is not None else job.status, + message=(message or "")[:4000] or None, + )) + + +def execute_job(job_id: int, token: str, callback_base: str) -> None: + # Reserve the dispatch slot before opening a database session. With large + # bulk selections this keeps waiting workers from occupying the connection + # pool while they wait for their configured MeshCtrl start interval. + dispatch_delay_seconds, dispatch_wait_seconds = _wait_for_dispatch_slot() + db = SessionLocal() + temp_path: str | None = None + temp_dir: str | None = None + try: + job = db.get(SoftwareJob, job_id) + asset = db.get(Asset, job.asset_id) if job else None + if not job or not asset or not asset.mesh_node_id: + if job: + job.status='failed' + job.message='Asset besitzt keine MeshCentral Node-ID.' + job.finished_at=datetime.utcnow() + sync_asset_job_state(db, job) + db.commit() + return + callback_url=f"{callback_base}/api/software-jobs/{job.id}/callback?token={quote(token)}" + definition_id=None + if job.job_type=='job_definition': + try: definition_id=int((job.parameters or {}).get('definition_id')) + except (TypeError,ValueError): raise RuntimeError('Jobdefinition fehlt oder ist ungültig.') + payload,interpreter,upload_required=_job_execution_artifacts(db,job.platform,callback_url,job.id,asset,definition_id) + job.attempt_count=(job.attempt_count or 0)+1 + attempt=job.attempt_count + remote_dir,remote_file=_remote_job_paths(job.platform,job.id,attempt,interpreter) + job.resolved_script=payload + launch_command=None + launch_shell=None + if upload_required: + launch_command,launch_shell,_launch_flags=_uploaded_script_launch_spec(job.platform,interpreter,remote_file) + job.command_preview=f'UPLOAD -> {remote_file}\nSHELL -> {launch_shell}\nEXECUTE -> {launch_command}' + else: + job.command_preview=payload + job.status='sending' + _add_job_event(db,job,'sending','sending','Jobdatei wird über MeshCentral übertragen.') + sync_asset_job_state(db, job) + db.commit() + + cfg=load_config().get('meshcentral',{}) + password_env=str(cfg.get('password_env') or 'MESHCENTRAL_PASSWORD') + password=os.getenv(password_env,'') + if not password: raise RuntimeError(f'MeshCentral-Passwortvariable {password_env} ist nicht gesetzt.') + timeout=max(30,int(cfg.get('timeout_seconds') or 600)) + job_parameters = job.parameters if isinstance(job.parameters, dict) else {} + diagnostics=[ + '--- File dispatcher diagnostics ---', + f'Dispatch interval configured: {dispatch_delay_seconds:g} seconds', + f'Dispatch queue wait: {dispatch_wait_seconds:.3f} seconds', + f'Job type (technical): {job.job_type}', + f'Package (technical): {job.package.name if job.package else job.package_id}', + f'Creation mode: {job_parameters.get("_creation_mode") or "single"}', + f'Bulk batch id: {job_parameters.get("_bulk_batch_id") or "-"}', + f'Bulk position: {job_parameters.get("_bulk_position") or "-"}/{job_parameters.get("_bulk_total") or "-"}', + f'Mesh node id: {asset.mesh_node_id}', + f'Interpreter: {interpreter}', + f'Upload required: {upload_required}', + f'Resolved script characters: {len(payload)}', + f'Resolved script SHA-256: {hashlib.sha256(payload.encode("utf-8")).hexdigest()}', + ] + if job.job_type == 'job_definition': + diagnostics += [ + f'Definition id: {job_parameters.get("definition_id") or "-"}', + f'Definition name: {job_parameters.get("definition_name") or "-"}', + f'Definition kind: {job_parameters.get("definition_job_kind") or "-"}', + f'Definition revision: {job_parameters.get("definition_revision") or "-"}', + f'Definition interpreter: {job_parameters.get("definition_interpreter") or interpreter}', + f'Definition source type: {job_parameters.get("definition_source_type") or "-"}', + ] + + if upload_required: + suffix=_script_extension(interpreter) + temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-') + temp_path=str(Path(temp_dir)/('run'+suffix)) + Path(temp_path).write_text(payload,encoding='utf-8',newline='\n') + diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}'] + + prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file) + diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}'] + if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}') + + upload_results=[] + uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout) + upload_results.append(uploaded) + diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}'] + upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '') + upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text + if not upload_ok: + # MeshCtrl can return code 0 together with "Upload error". + # Recreate the directory, remove any stale target and retry once. + repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file) + diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}'] + uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout) + upload_results.append(uploaded) + diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}'] + upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '') + upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text + if not upload_ok: + combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip()) + raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}') + + diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False'] + started=datetime.utcnow() + result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout) + finished=datetime.utcnow() + diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}'] + else: + started=datetime.utcnow() + action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply'] + if job.platform=='windows' and interpreter=='powershell': action += ['--powershell'] + result=_run_meshctrl(cfg,asset,password,action,timeout) + finished=datetime.utcnow() + diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}'] + + now=datetime.utcnow() + # MeshCtrl itself returns code 0 even for some remote PowerShell parser + # errors. Only delete the remote files after a completed successful + # callback, never merely because the MeshCtrl process exited cleanly. + db.expire_all() + callback_state=db.get(SoftwareJob,job.id) + cleanup_result=None + callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success') + diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}'] + if upload_required and callback_success: + cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120)) + diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}'] + elif upload_required: + diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.'] + + db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False) + db.commit() + terminal_states={'success','failed','partial','timeout','cancelled'} + pending=db.query(SoftwareJob).filter(SoftwareJob.id==job.id,SoftwareJob.callback_completed_at.is_(None),SoftwareJob.status.notin_(terminal_states)) + if result.returncode==0: + message='Skriptdatei ausgeführt; Callback wird erwartet.' + if upload_required and not callback_success: + message+=' Jobdateien bleiben bis zu einem bestätigten erfolgreichen Callback erhalten.' + if upload_required and cleanup_result is not None and cleanup_result.returncode!=0: + message+=' Die lokale Jobdatei konnte nicht automatisch gelöscht werden.' + pending.update({SoftwareJob.status:'sent',SoftwareJob.message:message},synchronize_session=False) + else: + pending.update({SoftwareJob.status:'failed',SoftwareJob.message:f'MeshCtrl-Ausführungsfehler {result.returncode}; Jobdateien bleiben zur Diagnose erhalten.',SoftwareJob.finished_at:now},synchronize_session=False) + db.expire_all() + refreshed=db.get(SoftwareJob,job.id) + if refreshed: + _add_job_event(db,refreshed,'meshctrl_result',refreshed.status,refreshed.message) + sync_asset_job_state(db, refreshed) + db.commit() + except Exception as exc: + job=db.get(SoftwareJob,job_id) + if job: + job.status='failed' + job.message=str(exc) + job.finished_at=datetime.utcnow() + if 'diagnostics' in locals() and diagnostics: + job.meshctrl_stdout='\n'.join(diagnostics)[-30000:] + _add_job_event(db,job,'error','failed',str(exc)) + sync_asset_job_state(db, job) + db.commit() + finally: + if temp_path: + try: os.unlink(temp_path) + except OSError: pass + if temp_dir: + try: os.rmdir(temp_dir) + except OSError: pass + db.close() + diff --git a/app/static/app.css b/app/static/app.css new file mode 100644 index 0000000..1733d08 --- /dev/null +++ b/app/static/app.css @@ -0,0 +1,13 @@ + +/* Registry job editor: action-specific fields */ +.registry-value-fields, +.registry-permission-fields { + display: contents; +} +.registry-value-fields[hidden], +.registry-permission-fields[hidden] { + display: none !important; +} + +/* Asset job-status exclusion filter: checkboxes stay left-aligned and vertical. */ +.job-filter-status-fieldset{border:1px solid var(--border-color,#415166);border-radius:8px;padding:.85rem 1rem;margin:0}.job-filter-status-fieldset legend{padding:0 .35rem;font-weight:700}.job-filter-status-options{display:flex;flex-direction:column;align-items:flex-start;gap:.5rem;width:100%;margin-top:.65rem}.job-filter-status-options .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;max-width:none!important;text-align:left!important}.job-filter-status-options .checkbox-label input{flex:0 0 auto!important;margin:0!important}.job-filter-status-actions{justify-content:flex-start;margin-top:.75rem} diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..aecb3ee --- /dev/null +++ b/app/static/css/app.css @@ -0,0 +1,2825 @@ +:root{font-family:Arial,sans-serif;color:#222;background:#f3f5f7}*{box-sizing:border-box}body{margin:0}header{height:58px;background:#036;color:#fff;display:flex;align-items:center;padding:0 24px;gap:36px}.brand{font-size:22px;font-weight:bold}nav{display:flex;gap:20px}header a{color:#fff;text-decoration:none}main{width:100%;max-width:none;margin:24px 0;padding:0 24px}.toolbar{display:flex;justify-content:space-between;align-items:center}.button,button{background:#0878bd;color:#fff;border:0;border-radius:4px;padding:10px 15px;text-decoration:none;cursor:pointer}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.card{display:flex;gap:14px;background:#fff;border:1px solid #d8dde2;border-radius:6px;padding:14px;text-decoration:none;color:#222}.card img,.placeholder{width:58px;height:58px;object-fit:cover;border-radius:5px}.placeholder{display:grid;place-items:center;background:#dbeaf3;font-size:26px}.card small{display:block;color:#667;margin-top:8px}table{width:100%;border-collapse:collapse;background:#fff}th,td{text-align:left;border-bottom:1px solid #ddd;padding:10px}th{background:#e8edf1}a{color:#056ca8}.filters{display:flex;gap:10px;margin:12px 0 18px;flex-wrap:wrap}.filters a{background:#fff;border:1px solid #ccd4da;padding:7px 10px;border-radius:4px;text-decoration:none}form{background:#fff;padding:20px;border:1px solid #d8dde2;border-radius:6px}label{display:block;margin-bottom:14px;font-weight:bold}input,select,textarea{display:block;width:100%;padding:9px;margin-top:5px;border:1px solid #bbb;border-radius:4px;font:inherit}textarea{min-height:100px}.detail{display:grid;grid-template-columns:280px 1fr;gap:24px;background:#fff;padding:20px}.asset-image{max-width:100%;max-height:260px;object-fit:contain}dl{display:grid;grid-template-columns:220px 1fr;margin:0}dt,dd{padding:9px;border-bottom:1px solid #eee;margin:0}dt{font-weight:bold}@media(max-width:700px){.detail{grid-template-columns:1fr}dl{grid-template-columns:1fr}header{padding:0 10px;gap:12px}nav{gap:9px}} +.panel{background:#fff;border:1px solid #ccd4dc;border-radius:4px;padding:18px;margin-top:18px;overflow:auto}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.card{background:#fff;border:1px solid #ccd4dc;padding:16px;display:flex;flex-direction:column}.card strong{font-size:28px}.config-list{display:grid;grid-template-columns:minmax(130px,220px) 1fr;gap:8px 16px}.sync-status{display:inline-block;padding:3px 8px;border-radius:12px;background:#e5e7eb}.status-success,.status-synced,.status-created,.status-updated{background:#d9f2df}.status-conflict,.status-partial{background:#fff0c2}.status-error,.status-missing{background:#ffd9d9}code{word-break:break-all} +.log-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.log-header h2{margin:0}.sync-log{height:340px;overflow:auto;background:#111827;color:#e5e7eb;border-radius:5px;padding:12px;font-family:Consolas,"Courier New",monospace;font-size:13px;line-height:1.55;white-space:pre-wrap}.log-line{padding:1px 0}.log-debug{color:#9ca3af}.log-info{color:#dbeafe}.log-success{color:#86efac}.log-warning{color:#fde68a}.log-error{color:#fca5a5}button:disabled{opacity:.65;cursor:wait} +.toolbar-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.inline-form{display:inline;margin:0;padding:0;background:transparent;border:0}.button-danger{background:#b42318}.button-danger:hover{background:#912018}.empty-state{text-align:center;color:#667;padding:24px} +.table-scroll{overflow:auto}.table-hint{color:#667;font-size:13px}.multiline{white-space:pre-line;min-width:140px}#asset-table th[draggable=true]{cursor:grab;user-select:none}#asset-table th.dragging{opacity:.45}#asset-table{min-width:900px} + +@media(max-width:700px){main{padding:0 10px}} +.button-secondary{background:#546675}.column-filter-row th{padding:6px;background:#f5f7f9}.column-filter-row input{margin:0;min-width:110px;padding:6px;font-size:13px}.sortable-column{cursor:pointer;user-select:none;padding-right:24px;position:relative}.sortable-column.sort-asc::after{content:"▲";position:absolute;right:7px;font-size:10px}.sortable-column.sort-desc::after{content:"▼";position:absolute;right:7px;font-size:10px}tr[hidden]{display:none}.history-old,.history-new{white-space:pre-wrap} + +/* Tabellen: fixierte Kopfzeilen und kompakte Aktionsschaltflächen */ +.sticky-table{max-height:calc(100vh - 170px);overflow:auto;border:1px solid #d8dde2;background:#fff} +.sticky-table thead tr:first-child th{position:sticky;top:0;z-index:4} +.sticky-table thead .column-filter-row th{position:sticky;top:41px;z-index:3} +.actions-column,.row-actions{white-space:nowrap;text-align:center;min-width:145px} +.row-actions{white-space:nowrap;text-align:center} +.icon-button{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;border:0;border-radius:4px;text-decoration:none;color:#fff;cursor:pointer;font-size:16px;line-height:1} +.button-issue{background:#198754}.button-issue:hover{background:#146c43} +.button-return{background:#d97706}.button-return:hover{background:#b45309} +.button-duplicate{background:#6f42c1}.button-duplicate:hover{background:#59359a} +.button-save{background:#0878bd}.notice{padding:10px 14px;background:#fff3cd;border:1px solid #ffe69c;border-radius:4px} +.checkbox{width:auto;display:inline-block}.form-actions{margin-top:16px} + +/* Temporäre Hinweise und Fehlermeldungen */ +.toast-container { + position: fixed; + z-index: 5000; + display: flex; + width: min(430px, calc(100vw - 2rem)); + flex-direction: column; + gap: .65rem; + pointer-events: none; +} +.toast { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: .7rem; + padding: .85rem 1rem; + border: 1px solid rgba(0,0,0,.12); + border-radius: .55rem; + background: #fff; + box-shadow: 0 .45rem 1.4rem rgba(0,0,0,.22); + opacity: 1; + transform: translateY(0); + transition: opacity .35s ease, transform .35s ease; + pointer-events: auto; +} +.toast-error { border-left: .42rem solid #b42318; } +.toast-warning { border-left: .42rem solid #b7791f; } +.toast-success { border-left: .42rem solid #137333; } +.toast-symbol { font-weight: 700; } +.toast-error .toast-symbol { color: #b42318; } +.toast-warning .toast-symbol { color: #b7791f; } +.toast-success .toast-symbol { color: #137333; } +.toast-close { + border: 0; + background: transparent; + padding: 0 .15rem; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; +} +.toast-hide { opacity: 0; transform: translateY(-.5rem); } + +/* v0.3.0: Branding, Navigation und breite/responsive Tabellen */ +html,body{max-width:100%;overflow-x:hidden}main{min-width:0}.main-header{min-height:58px;height:auto;flex-wrap:wrap}.brand{display:flex;align-items:center;gap:10px;color:#fff;text-decoration:none;white-space:nowrap}.brand img{width:38px;height:38px;object-fit:contain;border-radius:4px;background:#fff}.main-nav{flex:1;flex-wrap:wrap}.user-menu{position:relative;margin-left:auto}.user-menu summary{cursor:pointer;list-style:none;color:#fff;white-space:nowrap}.user-menu summary::-webkit-details-marker{display:none}.user-menu-panel{position:absolute;right:0;top:calc(100% + 10px);z-index:100;background:#fff;color:#222;border:1px solid #ccd4da;border-radius:5px;box-shadow:0 8px 24px rgba(0,0,0,.18);min-width:210px;padding:8px}.user-menu-panel a,.user-menu-panel span{display:block;padding:9px 10px}.user-menu-panel a{text-decoration:none}.disabled-link{pointer-events:none;opacity:.55}.muted{color:#667;font-size:13px}.settings-form{max-width:900px}.settings-section{margin-bottom:26px}.settings-section h2{margin-top:0}.logo-preview{display:flex;align-items:center;gap:18px;margin:12px 0}.logo-preview img{max-width:220px;max-height:90px;object-fit:contain;border:1px solid #ddd;padding:6px;background:#fff}.checkbox-label{display:flex;align-items:center;gap:8px;margin:0}.table-scroll,.sticky-table{width:100%;max-width:100%;overflow-x:auto;overflow-y:auto}.data-table{width:max-content;min-width:100%;table-layout:auto}.data-table th,.data-table td{max-width:420px;overflow-wrap:anywhere}.data-table th{white-space:nowrap}.sticky-table{overscroll-behavior:contain}.sticky-table::-webkit-scrollbar{height:14px;width:14px} +@media(max-width:900px){header{gap:14px;padding:10px 14px}.main-nav{order:3;width:100%;overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}main{padding:0 12px}.toolbar{align-items:flex-start;gap:12px;flex-wrap:wrap}.toolbar-actions{width:100%}.detail{grid-template-columns:1fr}} + +/* v0.3.1: Firmenname und Benutzerverwaltung */ +.brand-company{font-size:15px;font-weight:normal;opacity:.9}.settings-subsection{padding:16px;border:1px solid #d8dde2;border-radius:6px;background:#f8fafb}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.checkbox-row{display:flex;gap:24px;flex-wrap:wrap;margin-bottom:14px}.auth-card{max-width:520px;margin:40px auto;background:#fff;padding:24px;border:1px solid #d8dde2;border-radius:8px}.auth-card form{padding:0;border:0}.profile-panel{max-width:850px}.compact-form{padding:0;border:0}.user-inline-form{display:grid;grid-template-columns:repeat(3,minmax(120px,1fr)) auto auto auto;gap:6px;align-items:center;padding:0;border:0;background:transparent;min-width:760px}.user-inline-form input{margin:0;padding:6px}.user-inline-form .checkbox-label{margin:0;font-size:12px}.user-inline-form button{padding:7px 10px}@media(max-width:700px){.form-grid{grid-template-columns:1fr}.brand-company{display:none}} + +/* Benutzer-Dropdown: Links müssen sich von der weißen Navigation unterscheiden. */ +.user-menu-panel a { color:#1f2933 !important; } +.user-menu-panel a:visited { color:#1f2933 !important; } +.user-menu-panel a:hover { color:#0b4f86 !important; background:#eef4f8; border-radius:4px; } +.user-menu-panel .muted { color:#667085 !important; } +.group-checkboxes{display:flex;flex-wrap:wrap;gap:.5rem 1rem}.user-permission-form fieldset{margin:.75rem 0;padding:.75rem}dialog{border:0;border-radius:10px;padding:1.25rem;box-shadow:0 20px 60px rgba(0,0,0,.25);min-width:min(420px,90vw)}dialog::backdrop{background:rgba(0,0,0,.35)}dialog form{display:grid;gap:1rem} + + +/* Baustruktur */ +.tree-filters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;align-items:end;margin-bottom:1.25rem}.tree-scroll{overflow:auto;padding:1rem 0}.asset-tree,.asset-tree ul{list-style:none;margin:0;padding:0;display:flex;justify-content:center;gap:1.25rem;position:relative}.asset-tree ul{padding-top:1.6rem}.tree-item{display:flex;flex-direction:column;align-items:center;position:relative}.tree-item>ul:before{content:"";position:absolute;top:0;left:50%;height:1.6rem;border-left:1px solid #aeb8c2}.tree-card{width:230px;min-height:88px;background:#fff;border:1px solid #d7dde3;border-radius:10px;box-shadow:0 2px 8px rgba(31,41,51,.08);display:flex;overflow:hidden}.tree-card img,.tree-placeholder{width:72px;min-width:72px;height:88px;object-fit:cover;background:#eef2f5;display:flex;align-items:center;justify-content:center;font-size:1.5rem}.tree-card-body{padding:.65rem;min-width:0}.tree-name{display:block;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tree-meta,.tree-category{font-size:.78rem;color:#667085;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tree-category{margin-top:.25rem;color:#0b4f86}@media(max-width:800px){.asset-tree,.asset-tree ul{justify-content:flex-start}.tree-card{width:205px}} + +/* Baustruktur v0.3.4.3: kompakte Kategorie-Icons und Mehrfachfilter */ +.tree-category-filter{margin:0;padding:.55rem .7rem;border:1px solid #d7dde3;border-radius:7px;min-width:0}.tree-category-filter legend{padding:0 .25rem;font-weight:600}.tree-category-options{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.25rem .7rem;max-height:130px;overflow:auto}.tree-category-filter small{display:block;margin-top:.35rem;color:#667085}.tree-hide-option{align-self:center}.tree-card{width:215px;min-height:78px;align-items:center}.tree-category-icon{width:48px;min-width:48px;height:78px;background:#eef4f8;color:#0b4f86;display:flex;align-items:center;justify-content:center}.tree-category-icon svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.tree-category-icon svg circle{fill:currentColor;stroke:none}.tree-card-body{padding:.55rem .65rem}.tree-card img,.tree-placeholder{display:none}@media(max-width:800px){.tree-card{width:195px}.tree-category-icon{width:42px;min-width:42px}} + +/* Baustruktur v0.3.4.6: durchgehende Verbindungslinien mit Abzweigern */ +.tree-scroll { + --tree-line-color: #8ea4b8; + --tree-line-width: 2px; + --tree-branch-height: 30px; + padding: 1.25rem 1rem 2rem; +} + +/* Die alten, nur senkrechten Verbindungslinien deaktivieren. */ +.asset-tree .tree-item > ul::before { + display: none; +} + +.asset-tree, +.asset-tree ul { + gap: 0; + overflow: visible; +} + +.asset-tree > .tree-item { + margin-inline: 1rem; +} + +.asset-tree .tree-item { + position: relative; + padding-inline: .7rem; +} + +/* Abstand zwischen Elternkarte und der darunterliegenden Abzweigung. */ +.asset-tree .tree-item > ul { + position: relative; + padding-top: calc(var(--tree-branch-height) * 2); +} + +/* Senkrechte Leitung von der Elternkarte bis zur horizontalen Sammelschiene. */ +.asset-tree .tree-item > ul::after { + content: ""; + position: absolute; + top: 0; + left: 50%; + width: 0; + height: var(--tree-branch-height); + border-left: var(--tree-line-width) solid var(--tree-line-color); + transform: translateX(-50%); +} + +/* Obere Hälfte der Verbindung jedes Kindes: horizontale Sammelschiene. */ +.asset-tree .tree-item > ul > .tree-item::before, +.asset-tree .tree-item > ul > .tree-item::after { + content: ""; + position: absolute; + top: calc(var(--tree-branch-height) * -1); + width: 50%; + height: var(--tree-branch-height); + border-top: var(--tree-line-width) solid var(--tree-line-color); + pointer-events: none; +} + +.asset-tree .tree-item > ul > .tree-item::before { + right: 50%; +} + +.asset-tree .tree-item > ul > .tree-item::after { + left: 50%; + border-left: var(--tree-line-width) solid var(--tree-line-color); + border-top-left-radius: 9px; +} + +/* Erstes Kind: keine Linie über die linke Außenkante hinaus. */ +.asset-tree .tree-item > ul > .tree-item:first-child::before { + border-top: 0; +} + +.asset-tree .tree-item > ul > .tree-item:first-child::after { + border-top-left-radius: 9px; +} + +/* Letztes Kind: rechte Außenkante abschließen und abrunden. */ +.asset-tree .tree-item > ul > .tree-item:last-child::after { + border-top: 0; + border-left: 0; +} + +.asset-tree .tree-item > ul > .tree-item:last-child::before { + border-right: var(--tree-line-width) solid var(--tree-line-color); + border-top-right-radius: 9px; +} + +/* Ein einziges Kind benötigt keine horizontale Sammelschiene. */ +.asset-tree .tree-item > ul > .tree-item:only-child::before, +.asset-tree .tree-item > ul > .tree-item:only-child::after { + border: 0; +} + +.asset-tree .tree-item > ul > .tree-item:only-child::after { + left: 50%; + width: 0; + border-left: var(--tree-line-width) solid var(--tree-line-color); + border-radius: 0; +} + +/* Die senkrechte Leitung endet sauber mittig an der Gerätekarte. */ +.asset-tree .tree-item > ul > .tree-item > .tree-card::before { + content: ""; + position: absolute; + top: calc(var(--tree-branch-height) * -1); + left: 50%; + width: 0; + height: var(--tree-branch-height); + border-left: var(--tree-line-width) solid var(--tree-line-color); + transform: translateX(-50%); + pointer-events: none; +} + +.asset-tree .tree-card { + position: relative; + z-index: 1; +} + +/* Bei mehreren unabhängigen Wurzeln werden keine künstlichen Linien gezogen. */ +.asset-tree > .tree-item::before, +.asset-tree > .tree-item::after, +.asset-tree > .tree-item > .tree-card::before { + display: none; +} + +@media (max-width: 800px) { + .tree-scroll { + --tree-branch-height: 24px; + padding-inline: .5rem; + } + .asset-tree .tree-item { + padding-inline: .45rem; + } +} + +/* Diagramme v0.3.5 */ +:root { + --chart-color-0:#2563eb; --chart-color-1:#16a34a; --chart-color-2:#f59e0b; + --chart-color-3:#dc2626; --chart-color-4:#7c3aed; --chart-color-5:#0891b2; + --chart-color-6:#ea580c; --chart-color-7:#4f46e5; --chart-color-8:#65a30d; + --chart-color-9:#db2777; --chart-color-10:#0f766e; --chart-color-11:#64748b; +} +.chart-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,430px),1fr));gap:1rem;margin:1rem 0 1.5rem}.chart-card{background:#fff;border:1px solid #dfe4ea;border-radius:12px;padding:1rem;min-width:0;box-shadow:0 1px 3px rgba(15,23,42,.06)}.chart-card-head{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start;margin-bottom:1rem}.chart-card-head h3{margin:0 0 .25rem}.chart-card-head p{margin:0;color:#64748b;font-size:.9rem}.chart-total{white-space:nowrap;background:#eef2ff;color:#1f2937;border-radius:999px;padding:.3rem .55rem;font-size:.8rem;font-weight:700}.donut-layout{display:flex;align-items:center;gap:1.4rem;min-height:230px}.donut-chart{width:190px;aspect-ratio:1;border-radius:50%;position:relative;flex:0 0 auto}.donut-chart:after{content:"";position:absolute;inset:27%;background:#fff;border-radius:50%;box-shadow:0 0 0 1px #eef2f7}.donut-chart span{position:absolute;inset:0;display:grid;place-items:center;z-index:1;font-size:1.5rem;font-weight:800}.donut-chart.pie-chart:after{display:none}.donut-chart.pie-chart span{color:#fff;text-shadow:0 1px 3px #0008}.chart-legend{display:grid;gap:.45rem;min-width:0;flex:1}.chart-legend>div{display:grid;grid-template-columns:12px minmax(0,1fr) auto;align-items:center;gap:.45rem;font-size:.88rem}.chart-legend i,.stack-legend i{width:10px;height:10px;border-radius:3px;background:var(--legend-color);display:inline-block}.chart-legend span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar-chart{display:grid;gap:.65rem}.bar-row{display:grid;grid-template-columns:minmax(90px,28%) minmax(120px,1fr) 35px;gap:.6rem;align-items:center}.bar-label{font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar-track{height:25px;border-radius:6px;background:#eef2f7;overflow:hidden;display:flex}.bar-segment{height:100%;min-width:2px;background:var(--segment-color);display:flex;align-items:center;justify-content:flex-end;color:#fff;font-size:.75rem;font-weight:700}.bar-segment span{padding:0 .4rem;text-shadow:0 1px 1px #0005}.bar-value{text-align:right;font-size:.85rem}.stack-legend{display:flex;flex-wrap:wrap;gap:.75rem;margin:-.2rem 0 .9rem;font-size:.8rem}.stack-legend span{display:flex;align-items:center;gap:.3rem}.chart-empty{display:grid;place-items:center;min-height:180px;color:#64748b}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:1rem;margin-top:1.5rem}.section-heading h2{margin:0}.form-card{background:#fff;border:1px solid #dfe4ea;border-radius:12px;padding:1rem;max-width:980px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.chart-form label{display:grid;gap:.35rem}.checkbox-row{display:flex;flex-wrap:wrap;gap:1.25rem;margin:1rem 0}.checkbox-row label{display:flex;align-items:center;gap:.4rem}.muted-disabled{opacity:.45}.block{display:block}.dashboard-charts .chart-card{min-height:280px}@media(max-width:700px){.donut-layout{flex-direction:column}.form-grid{grid-template-columns:1fr}.bar-row{grid-template-columns:90px minmax(90px,1fr) 30px}} + +/* Mehrfachauswahl und Massenänderung v0.3.6.2 */ +.selection-column{width:42px;min-width:42px;text-align:center}.selection-column input{width:18px;height:18px;cursor:pointer}.toolbar-actions button:disabled{opacity:.5;cursor:not-allowed}.bulk-edit-panel{max-width:900px}.compact-list{columns:2;column-gap:2rem}.compact-list li{break-inside:avoid;margin:.25rem 0}.hidden-control{display:none!important}.user-delete-form{margin-top:.75rem;padding-top:.75rem;border-top:1px solid #e5e7eb}.user-delete-form .button-danger{width:auto}@media(max-width:700px){.compact-list{columns:1}} +.header-actions{display:flex;align-items:center;gap:.5rem}.language-menu{position:relative}.language-menu summary{cursor:pointer;list-style:none;padding:.45rem .65rem;border-radius:.45rem}.menu-button{display:block;width:100%;border:0;background:transparent;text-align:left;padding:.55rem .8rem;cursor:pointer}.menu-button:hover,.menu-button.active{background:rgba(0,0,0,.06)}.translation-import{margin-top:1rem}.translation-import input[type=file]{margin-right:1rem}.table-wrap textarea{min-width:240px;width:100%;resize:vertical}.toolbar-actions{display:flex;gap:.5rem;flex-wrap:wrap} + +/* v0.3.8.0 central field catalog */ +.field-capabilities{display:flex;flex-wrap:wrap;gap:.5rem 1rem}.field-capabilities .checkbox-label{min-width:150px}.data-table input[type=checkbox]{width:auto;margin:.2rem auto}.mesh-mapping-table input,.mesh-mapping-table select{min-width:130px}.settings-form small{display:block;margin-top:.3rem;color:#667085;font-weight:normal} +.mesh-mapping-table{min-width:1750px}.mesh-mapping-table input,.mesh-mapping-table select{min-width:130px}.mesh-mapping-table .mapping-path{min-width:360px;font-family:monospace}.mesh-mapping-table .mapping-priority{min-width:65px;width:75px}.mesh-mapping-table .mapping-separator{min-width:65px;width:75px} + +/* Dropdown forms inherit the compact menu styling instead of the global form card. */ +.user-menu-panel form{margin:0;padding:0;border:0;border-radius:0;background:transparent;box-shadow:none;width:100%} +.user-menu-panel .menu-button{display:block;width:100%;margin:0;padding:9px 10px;border:0;border-radius:4px;background:transparent;color:#222;text-align:left;cursor:pointer} +.user-menu-panel .menu-button:hover{background:#eef4f8;color:#0b4f86} +.user-menu-panel .menu-button.active{background:#e4edf5;color:#0b4f86;font-weight:600} + + +/* v0.3.8.6: benutzerspezifische Toast-Position */ +.toast-position-top-left{top:1rem;left:1rem;align-items:flex-start} +.toast-position-top-center{top:1rem;left:50%;transform:translateX(-50%);align-items:center} +.toast-position-top-right{top:1rem;right:1rem;align-items:flex-end} +.toast-position-bottom-left{bottom:1rem;left:1rem;align-items:flex-start;flex-direction:column-reverse} +.toast-position-bottom-center{bottom:1rem;left:50%;transform:translateX(-50%);align-items:center;flex-direction:column-reverse} +.toast-position-bottom-right{bottom:1rem;right:1rem;align-items:flex-end;flex-direction:column-reverse} +@media(max-width:520px){.toast-container{left:1rem!important;right:1rem!important;width:auto;transform:none!important}.toast-position-top-left,.toast-position-top-center,.toast-position-top-right{top:1rem}.toast-position-bottom-left,.toast-position-bottom-center,.toast-position-bottom-right{bottom:1rem}} + +/* Diagramm-Statusfilter */ +.checkbox-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px 16px;margin:10px 0} +.checkbox-option{display:flex;align-items:center;gap:8px;margin:0;padding:8px 10px;border:1px solid #d8dde2;border-radius:6px;background:#f8fafc} +.checkbox-option input{width:auto;margin:0} +.inline-form{display:inline-block;margin:0;padding:0;border:0;background:transparent} + +/* Geräteinventar mit aufklappbarer Baumansicht */ +.inventory-header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;flex-wrap:wrap} +.inventory-header h2{margin-bottom:4px}.inventory-meta{display:flex;gap:18px;flex-wrap:wrap;padding:12px 0 16px;border-bottom:1px solid #d9e1e8}.inventory-meta code{word-break:break-all} +.inventory-tabs{display:flex;gap:4px;flex-wrap:wrap;margin:18px 0 0;border-bottom:1px solid #cbd6df}.inventory-tab{border:0;background:transparent;color:#111;padding:10px 14px;cursor:pointer;border-bottom:3px solid transparent;font-weight:600}.inventory-tab:hover{color:#000;background:rgba(0,0,0,.035)}.inventory-tab.active{color:#000;border-bottom-color:#0b4f86;background:rgba(0,0,0,.04)} +.inventory-tab-content{display:none;padding-top:16px}.inventory-tab-content.active{display:block}.inventory-tree{position:relative;padding-left:4px}.inventory-node{margin:4px 0}.inventory-node>summary{display:flex;align-items:center;gap:8px;cursor:pointer;padding:7px 9px;border-radius:5px;font-weight:600}.inventory-node>summary:hover{background:rgba(0,0,0,.05)}.inventory-count{font-size:.8em;font-weight:400;opacity:.65;border:1px solid currentColor;border-radius:10px;padding:0 6px}.inventory-children{position:relative;margin-left:15px;padding-left:18px;border-left:2px solid #c9d5df}.inventory-children:before{content:"";position:absolute;left:-2px;top:0;width:14px;border-top:2px solid #c9d5df}.inventory-property{display:grid;grid-template-columns:minmax(150px,280px) minmax(180px,1fr);gap:16px;position:relative;padding:7px 9px;margin:2px 0;border-radius:4px}.inventory-property:before{content:"";position:absolute;left:-20px;top:50%;width:18px;border-top:2px solid #c9d5df}.inventory-property:hover{background:rgba(0,0,0,.035)}.inventory-property>span{word-break:break-word}.inventory-property>strong{font-weight:500;word-break:break-word}.json-viewer{max-height:650px;overflow:auto;background:#111827;color:#e5e7eb;padding:16px;border-radius:6px;white-space:pre-wrap;word-break:break-word}.empty-state{padding:24px;border:1px dashed #b9c6d0;border-radius:6px;text-align:center}.table-filter-row{max-width:480px;margin-bottom:12px}.table-filter-row label{display:block}.table-filter-row input{width:100%}.table-scroll{overflow:auto} +@media(max-width:700px){.inventory-property{grid-template-columns:1fr;gap:3px}.inventory-children{margin-left:6px;padding-left:12px}.inventory-property:before{left:-14px;width:12px}} + + +/* v0.3.9.1: Inventar-Icons und globaler CSS-Editor */ +.inventory-section-icon{display:inline-grid;place-items:center;width:1.45rem;min-width:1.45rem;height:1.45rem;font-size:1.05rem;line-height:1;filter:saturate(1.15)} +.custom-css-editor{display:block;width:100%;min-height:360px;resize:vertical;font-family:Consolas,"Liberation Mono",Menlo,monospace;font-size:.9rem;line-height:1.5;tab-size:2;white-space:pre} + + +/* v0.3.9.7: record count in the upper-right toolbar */ +.record-count{ + display:inline-flex; + align-items:center; + min-height:36px; + padding:7px 11px; + border:1px solid #c9d3dc; + border-radius:4px; + background:#fff; + color:#44515c; + font-size:13px; + font-weight:600; + white-space:nowrap; +} +.toolbar-actions{ + display:flex; + align-items:center; + justify-content:flex-end; + gap:8px; + flex-wrap:wrap; +} + +/* v0.3.9.7: use exactly one vertical scrollbar on pages with sticky tables */ +body.single-scroll-list{ + height:100vh; + min-height:0; + overflow:hidden; + display:flex; + flex-direction:column; +} +body.single-scroll-list .main-header{ + flex:0 0 auto; +} +body.single-scroll-list main{ + flex:1 1 auto; + min-height:0; + overflow:hidden; + display:flex; + flex-direction:column; + margin-top:14px; + margin-bottom:0; +} +body.single-scroll-list .sticky-table{ + flex:1 1 auto; + min-height:0; + max-height:none; + overflow:auto; + margin-bottom:0; +} +body.single-scroll-list .sticky-table + .muted{ + flex:0 0 auto; + margin:6px 0 8px; +} +@media(max-width:900px){ + body.single-scroll-list{ + height:auto; + min-height:100vh; + overflow:auto; + display:block; + } + body.single-scroll-list main{ + display:block; + overflow:visible; + } + body.single-scroll-list .sticky-table{ + max-height:calc(100vh - 220px); + } +} + + +/* v0.3.9.8: direct asset edit action */ +.button-edit{background:#0b6fa4} +.button-edit:hover{background:#07577f} + +/* v0.3.9.8: category list - valid table geometry and compact attributes */ +.categories-table td, +.categories-table th{vertical-align:middle} +.categories-table .category-actions{white-space:nowrap;min-width:285px} +.categories-table .action-buttons{display:flex;align-items:center;gap:6px;justify-content:flex-start} +.categories-table .action-buttons .button{padding:7px 10px} +.category-attributes{min-width:320px;max-width:620px} +.attribute-chip{ + display:inline-block; + margin:2px 4px 2px 0; + padding:3px 7px; + border:1px solid #c9d3dc; + border-radius:999px; + background:#f5f7f9; + color:#44515c; + font-size:12px; + white-space:nowrap; +} +.attribute-chip-list{ + border-color:#8ab9d4; + background:#eaf5fb; + color:#07577f; +} + +/* Uploaded favicon preview should remain favicon-sized. */ +.favicon-preview img{ + width:48px; + height:48px; + object-fit:contain; + image-rendering:auto; +} + +/* Keep action cell borders aligned; flex belongs inside the cell, not on td. */ +.row-actions{ + display:table-cell; + vertical-align:middle; + text-align:center; +} +.row-actions > .inline-form, +.row-actions > .icon-button{ + margin:2px; +} + + +/* v0.3.9.9: accessible horizontal scrolling */ +.table-scrollbar-top{ + width:100%; + height:16px; + overflow-x:auto; + overflow-y:hidden; + border:1px solid #d8dde2; + border-bottom:0; + background:#f5f7f9; +} +.table-scrollbar-top > div{height:1px} +.table-scrollbar-top[hidden]{display:none} +#asset-table-scroll{border-top:0} + +/* Column visibility dialog and header context menu */ +.column-settings-dialog{width:min(520px,92vw)} +.column-settings-dialog form{padding:0;border:0} +.dialog-heading{display:flex;align-items:center;justify-content:space-between;gap:1rem} +.dialog-heading h2{margin:0} +.column-settings-list{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:8px; + max-height:55vh; + overflow:auto; + margin:14px 0; +} +.column-context-menu{ + position:fixed; + z-index:7000; + min-width:190px; + padding:5px; + border:1px solid #c9d3dc; + border-radius:6px; + background:#fff; + box-shadow:0 8px 24px rgba(0,0,0,.2); +} +.column-context-menu button{ + display:block; + width:100%; + padding:8px 10px; + border:0; + border-radius:4px; + background:transparent; + color:#222; + text-align:left; +} +.column-context-menu button:hover{background:#eef4f8;color:#0b4f86} + +/* Fixed field widths should clip long values instead of expanding the table. */ +#asset-table th[style*="width"], +#asset-table td[style*="width"]{ + overflow:hidden; + text-overflow:ellipsis; +} +#asset-table td[style*="width"]{ + white-space:nowrap; +} +@media(max-width:700px){ + .column-settings-list{grid-template-columns:1fr} +} + + +/* v0.3.10.0: thicker and easier-to-grab asset table scrollbars */ +#asset-table-scroll-top{ + height:24px; + scrollbar-width:auto; +} +#asset-table-scroll{ + scrollbar-width:auto; +} +#asset-table-scroll-top::-webkit-scrollbar, +#asset-table-scroll::-webkit-scrollbar{ + width:20px; + height:20px; +} +#asset-table-scroll-top::-webkit-scrollbar-track, +#asset-table-scroll::-webkit-scrollbar-track{ + background:#e7ebef; + border:1px solid #c5ced6; +} +#asset-table-scroll-top::-webkit-scrollbar-thumb, +#asset-table-scroll::-webkit-scrollbar-thumb{ + background:#8797a5; + border:3px solid #e7ebef; + border-radius:10px; +} +#asset-table-scroll-top::-webkit-scrollbar-thumb:hover, +#asset-table-scroll::-webkit-scrollbar-thumb:hover{ + background:#607383; +} + +/* About dialog */ +.header-help-button{ + display:inline-grid; + place-items:center; + width:30px; + height:30px; + padding:0; + border:1px solid rgba(255,255,255,.45); + border-radius:50%; + background:transparent; + color:#fff; + font-weight:700; +} +.header-help-button:hover{background:rgba(255,255,255,.16)} +.about-dialog{width:min(620px,92vw)} +.about-dialog form{padding:0;border:0} +.about-logo{text-align:center;margin:8px 0 16px} +.about-logo img{max-width:220px;max-height:90px;object-fit:contain} +.about-manufacturer-logo{text-align:center;margin:8px 0 18px} +.about-manufacturer-logo img{display:block;width:auto;height:auto;max-width:150px;max-height:150px;object-fit:contain;margin:0 auto} +.about-details{grid-template-columns:150px minmax(0,1fr)} +.about-details dd{overflow-wrap:anywhere} + + +/* v0.3.12.0: duplicate search in current filtered asset list */ +.duplicate-search-dialog{width:min(570px,92vw)} +.duplicate-search-dialog form{padding:0;border:0} +#asset-table tbody tr.duplicate-hidden{display:none!important} +#asset-table tbody tr.duplicate-match td{ + background:#fff8d8; +} +#asset-table tbody tr.duplicate-match:hover td{ + background:#fff1ad; +} + + +/* v0.3.13.0: hidden technical forms do not create whitespace */ +.hidden-utility-form{ + display:none!important; + width:0!important; + height:0!important; + margin:0!important; + padding:0!important; + border:0!important; +} + +.merge-order-table tbody tr{cursor:grab} +.merge-order-table tbody tr.dragging{opacity:.45} +.merge-drag-handle{width:70px;text-align:center;font-size:20px;user-select:none;cursor:grab} +.merge-order-table td{vertical-align:top} + + +/* v0.3.13.1: category controls and dashboard cards must keep text inside */ +.filters{ + align-items:flex-start; +} +.filters a{ + display:inline-flex; + align-items:center; + justify-content:center; + min-height:34px; + max-width:100%; + white-space:normal; + overflow-wrap:anywhere; + word-break:normal; + line-height:1.2; + text-align:center; +} + +.cards{ + grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); +} +.cards .card{ + min-width:0; + overflow:hidden; +} +.cards .card > *{ + min-width:0; + max-width:100%; +} +.cards .card h2, +.cards .card h3, +.cards .card strong, +.cards .card span, +.cards .card a{ + white-space:normal; + overflow-wrap:anywhere; + word-break:normal; +} +.cards .card h2, +.cards .card h3{ + line-height:1.12; + margin-top:.35rem; + margin-bottom:.35rem; +} + + +/* v0.3.14.0: compact global sidebar */ +.app-shell{display:grid;grid-template-columns:220px minmax(0,1fr);min-height:calc(100vh - 58px);transition:grid-template-columns .16s ease} +.app-shell.sidebar-collapsed{grid-template-columns:58px minmax(0,1fr)} +.side-nav{position:relative;background:#f5f7f9;border-right:1px solid #d7dee5;padding:10px 8px;overflow:visible;z-index:30} +.side-nav>a,.side-nav-settings>.settings-trigger,.side-nav-toggle{display:flex;align-items:center;gap:10px;width:100%;min-height:40px;padding:8px 10px;border:0;border-radius:5px;background:transparent;color:inherit;text-decoration:none;white-space:nowrap;box-sizing:border-box} +.side-nav>a:hover,.side-nav-settings>.settings-trigger:hover,.side-nav-toggle:hover{background:#e5edf3} +.nav-icon{display:inline-flex;width:24px;min-width:24px;justify-content:center;font-size:18px} +.nav-label{overflow:hidden;text-overflow:ellipsis} +.nav-caret{margin-left:auto} +.sidebar-collapsed .nav-label,.sidebar-collapsed .nav-caret{display:none} +.sidebar-collapsed .side-nav>a,.sidebar-collapsed .side-nav-settings>.settings-trigger,.sidebar-collapsed .side-nav-toggle{justify-content:center;padding-left:6px;padding-right:6px} +.app-content{min-width:0;padding:14px} +.settings-hover-menu{display:none;position:absolute;left:calc(100% - 5px);top:0;min-width:235px;background:#fff;border:1px solid #cbd5df;border-radius:5px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:6px;z-index:100} +.side-nav-settings{position:relative} +.side-nav-settings:hover .settings-hover-menu,.side-nav-settings:focus-within .settings-hover-menu{display:block} +.settings-hover-menu a{display:block;padding:8px 10px;border-radius:4px;color:inherit;text-decoration:none;white-space:nowrap} +.settings-hover-menu a:hover{background:#eaf2f7} + +/* Settings page with independent left submenu */ +.settings-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:18px;align-items:start} +.settings-subnav{position:sticky;top:12px;border:1px solid #d7dee5;border-radius:6px;background:#fff;padding:10px} +.settings-subnav h2{margin:4px 8px 10px} +.settings-subnav a{display:block;padding:8px 10px;border-radius:4px;text-decoration:none;color:inherit} +.settings-subnav a:hover,.settings-subnav a.active{background:#e5edf3} +.settings-main{min-width:0} +.system-info-grid{display:grid;grid-template-columns:minmax(160px,220px) minmax(0,1fr);gap:8px 18px;margin:0} +.system-info-grid dt{font-weight:700} +.system-info-grid dd{margin:0;overflow-wrap:anywhere} +@media(max-width:900px){.app-shell,.app-shell.sidebar-collapsed{grid-template-columns:58px minmax(0,1fr)}.nav-label,.nav-caret{display:none}.settings-layout{grid-template-columns:1fr}.settings-subnav{position:static}} + + +/* v0.3.14.1: no duplicate settings navigation inside content */ +.settings-main{display:contents} +.settings-action-grid{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); + gap:16px; +} + +/* v0.3.14.4: stable first paint and right aligned header menus */ +.main-header{display:flex;align-items:center;flex-wrap:nowrap} +.header-actions{margin-left:auto;justify-content:flex-end;flex:0 0 auto} +html.sidebar-collapsed-preset .app-shell{grid-template-columns:58px minmax(0,1fr)} +html.sidebar-collapsed-preset .nav-label,html.sidebar-collapsed-preset .nav-caret{display:none} +html.sidebar-collapsed-preset .side-nav>a,html.sidebar-collapsed-preset .side-nav-settings>.settings-trigger,html.sidebar-collapsed-preset .side-nav-toggle{justify-content:center;padding-left:6px;padding-right:6px} +.app-shell:not(.sidebar-ready){transition:none} +.asset-list-initializing #asset-table-scroll,.asset-list-initializing #asset-table-scroll-top{visibility:hidden} +.asset-list-page #asset-table-scroll{contain:layout paint} +@media(max-width:700px){.main-header{flex-wrap:wrap}.header-actions{margin-left:auto}} + + +/* v0.3.14.5: coordinated asset-page initialization */ +.asset-page-loader{ + display:none; + min-height:calc(100vh - 110px); + align-items:center; + justify-content:center; + padding:24px; + box-sizing:border-box; +} +html.asset-page-booting .asset-page-loader{display:flex} +html.asset-page-booting .asset-list-page{ + position:absolute; + left:-100000px; + top:0; + width:calc(100% - 28px); + visibility:hidden; + pointer-events:none; +} +.asset-page-loader-card{ + display:flex; + flex-direction:column; + align-items:center; + justify-content:center; + gap:10px; + min-width:min(360px,90vw); + padding:28px 34px; + border:1px solid #d7dee5; + border-radius:8px; + background:#fff; + box-shadow:0 8px 24px rgba(0,0,0,.08); + text-align:center; +} +.asset-page-spinner{ + width:30px; + height:30px; + border:3px solid #d7dee5; + border-top-color:currentColor; + border-radius:50%; + animation:asset-page-spin .75s linear infinite; +} +@keyframes asset-page-spin{to{transform:rotate(360deg)}} +@media (prefers-reduced-motion:reduce){ + .asset-page-spinner{animation-duration:1.6s} +} + + +/* v0.3.15.0: asset-table performance refactoring */ +#asset-table.asset-table-fixed{ + table-layout:fixed; + max-width:none; +} +#asset-table.asset-table-fixed th, +#asset-table.asset-table-fixed td{ + box-sizing:border-box; + overflow:hidden; + text-overflow:ellipsis; +} +#asset-table.asset-table-fixed td{ + white-space:nowrap; +} +#asset-table.asset-table-fixed td.multiline{ + white-space:nowrap; +} + + + +/* v0.3.15.2: complete scroll-height chain for the sidebar layout */ +body.asset-list-scroll-page{ + height:100vh; + min-height:0; + overflow:hidden; + display:flex; + flex-direction:column; +} + +body.asset-list-scroll-page > .main-header{ + flex:0 0 auto; +} + +body.asset-list-scroll-page > .app-shell{ + flex:1 1 auto; + height:auto; + min-height:0; + overflow:hidden; +} + +body.asset-list-scroll-page .side-nav{ + min-height:0; +} + +body.asset-list-scroll-page .app-content{ + min-height:0; + overflow:hidden; + display:flex; + flex-direction:column; +} + +body.asset-list-scroll-page #asset-page-loader{ + flex:1 1 auto; + min-height:0; +} + +body.asset-list-scroll-page #asset-list-page{ + flex:1 1 auto; + min-height:0; + overflow:hidden; + display:flex; + flex-direction:column; +} + +body.asset-list-scroll-page #asset-list-page > .toolbar, +body.asset-list-scroll-page #asset-list-page > .filters, +body.asset-list-scroll-page #asset-list-page > .table-hint, +body.asset-list-scroll-page #asset-list-page > .hidden-utility-form, +body.asset-list-scroll-page #asset-list-page > #asset-table-scroll-top, +body.asset-list-scroll-page #asset-list-page > .muted{ + flex:0 0 auto; +} + +body.asset-list-scroll-page #asset-table-scroll{ + flex:1 1 auto; + width:100%; + max-width:100%; + min-height:0; + max-height:none; + overflow-x:auto; + overflow-y:auto; + overscroll-behavior:contain; +} + +/* The older containment rule prevents reliable scroll sizing in this layout. */ +body.asset-list-scroll-page .asset-list-page #asset-table-scroll{ + contain:none; +} + +@media(max-width:900px){ + body.asset-list-scroll-page{ + height:auto; + min-height:100vh; + overflow:auto; + display:block; + } + + body.asset-list-scroll-page > .app-shell{ + min-height:calc(100vh - 58px); + overflow:visible; + } + + body.asset-list-scroll-page .app-content, + body.asset-list-scroll-page #asset-list-page{ + overflow:visible; + display:block; + } + + body.asset-list-scroll-page #asset-table-scroll{ + max-height:calc(100vh - 240px); + overflow:auto; + } +} + + +/* v0.4.0.1 backup management */ +.backup-page{max-width:1400px}.backup-page-header{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin-bottom:22px}.backup-page-header h1{margin-bottom:6px}.backup-create-form{padding:0;border:0;background:transparent}.backup-primary-action{min-height:42px;padding:10px 18px}.backup-summary-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:18px}.backup-summary-card{display:flex;align-items:center;gap:14px;min-height:92px;padding:18px;background:#fff;border:1px solid #ccd4dc;border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.03)}.backup-summary-icon{display:grid;place-items:center;width:44px;height:44px;flex:0 0 44px;border-radius:8px;background:#eef4f8;font-size:22px}.backup-summary-card div{display:flex;flex-direction:column;gap:7px;min-width:0}.backup-summary-label{font-size:12px;color:#667085;text-transform:uppercase;letter-spacing:.04em}.backup-summary-card strong{font-size:16px;overflow-wrap:anywhere}.backup-summary-card code{font-size:14px}.backup-panel{padding:22px;margin-top:16px;overflow:visible}.backup-panel-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:18px}.backup-panel-heading h2{margin:0 0 6px}.backup-panel-heading p{margin:0}.backup-status-badge,.backup-count{display:inline-flex;align-items:center;justify-content:center;min-width:34px;padding:5px 10px;border-radius:999px;background:#d9f2df;color:#185c2c;font-size:12px;font-weight:700}.backup-content-note{display:flex;align-items:flex-start;gap:10px;padding:14px 16px;border:1px solid #d9e4ec;border-radius:7px;background:#f7fafc}.backup-content-note p{margin:0;color:#53606d;line-height:1.5}.backup-upload-form{display:grid;grid-template-columns:minmax(260px,1fr) auto;gap:12px;align-items:end;padding:16px;border:1px solid #d8e0e7;border-radius:7px;background:#f8fafb}.backup-upload-form input[type=file]{margin:0;width:100%;background:#fff}.backup-table-wrap{border:1px solid #d8e0e7;border-radius:7px}.backup-table{margin:0}.backup-table th{background:#f5f7f9}.backup-actions{display:flex;flex-wrap:wrap;gap:7px;align-items:center}.backup-actions form{display:inline;padding:0;border:0;background:transparent}.backup-dialog{max-width:560px;border:0;border-radius:8px;padding:0;box-shadow:0 16px 50px rgba(0,0,0,.3)}.backup-dialog::backdrop{background:rgba(20,30,40,.45)}.backup-dialog form{padding:24px;border:0}.backup-empty-state{display:flex;flex-direction:column;align-items:center;text-align:center;padding:38px 18px;border:1px dashed #c8d1da;border-radius:8px;background:#fafbfc}.backup-empty-state>span{font-size:34px;margin-bottom:10px}.backup-empty-state p{margin:6px 0 0}@media(max-width:900px){.backup-summary-grid{grid-template-columns:1fr}.backup-page-header{flex-direction:column}.backup-upload-form{grid-template-columns:1fr}.backup-create-form,.backup-create-form button{width:100%}} + + + + +/* v0.4.0.3: one vertical scroll area on management pages */ +.management-table-scroll{ + width:100%; + max-width:100%; + max-height:none; + min-height:0; + overflow-x:auto; + overflow-y:visible; + overscroll-behavior-x:contain; + border:1px solid #d8dde2; + border-radius:6px; + background:#fff; +} + +.management-table-scroll .data-table{ + margin:0; +} + +/* Sticky headers only need to follow the browser scroll on these pages. */ +.management-table-scroll thead tr:first-child th{ + position:sticky; + top:58px; + z-index:4; +} + +.management-table-scroll thead .column-filter-row th{ + position:sticky; + top:99px; + z-index:3; +} + +/* Asset tree category selector */ +.tree-category-filter{ + min-width:0; + padding:0; + border:1px solid #cfd7df; + border-radius:8px; + background:#fff; + overflow:hidden; +} + +.tree-category-filter legend{ + margin-left:12px; + padding:0 8px; + font-weight:700; + color:#253544; +} + +.tree-category-options{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); + gap:8px; + max-height:190px; + padding:14px; + overflow:auto; + scrollbar-gutter:stable; + background:#f8fafc; + border-top:1px solid #e7ebef; + border-bottom:1px solid #e7ebef; +} + +.tree-category-option{ + display:flex; + align-items:center; + gap:9px; + min-width:0; + padding:9px 10px; + border:1px solid #dfe5ea; + border-radius:6px; + background:#fff; + cursor:pointer; + transition:border-color .15s ease, box-shadow .15s ease, background .15s ease; +} + +.tree-category-option:hover{ + border-color:#9eb4c7; + background:#f5f9fc; +} + +.tree-category-option:has(input:focus-visible){ + outline:2px solid currentColor; + outline-offset:2px; +} + +.tree-category-option:has(input:checked){ + border-color:#55758e; + background:#eef5fa; + box-shadow:inset 3px 0 0 #55758e; +} + +.tree-category-option input{ + position:absolute; + opacity:0; + pointer-events:none; +} + +.tree-category-check{ + flex:0 0 18px; + width:18px; + height:18px; + border:1px solid #9aa9b5; + border-radius:4px; + background:#fff; + display:grid; + place-items:center; +} + +.tree-category-option input:checked + .tree-category-check{ + border-color:#55758e; + background:#55758e; +} + +.tree-category-option input:checked + .tree-category-check::after{ + content:""; + width:8px; + height:4px; + border-left:2px solid #fff; + border-bottom:2px solid #fff; + transform:rotate(-45deg) translateY(-1px); +} + +.tree-category-name{ + min-width:0; + font-weight:600; + line-height:1.25; + overflow-wrap:anywhere; +} + +.tree-category-filter > small{ + display:block; + padding:8px 14px 10px; + color:#697884; + background:#fff; +} + +@media(max-width:900px){ + .management-table-scroll thead tr:first-child th, + .management-table-scroll thead .column-filter-row th{ + position:static; + } + + .tree-category-options{ + grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); + } +} + + +/* v0.4.0.4: professional full-width asset-tree filters */ +.tree-filters{ + display:block; + padding:18px; +} + +.tree-filter-controls{ + display:grid; + grid-template-columns:minmax(220px,1fr) minmax(220px,1fr) minmax(280px,1.15fr) auto; + gap:16px; + align-items:end; + margin-bottom:18px; +} + +.tree-filter-field{ + display:flex; + flex-direction:column; + gap:6px; + min-width:0; + font-weight:600; +} + +.tree-filter-field select, +.tree-filter-field input{ + width:100%; +} + +.tree-toggle-option{ + display:flex; + align-items:center; + gap:11px; + min-height:42px; + padding:8px 12px; + border:1px solid #d7dee5; + border-radius:7px; + background:#f8fafc; + cursor:pointer; + font-weight:600; +} + +.tree-toggle-option:hover{ + border-color:#9eb4c7; + background:#f3f7fa; +} + +.tree-toggle-option input{ + position:absolute; + opacity:0; + pointer-events:none; +} + +.tree-toggle-track{ + position:relative; + flex:0 0 38px; + width:38px; + height:22px; + border-radius:999px; + background:#a9b5bf; + transition:background .15s ease; +} + +.tree-toggle-thumb{ + position:absolute; + top:3px; + left:3px; + width:16px; + height:16px; + border-radius:50%; + background:#fff; + box-shadow:0 1px 3px rgba(0,0,0,.28); + transition:transform .15s ease; +} + +.tree-toggle-option input:checked + .tree-toggle-track{ + background:#55758e; +} + +.tree-toggle-option input:checked + .tree-toggle-track .tree-toggle-thumb{ + transform:translateX(16px); +} + +.tree-toggle-option:has(input:focus-visible){ + outline:2px solid currentColor; + outline-offset:2px; +} + +.tree-toggle-text{ + line-height:1.25; +} + +.tree-filter-actions{ + display:flex; + align-items:center; + gap:8px; + min-height:42px; +} + +.tree-category-filter{ + width:100%; + box-sizing:border-box; +} + +.tree-category-options{ + grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); + max-height:220px; +} + +@media(max-width:1150px){ + .tree-filter-controls{ + grid-template-columns:repeat(2,minmax(220px,1fr)); + } + + .tree-filter-actions{ + justify-content:flex-start; + } +} + +@media(max-width:700px){ + .tree-filter-controls{ + grid-template-columns:1fr; + } + + .tree-filter-actions, + .tree-filter-actions .button{ + width:100%; + } + + .tree-filter-actions .button{ + text-align:center; + } + + .tree-category-options{ + grid-template-columns:1fr; + } +} + + +/* v0.4.0.5: one common filter row for every data table */ +.column-filter-row{ + display:table-row !important; + visibility:visible !important; +} + +.column-filter-row[hidden]{ + display:table-row !important; +} + +.column-filter-row th{ + display:table-cell !important; +} + +.column-filter-row input[type="search"]{ + display:block; + width:100%; + min-width:90px; + box-sizing:border-box; +} + +/* Management pages use browser scrolling; avoid clipped sticky rows. */ +.management-table-scroll thead tr:first-child th, +.management-table-scroll thead .column-filter-row th{ + position:static; + top:auto; +} + +/* Asset table keeps its own sticky behavior inside its dedicated scroller. */ +#asset-table-scroll #asset-table thead tr:first-child th{ + position:sticky; + top:0; + z-index:4; +} + +#asset-table-scroll #asset-table thead .column-filter-row th{ + position:sticky; + top:41px; + z-index:3; +} + + +/* v0.4.0.8: clearly visible active table filters */ +.column-filter-row input[type="search"]{ + transition: + background-color .15s ease, + border-color .15s ease, + box-shadow .15s ease; +} + +.column-filter-row th.filter-cell-active{ + background:#fff2c7; +} + +.column-filter-row input[type="search"].filter-input-active{ + background:#fff7d8; + border-color:#cf9b16; + box-shadow: + 0 0 0 2px rgba(207,155,22,.18), + inset 3px 0 0 #cf9b16; + font-weight:600; +} + +.column-filter-row input[type="search"].filter-input-active:focus{ + background:#fff9e5; + border-color:#a87500; + box-shadow: + 0 0 0 3px rgba(207,155,22,.24), + inset 3px 0 0 #a87500; +} + +.table-filter-status{ + display:flex; + align-items:center; + gap:10px; + width:100%; + box-sizing:border-box; + margin:0 0 8px; + padding:8px 10px; + border:1px solid #d8dde2; + border-radius:6px; + background:#f8fafc; + color:#344554; + font-size:13px; +} + +.table-filter-status[hidden]{ + display:none !important; +} + +.table-filter-status.is-active{ + border-color:#d4a735; + background:#fff8df; + color:#604900; +} + +.table-filter-status-icon{ + display:inline-grid; + place-items:center; + flex:0 0 24px; + width:24px; + height:24px; + border-radius:50%; + background:#d4a735; + color:#fff; + font-size:15px; + font-weight:700; +} + +.table-filter-status-label{ + white-space:nowrap; +} + +.table-filter-status-details{ + min-width:0; + color:#6a5a25; +} + +.table-filter-clear{ + margin-left:auto; + padding:5px 9px; + font-size:12px; + white-space:nowrap; +} + +.asset-list-page > .table-filter-status{ + flex:0 0 auto; +} + +@media(max-width:700px){ + .table-filter-status{ + flex-wrap:wrap; + } + + .table-filter-status-details{ + flex:1 1 calc(100% - 44px); + } + + .table-filter-clear{ + width:100%; + margin-left:0; + } +} + + +/* v0.4.0.9: configurable system colors */ +html,body{background:var(--am-page-bg);color:var(--am-text)} +body,main,.app-content{color:var(--am-text)} +.main-header{background:var(--am-header)} +.side-nav{background:var(--am-navigation);border-color:var(--am-border)} +.panel,.auth-card,.tree-card,.backup-card,.settings-section,dialog, +.data-table,.management-table-scroll,.sticky-table,.table-scroll{ + background-color:var(--am-surface); + color:var(--am-text); + border-color:var(--am-border); +} +button,.button{background-color:var(--am-primary)} +button:hover,.button:hover{background-color:var(--am-primary-hover)} +.button-secondary{background:#546675} +.button-danger{background:var(--am-danger)} +.button-save{background:var(--am-primary)} +.button-issue{background:var(--am-success)} +.button-return{background:var(--am-warning)} +input,select,textarea{ + background:var(--am-surface); + color:var(--am-text); + border-color:var(--am-border); +} +th{background:#e7ecf0;color:var(--am-text)} +td,th{border-color:var(--am-border)} +a{color:var(--am-primary)} +.filters a{border-color:var(--am-border);background:var(--am-surface)} +.filters a:hover{border-color:var(--am-primary)} +.notice{color:#594600} +.muted,.table-hint,small{color:color-mix(in srgb,var(--am-text) 67%,transparent)} + +/* Header theme switch */ +.header-theme-form{margin:0;padding:0;border:0;background:transparent} +.header-theme-toggle{ + position:relative; + display:flex; + align-items:center; + justify-content:space-between; + width:48px; + height:27px; + padding:3px 6px; + border:1px solid rgba(255,255,255,.45); + border-radius:999px; + background:rgba(255,255,255,.16); + color:#fff; +} +.header-theme-toggle:hover{background:rgba(255,255,255,.28)} +.header-theme-toggle::after{ + content:""; + position:absolute; + top:3px; + left:3px; + width:19px; + height:19px; + border-radius:50%; + background:#fff; + box-shadow:0 1px 4px rgba(0,0,0,.3); + transition:transform .18s ease; +} +html[data-theme="dark"] .header-theme-toggle::after{transform:translateX(21px)} +.theme-icon-light,.theme-icon-dark{font-size:13px;line-height:1;z-index:1} +.theme-icon-light{color:#d99000}.theme-icon-dark{color:#dce8ff} + +/* Profile theme cards */ +.theme-mode-choice{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:10px 0 16px} +.theme-choice-card{position:relative;display:grid;gap:8px;padding:10px;border:2px solid var(--am-border);border-radius:8px;cursor:pointer} +.theme-choice-card:has(input:checked){border-color:var(--am-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--am-primary) 20%,transparent)} +.theme-choice-card input{position:absolute;opacity:0} +.theme-choice-preview{display:grid;grid-template-columns:28% 1fr;gap:5px;height:70px;padding:6px;border-radius:5px;border:1px solid #adb5bd} +.theme-choice-preview span:first-child{border-radius:3px} +.theme-choice-preview span:last-child{border-radius:3px} +.theme-choice-preview-light{background:#eef1f4}.theme-choice-preview-light span:first-child{background:#fff}.theme-choice-preview-light span:last-child{background:#fff} +.theme-choice-preview-dark{background:#151b23}.theme-choice-preview-dark span:first-child{background:#232d39}.theme-choice-preview-dark span:last-child{background:#202832} + +/* Appearance color editor */ +.settings-section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px} +.theme-color-layout{display:grid;grid-template-columns:minmax(420px,1.25fr) minmax(280px,.75fr);gap:24px;align-items:start} +.theme-color-grid{display:grid;grid-template-columns:repeat(2,minmax(190px,1fr));gap:12px} +.theme-color-field{display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--am-border);border-radius:7px;background:var(--am-surface)} +.theme-color-input{display:grid;grid-template-columns:52px 1fr;gap:9px;align-items:center} +.theme-color-input input[type="color"]{width:52px;height:36px;padding:2px;cursor:pointer} +.theme-color-input code{padding:7px 8px;border:1px solid var(--am-border);border-radius:4px;background:color-mix(in srgb,var(--am-page-bg) 75%,var(--am-surface))} +.theme-live-preview{position:sticky;top:75px;padding:14px;border:1px solid var(--am-border);border-radius:8px;background:var(--am-surface)} +.theme-preview-window{overflow:hidden;height:230px;border:1px solid var(--preview-border,#d7dde3);border-radius:7px;background:var(--preview-page-bg,#f2f4f6)} +.theme-preview-header{height:35px;background:var(--preview-header,#063c66)} +.theme-preview-body{display:grid;grid-template-columns:25% 1fr;height:195px} +.theme-preview-nav{background:var(--preview-navigation,#fff);border-right:1px solid var(--preview-border,#d7dde3)} +.theme-preview-content{padding:16px} +.theme-preview-card{height:95px;border:1px solid var(--preview-border,#d7dde3);border-radius:5px;background:var(--preview-surface,#fff)} +.theme-preview-buttons{display:flex;gap:8px;margin-top:15px} +.theme-preview-buttons i{width:44px;height:20px;border-radius:4px;background:var(--preview-primary,#0878bd)} +.theme-preview-buttons i:nth-child(2){background:var(--preview-success,#198754)} +.theme-preview-buttons i:nth-child(3){background:var(--preview-danger,#b42318)} + +/* Dark mode */ +html[data-theme="dark"]{ + color-scheme:dark; + --am-page-bg:#11161d; + --am-surface:#1c242e; + --am-text:#e7edf3; + --am-border:#3a4755; + --am-navigation:#18212b; +} +html[data-theme="dark"] body, +html[data-theme="dark"] .app-content{background:#11161d;color:#e7edf3} +html[data-theme="dark"] .main-header{background:color-mix(in srgb,var(--am-header) 70%,#101820)} +html[data-theme="dark"] .side-nav, +html[data-theme="dark"] .settings-hover-menu, +html[data-theme="dark"] .user-menu-panel{background:#18212b;color:#e7edf3;border-color:#3a4755} +html[data-theme="dark"] .side-nav a, +html[data-theme="dark"] .user-menu-panel a, +html[data-theme="dark"] .settings-hover-menu a{color:#e7edf3 !important} +html[data-theme="dark"] .side-nav a:hover, +html[data-theme="dark"] .user-menu-panel a:hover, +html[data-theme="dark"] .settings-hover-menu a:hover{background:#273442 !important;color:#fff !important} +html[data-theme="dark"] .panel, +html[data-theme="dark"] .auth-card, +html[data-theme="dark"] .settings-section, +html[data-theme="dark"] dialog, +html[data-theme="dark"] .tree-card, +html[data-theme="dark"] .management-table-scroll, +html[data-theme="dark"] .sticky-table{background:#1c242e;color:#e7edf3;border-color:#3a4755} +html[data-theme="dark"] input, +html[data-theme="dark"] select, +html[data-theme="dark"] textarea{background:#131a22;color:#edf3f8;border-color:#465463} +html[data-theme="dark"] table, +html[data-theme="dark"] td{background:#1c242e;color:#e7edf3;border-color:#3a4755} +html[data-theme="dark"] th{background:#293541;color:#f3f6f9;border-color:#465463} +html[data-theme="dark"] .column-filter-row th{background:#232e39} +html[data-theme="dark"] .column-filter-row th.filter-cell-active{background:#554612} +html[data-theme="dark"] .column-filter-row input.filter-input-active{background:#493d14;color:#fff2b3;border-color:#d4a735} +html[data-theme="dark"] .filters a{background:#1c242e;color:#8dcbf1;border-color:#3a4755} +html[data-theme="dark"] .table-filter-status.is-active{background:#403715;color:#ffeb9e;border-color:#a98b2d} +html[data-theme="dark"] .table-filter-status-details{color:#ddca86} +html[data-theme="dark"] .notice{background:#493d14;color:#fff0ad;border-color:#7a6721} +html[data-theme="dark"] .muted, +html[data-theme="dark"] .table-hint, +html[data-theme="dark"] small{color:#aeb9c5} +html[data-theme="dark"] code{background:#121820;color:#dbe8f2} +html[data-theme="dark"] .tree-category-options{background:#151d25;border-color:#3a4755} +html[data-theme="dark"] .tree-category-option{background:#1c242e;color:#e7edf3;border-color:#3a4755} +html[data-theme="dark"] .tree-category-option:hover{background:#26323e} +html[data-theme="dark"] .tree-category-option:has(input:checked){background:#263a49;border-color:#5f9ac0} +html[data-theme="dark"] .backup-empty-state{background:#151d25;border-color:#465463} +html[data-theme="dark"] .theme-color-field, +html[data-theme="dark"] .theme-live-preview{background:#18212b} + +@media(max-width:900px){ + .theme-color-layout{grid-template-columns:1fr} + .theme-live-preview{position:static} +} +@media(max-width:600px){ + .theme-color-grid,.theme-mode-choice{grid-template-columns:1fr} + .settings-section-heading{flex-direction:column} +} + + +/* v0.4.0.10: Dark-mode cleanup and configurable borders/surfaces */ + +/* Settings sections and forms must use configured surface/border colors. */ +.settings-form, +.settings-section, +.settings-subsection, +.auth-card, +.profile-panel, +.panel{ + background:var(--am-surface); + color:var(--am-text); + border-color:var(--am-border); +} + +.settings-form{ + border-color:transparent; +} + +.settings-section{ + box-shadow:none; +} + +/* Language and user dropdowns */ +.language-menu .user-menu-panel, +.user-menu .user-menu-panel{ + background:var(--am-surface); + color:var(--am-text); + border-color:var(--am-border); +} + +.language-menu .menu-button, +.user-menu .menu-button{ + width:100%; + background:transparent; + color:var(--am-text); + border:0; + text-align:left; +} + +.language-menu .menu-button:hover, +.user-menu .menu-button:hover, +.language-menu .menu-button.active, +.user-menu .menu-button.active{ + background:color-mix(in srgb,var(--am-primary) 18%,var(--am-surface)); + color:var(--am-text); +} + +html[data-theme="dark"] .language-menu .user-menu-panel, +html[data-theme="dark"] .user-menu .user-menu-panel{ + background:#18212b; + color:#e7edf3; + border-color:#3a4755; +} + +html[data-theme="dark"] .language-menu .menu-button, +html[data-theme="dark"] .user-menu .menu-button{ + background:transparent; + color:#e7edf3; +} + +html[data-theme="dark"] .language-menu .menu-button:hover, +html[data-theme="dark"] .user-menu .menu-button:hover, +html[data-theme="dark"] .language-menu .menu-button.active, +html[data-theme="dark"] .user-menu .menu-button.active{ + background:#273442; + color:#ffffff; +} + +/* Asset-tree category text and switch */ +.tree-category-filter, +.tree-category-options, +.tree-category-option, +.tree-toggle-option{ + color:var(--am-text); + border-color:var(--am-border); +} + +.tree-category-filter{ + background:var(--am-surface); +} + +.tree-category-filter legend, +.tree-category-name, +.tree-toggle-text{ + color:var(--am-text); +} + +.tree-category-options{ + background:color-mix(in srgb,var(--am-page-bg) 72%,var(--am-surface)); +} + +.tree-category-option, +.tree-toggle-option{ + background:var(--am-surface); +} + +.tree-category-option:hover, +.tree-toggle-option:hover{ + background:color-mix(in srgb,var(--am-primary) 8%,var(--am-surface)); +} + +.tree-category-option:has(input:checked){ + background:color-mix(in srgb,var(--am-primary) 14%,var(--am-surface)); + border-color:var(--am-primary); +} + +html[data-theme="dark"] .tree-category-filter{ + background:#1c242e; + border-color:#3a4755; +} + +html[data-theme="dark"] .tree-category-filter legend, +html[data-theme="dark"] .tree-category-name, +html[data-theme="dark"] .tree-toggle-text{ + color:#e7edf3; +} + +html[data-theme="dark"] .tree-category-options{ + background:#151d25; + border-color:#3a4755; +} + +html[data-theme="dark"] .tree-category-option, +html[data-theme="dark"] .tree-toggle-option{ + background:#1c242e; + color:#e7edf3; + border-color:#3a4755; +} + +html[data-theme="dark"] .tree-category-option:hover, +html[data-theme="dark"] .tree-toggle-option:hover{ + background:#26323e; +} + +html[data-theme="dark"] .tree-category-option:has(input:checked){ + background:#263a49; + border-color:#5f9ac0; +} + +/* Prevent light remnants from native-looking fieldsets/cards */ +fieldset, +legend{ + color:var(--am-text); + border-color:var(--am-border); +} + +html[data-theme="dark"] fieldset{ + background:transparent; + border-color:#3a4755; +} + +html[data-theme="dark"] legend{ + color:#e7edf3; +} + + +/* v0.4.0.11: comprehensive dark-mode surfaces */ + +/* General application surfaces */ +html[data-theme="dark"] form:not(.inline-form):not(.header-theme-form):not(.backup-create-form){ + background:#1c242e; + color:#e7edf3; + border-color:#3a4755; +} + +html[data-theme="dark"] .compact-form, +html[data-theme="dark"] .user-permission-form, +html[data-theme="dark"] .translation-filter-form, +html[data-theme="dark"] .backup-upload-form, +html[data-theme="dark"] .chart-form, +html[data-theme="dark"] .settings-form{ + background:#1c242e !important; + color:#e7edf3; + border-color:#3a4755 !important; +} + +html[data-theme="dark"] .card, +html[data-theme="dark"] .cards .card, +html[data-theme="dark"] .sync-summary-card, +html[data-theme="dark"] .backup-summary-card, +html[data-theme="dark"] .backup-content-note, +html[data-theme="dark"] .backup-upload-form, +html[data-theme="dark"] .backup-table-wrap, +html[data-theme="dark"] .translation-filter-panel, +html[data-theme="dark"] .translation-selection-actions, +html[data-theme="dark"] .form-card, +html[data-theme="dark"] .chart-card, +html[data-theme="dark"] .detail, +html[data-theme="dark"] .config-list, +html[data-theme="dark"] .logo-preview, +html[data-theme="dark"] .settings-subsection{ + background:#1c242e !important; + color:#e7edf3 !important; + border-color:#3a4755 !important; +} + +html[data-theme="dark"] .card strong, +html[data-theme="dark"] .card small, +html[data-theme="dark"] .backup-summary-label, +html[data-theme="dark"] .backup-summary-card strong, +html[data-theme="dark"] .backup-content-note p{ + color:#e7edf3 !important; +} + +/* Forms, controls and browser-native file inputs */ +html[data-theme="dark"] input, +html[data-theme="dark"] select, +html[data-theme="dark"] textarea{ + background:#131a22 !important; + color:#edf3f8 !important; + border-color:#465463 !important; +} + +html[data-theme="dark"] input::placeholder, +html[data-theme="dark"] textarea::placeholder{ + color:#93a1ae !important; + opacity:1; +} + +html[data-theme="dark"] input[type="file"]{ + background:#131a22 !important; + color:#dfe7ee !important; +} + +html[data-theme="dark"] input[type="file"]::file-selector-button{ + background:#526678; + color:#fff; + border:0; + border-radius:4px; + padding:6px 10px; + margin-right:10px; + cursor:pointer; +} + +html[data-theme="dark"] input[type="checkbox"], +html[data-theme="dark"] input[type="radio"]{ + accent-color:var(--am-primary); +} + +/* MeshCentral synchronization */ +html[data-theme="dark"] .sync-status, +html[data-theme="dark"] .status-success, +html[data-theme="dark"] .status-synced, +html[data-theme="dark"] .status-created, +html[data-theme="dark"] .status-updated{ + background:#244631; + color:#bce8c9; +} + +html[data-theme="dark"] .status-conflict, +html[data-theme="dark"] .status-partial{ + background:#4d411b; + color:#ffe68d; +} + +html[data-theme="dark"] .status-error, +html[data-theme="dark"] .status-missing{ + background:#552828; + color:#ffc2c2; +} + +html[data-theme="dark"] .log-box, +html[data-theme="dark"] .sync-log, +html[data-theme="dark"] pre{ + background:#101722 !important; + color:#dce8f4 !important; + border-color:#3a4755 !important; +} + +/* Backup page */ +html[data-theme="dark"] .backup-summary-card{ + box-shadow:none; +} + +html[data-theme="dark"] .backup-summary-icon{ + background:#283544; + color:#d9e7f2; +} + +html[data-theme="dark"] .backup-status-badge, +html[data-theme="dark"] .backup-count{ + background:#244631; + color:#c5efd0; +} + +html[data-theme="dark"] .backup-table th{ + background:#293541; + color:#f2f6f9; +} + +html[data-theme="dark"] .backup-empty-state{ + background:#151d25 !important; + color:#dbe5ed; + border-color:#465463; +} + +/* Categories and status values */ +html[data-theme="dark"] .category-attributes .attribute-badge, +html[data-theme="dark"] .category-attributes span{ + background:#253443; + color:#dbeaf4; + border-color:#486071; +} + +html[data-theme="dark"] .category-attributes span:hover{ + background:#304456; +} + +html[data-theme="dark"] .categories-table form, +html[data-theme="dark"] .data-table form{ + background:transparent !important; + border:0 !important; + color:inherit; +} + +html[data-theme="dark"] .data-table input, +html[data-theme="dark"] .data-table select, +html[data-theme="dark"] .data-table textarea{ + background:#131a22 !important; + color:#edf3f8 !important; + border-color:#465463 !important; +} + +/* Translations */ +html[data-theme="dark"] .translation-filter-panel, +html[data-theme="dark"] .translation-filter-form, +html[data-theme="dark"] .translation-selection-actions{ + background:#1c242e !important; +} + +html[data-theme="dark"] .translation-filter-checkbox, +html[data-theme="dark"] .translation-filter-checkbox span, +html[data-theme="dark"] .translation-filter-checkbox label{ + color:#e7edf3 !important; +} + +html[data-theme="dark"] .translations-table textarea, +html[data-theme="dark"] .table-wrap textarea{ + background:#131a22 !important; + color:#edf3f8 !important; +} + +/* User management */ +html[data-theme="dark"] .user-permission-form, +html[data-theme="dark"] .user-permission-form fieldset, +html[data-theme="dark"] .group-checkboxes, +html[data-theme="dark"] .checkbox-row{ + background:transparent !important; + color:#e7edf3 !important; + border-color:#465463 !important; +} + +html[data-theme="dark"] .user-permission-form label, +html[data-theme="dark"] .group-checkboxes label, +html[data-theme="dark"] .checkbox-row label, +html[data-theme="dark"] .checkbox-label{ + color:#e7edf3 !important; +} + +html[data-theme="dark"] .user-inline-form{ + background:transparent !important; +} + +/* Remove remaining bright strips and native white surfaces */ +html[data-theme="dark"] .table-scroll, +html[data-theme="dark"] .table-wrap, +html[data-theme="dark"] .sticky-table, +html[data-theme="dark"] .management-table-scroll{ + background:#1c242e !important; + border-color:#3a4755 !important; +} + +html[data-theme="dark"] hr{ + border-color:#3a4755; +} + +html[data-theme="dark"] .logo-preview img{ + background:#151d25; + border-color:#465463; +} + +/* Explicitly keep action forms transparent */ +html[data-theme="dark"] .inline-form, +html[data-theme="dark"] .backup-actions form, +html[data-theme="dark"] .row-actions form, +html[data-theme="dark"] .hidden-utility-form, +html[data-theme="dark"] .header-theme-form{ + background:transparent !important; + border:0 !important; +} + + +/* v0.4.0.12: loading overlays, dialogs and progress indicators */ + +/* Asset-page loading overlay */ +html.asset-page-booting .app-content{ + position:relative; +} + +html.asset-page-booting .asset-page-loader{ + position:fixed; + inset:58px 0 0 58px; + z-index:4500; + min-height:0; + padding:24px; + background:rgba(242,244,246,.82); + backdrop-filter:blur(2px); + -webkit-backdrop-filter:blur(2px); +} + +html.sidebar-collapsed-preset.asset-page-booting .asset-page-loader{ + left:58px; +} + +.asset-page-loader-card{ + color:var(--am-text); + background:var(--am-surface); + border-color:var(--am-border); + box-shadow:0 16px 42px rgba(0,0,0,.22); +} + +.asset-page-loader-card strong{ + color:var(--am-text); +} + +.asset-page-loader-card .muted{ + color:color-mix(in srgb,var(--am-text) 68%,transparent); +} + +.asset-page-spinner{ + color:var(--am-primary); + border-color:color-mix(in srgb,var(--am-border) 78%,transparent); + border-top-color:var(--am-primary); +} + +/* Generic native dialogs and modal overlays */ +dialog{ + color:var(--am-text); + background:var(--am-surface); + border:1px solid var(--am-border); +} + +dialog::backdrop{ + background:rgba(18,26,34,.62); + backdrop-filter:blur(2px); + -webkit-backdrop-filter:blur(2px); +} + +dialog form{ + color:var(--am-text); + background:transparent; +} + +/* HTML progress elements */ +progress{ + width:100%; + height:12px; + overflow:hidden; + border:0; + border-radius:999px; + background:color-mix(in srgb,var(--am-border) 60%,var(--am-surface)); + color:var(--am-primary); +} + +progress::-webkit-progress-bar{ + background:color-mix(in srgb,var(--am-border) 60%,var(--am-surface)); + border-radius:999px; +} + +progress::-webkit-progress-value{ + background:var(--am-primary); + border-radius:999px; +} + +progress::-moz-progress-bar{ + background:var(--am-primary); + border-radius:999px; +} + +/* Common custom progress bars */ +.progress, +.progress-track, +.loading-progress{ + background:color-mix(in srgb,var(--am-border) 60%,var(--am-surface)); + border-color:var(--am-border); +} + +.progress-bar, +.progress-value, +.loading-progress-bar{ + background:var(--am-primary); +} + +/* Dark mode */ +html[data-theme="dark"].asset-page-booting .asset-page-loader{ + background:rgba(8,12,17,.84); +} + +html[data-theme="dark"] .asset-page-loader-card{ + background:#1c242e; + color:#e7edf3; + border-color:#465463; + box-shadow:0 18px 50px rgba(0,0,0,.55); +} + +html[data-theme="dark"] .asset-page-loader-card strong{ + color:#f3f7fa; +} + +html[data-theme="dark"] .asset-page-loader-card .muted{ + color:#aeb9c5; +} + +html[data-theme="dark"] .asset-page-spinner{ + color:var(--am-primary); + border-color:#465463; + border-top-color:var(--am-primary); +} + +html[data-theme="dark"] dialog{ + background:#1c242e; + color:#e7edf3; + border-color:#465463; + box-shadow:0 20px 60px rgba(0,0,0,.62); +} + +html[data-theme="dark"] dialog::backdrop{ + background:rgba(4,7,10,.76); +} + +html[data-theme="dark"] progress, +html[data-theme="dark"] progress::-webkit-progress-bar, +html[data-theme="dark"] .progress, +html[data-theme="dark"] .progress-track, +html[data-theme="dark"] .loading-progress{ + background:#2a3541; +} + +@media(max-width:900px){ + html.asset-page-booting .asset-page-loader{ + left:0; + inset:58px 0 0 0; + } +} + + +/* v0.4.0.13: system-wide themed scrollbars */ + +/* Firefox */ +*{ + scrollbar-width:thin; + scrollbar-color: + color-mix(in srgb,var(--am-text) 34%,var(--am-border)) + color-mix(in srgb,var(--am-surface) 82%,var(--am-page-bg)); +} + +/* Chromium, Edge and Safari */ +*::-webkit-scrollbar{ + width:12px; + height:12px; +} + +*::-webkit-scrollbar-track{ + background:color-mix(in srgb,var(--am-surface) 82%,var(--am-page-bg)); + border-radius:999px; +} + +*::-webkit-scrollbar-thumb{ + background:color-mix(in srgb,var(--am-text) 34%,var(--am-border)); + border:3px solid color-mix(in srgb,var(--am-surface) 82%,var(--am-page-bg)); + border-radius:999px; + min-height:28px; +} + +*::-webkit-scrollbar-thumb:hover{ + background:color-mix(in srgb,var(--am-primary) 65%,var(--am-text)); +} + +*::-webkit-scrollbar-thumb:active{ + background:var(--am-primary); +} + +*::-webkit-scrollbar-corner{ + background:color-mix(in srgb,var(--am-surface) 82%,var(--am-page-bg)); +} + +/* Main scrollable application areas */ +#asset-table-scroll, +#asset-table-scroll-top, +.management-table-scroll, +.sticky-table, +.table-scroll, +.table-wrap, +.tree-scroll, +.tree-category-options, +.log-box, +.sync-log, +pre, +textarea, +dialog, +.user-menu-panel, +.settings-hover-menu{ + scrollbar-color: + color-mix(in srgb,var(--am-text) 34%,var(--am-border)) + color-mix(in srgb,var(--am-surface) 82%,var(--am-page-bg)); +} + +/* Top horizontal scrollbar on the asset table */ +#asset-table-scroll-top{ + background:var(--am-surface); + border-color:var(--am-border); +} + +#asset-table-scroll-top > div{ + background:transparent; +} + +/* Dark mode */ +html[data-theme="dark"] *{ + scrollbar-color:#667789 #18212b; +} + +html[data-theme="dark"] *::-webkit-scrollbar-track{ + background:#18212b; +} + +html[data-theme="dark"] *::-webkit-scrollbar-thumb{ + background:#667789; + border-color:#18212b; +} + +html[data-theme="dark"] *::-webkit-scrollbar-thumb:hover{ + background:#7f94a8; +} + +html[data-theme="dark"] *::-webkit-scrollbar-thumb:active{ + background:var(--am-primary); +} + +html[data-theme="dark"] *::-webkit-scrollbar-corner{ + background:#18212b; +} + +html[data-theme="dark"] #asset-table-scroll-top{ + background:#1c242e; + border-color:#3a4755; +} + +/* Stronger contrast for large table/tree scroll containers */ +html[data-theme="dark"] #asset-table-scroll::-webkit-scrollbar-thumb, +html[data-theme="dark"] #asset-table-scroll-top::-webkit-scrollbar-thumb, +html[data-theme="dark"] .management-table-scroll::-webkit-scrollbar-thumb, +html[data-theme="dark"] .tree-scroll::-webkit-scrollbar-thumb, +html[data-theme="dark"] .tree-category-options::-webkit-scrollbar-thumb{ + background:#718398; +} + +html[data-theme="dark"] #asset-table-scroll::-webkit-scrollbar-thumb:hover, +html[data-theme="dark"] #asset-table-scroll-top::-webkit-scrollbar-thumb:hover, +html[data-theme="dark"] .management-table-scroll::-webkit-scrollbar-thumb:hover, +html[data-theme="dark"] .tree-scroll::-webkit-scrollbar-thumb:hover, +html[data-theme="dark"] .tree-category-options::-webkit-scrollbar-thumb:hover{ + background:#8ba0b5; +} + +/* Light mode contrast */ +html[data-theme="light"] *{ + scrollbar-color:#8799a8 #e8edf1; +} + +html[data-theme="light"] *::-webkit-scrollbar-track{ + background:#e8edf1; +} + +html[data-theme="light"] *::-webkit-scrollbar-thumb{ + background:#8799a8; + border-color:#e8edf1; +} + +html[data-theme="light"] *::-webkit-scrollbar-thumb:hover{ + background:#6f8292; +} + +/* v0.5.0.0 job states */ +.job-status{display:inline-flex;align-items:center;min-height:24px;padding:2px 9px;border:1px solid var(--am-border);border-radius:999px;background:color-mix(in srgb,var(--am-surface) 80%,var(--am-page-bg));color:var(--am-text);font-size:.82rem;font-weight:700;white-space:nowrap} +.job-status-success{background:color-mix(in srgb,var(--am-success) 18%,var(--am-surface))} +.job-status-failed,.job-status-timeout,.job-status-cancelled{background:color-mix(in srgb,var(--am-danger) 18%,var(--am-surface))} +.job-status-partial{background:color-mix(in srgb,var(--am-warning) 18%,var(--am-surface))} +.job-status-created,.job-status-sending,.job-status-sent,.job-status-running{background:color-mix(in srgb,var(--am-primary) 16%,var(--am-surface))} +html[data-theme="dark"] .job-status-success{background:#244631;color:#bce8c9}html[data-theme="dark"] .job-status-failed,html[data-theme="dark"] .job-status-timeout,html[data-theme="dark"] .job-status-cancelled{background:#552828;color:#ffc2c2}html[data-theme="dark"] .job-status-partial{background:#4d411b;color:#ffe68d}html[data-theme="dark"] .job-status-created,html[data-theme="dark"] .job-status-sending,html[data-theme="dark"] .job-status-sent,html[data-theme="dark"] .job-status-running{background:#203d55;color:#b9dcf5} + + +/* v0.5.0.1: full-width backup management */ +.backup-page{ + width:100%; + max-width:none; +} +.backup-table-wrap{ + width:100%; + max-width:100%; + overflow-x:auto; + overflow-y:visible; +} +.backup-table{ + width:100%; + min-width:980px; +} + +/* v0.5.0.1: software settings */ +.software-settings-page{ + width:100%; + max-width:none; +} +.software-settings-header{ + align-items:flex-start; +} +.software-settings-header h1{ + margin-bottom:6px; +} +.software-settings-form{ + padding:0; + border:0; + background:transparent; +} +.software-settings-section{ + margin-bottom:18px; +} +.software-settings-section-heading{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:18px; + margin-bottom:18px; +} +.software-settings-section-heading h2{ + margin:0 0 6px; +} +.software-settings-section-heading p{ + margin:0; +} +.software-settings-badge{ + display:inline-flex; + align-items:center; + padding:5px 10px; + border-radius:999px; + background:color-mix(in srgb,var(--am-primary) 16%,var(--am-surface)); + border:1px solid color-mix(in srgb,var(--am-primary) 38%,var(--am-border)); + font-size:.78rem; + font-weight:700; + white-space:nowrap; +} +.software-settings-grid{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:16px; +} +.software-settings-wide{ + grid-column:1/-1; +} +.software-settings-grid label{ + min-width:0; +} +.software-settings-grid label > small{ + display:block; + margin-top:6px; +} +.software-toggle-option{ + display:flex; + align-items:center; + gap:12px; + min-height:74px; + padding:12px 14px; + border:1px solid var(--am-border); + border-radius:8px; + background:color-mix(in srgb,var(--am-surface) 88%,var(--am-page-bg)); + cursor:pointer; +} +.software-toggle-option > input{ + position:absolute; + opacity:0; + pointer-events:none; +} +.software-toggle-track{ + position:relative; + flex:0 0 40px; + width:40px; + height:23px; + border-radius:999px; + background:color-mix(in srgb,var(--am-text) 35%,var(--am-surface)); +} +.software-toggle-thumb{ + position:absolute; + top:3px; + left:3px; + width:17px; + height:17px; + border-radius:50%; + background:var(--am-surface); + box-shadow:0 1px 3px rgba(0,0,0,.35); + transition:transform .15s ease; +} +.software-toggle-option input:checked + .software-toggle-track{ + background:var(--am-primary); +} +.software-toggle-option input:checked + .software-toggle-track .software-toggle-thumb{ + transform:translateX(17px); +} +.software-toggle-option input:focus-visible + .software-toggle-track{ + outline:2px solid var(--am-primary); + outline-offset:3px; +} +.software-toggle-option > span:last-child{ + display:flex; + flex-direction:column; + gap:5px; +} +.software-callback-preview, +.software-debug-path{ + display:flex; + flex-direction:column; + gap:8px; + padding:14px 16px; + border:1px solid var(--am-border); + border-radius:8px; + background:color-mix(in srgb,var(--am-surface) 82%,var(--am-page-bg)); +} +.software-callback-preview code, +.software-debug-path code{ + overflow-wrap:anywhere; +} +.software-settings-actions{ + margin:0 0 20px; +} +.software-debug-toolbar{ + display:flex; + align-items:center; + justify-content:space-between; + gap:16px; + margin-bottom:10px; +} +.software-debug-log{ + min-height:280px; + max-height:520px; + margin:0; + overflow:auto; + white-space:pre-wrap; + overflow-wrap:anywhere; +} +.software-future-grid{ + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:10px; + margin-top:14px; +} +.software-future-grid span{ + padding:12px 14px; + border:1px dashed var(--am-border); + border-radius:7px; + background:color-mix(in srgb,var(--am-surface) 86%,var(--am-page-bg)); +} +html[data-theme="dark"] .software-toggle-thumb{ + background:#eef2f6; +} +@media(max-width:900px){ + .software-settings-grid, + .software-future-grid{ + grid-template-columns:1fr; + } + .software-settings-wide{ + grid-column:auto; + } + .software-settings-section-heading, + .software-debug-toolbar{ + flex-direction:column; + } +} + +/* v0.5.0.3 log controls */ +.software-log-heading, +.software-debug-actions{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; +} +.software-log-heading h2, +.software-log-heading h3{ + margin:0; +} +.software-log-heading{ + margin-bottom:10px; +} +.software-debug-actions{ + justify-content:flex-end; +} +@media(max-width:700px){ + .software-log-heading{ + align-items:flex-start; + flex-direction:column; + } +} + +/* v0.5.0.4 software inventory initialization */ +.software-inventory-heading{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:16px; + margin-bottom:16px; +} +.software-inventory-heading h2{ + margin:0 0 5px; +} +.software-inventory-heading p{ + margin:0; +} +.software-inventory-count{ + display:inline-flex; + align-items:center; + justify-content:center; + min-width:42px; + height:34px; + padding:0 11px; + border-radius:999px; + border:1px solid color-mix(in srgb,var(--am-primary) 42%,var(--am-border)); + background:color-mix(in srgb,var(--am-primary) 16%,var(--am-surface)); + font-weight:700; +} +.software-empty-inventory{ + display:flex; + flex-direction:column; + align-items:center; + gap:7px; + padding:28px 16px; + text-align:center; +} +.software-exclusion-preview{ + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:10px; + margin-bottom:12px; +} +.software-exclusion-preview span{ + padding:12px 14px; + border:1px dashed var(--am-border); + border-radius:7px; + background:color-mix(in srgb,var(--am-surface) 86%,var(--am-page-bg)); +} +@media(max-width:800px){ + .software-exclusion-preview{ + grid-template-columns:1fr; + } +} + +/* v0.5.0.5 central and asset software lists */ +.asset-software-toolbar, +.software-section-heading{ + display:flex; + align-items:flex-start; + justify-content:space-between; + gap:16px; + margin-bottom:16px; +} +.asset-software-toolbar h3, +.software-section-heading h2{ + margin:0 0 5px; +} +.asset-software-toolbar p, +.software-section-heading p{ + margin:0; +} +.software-inventory-meta{ + display:flex; + flex-wrap:wrap; + gap:12px 24px; + margin:0 0 16px; + padding:12px 14px; + border:1px solid var(--am-border); + border-radius:7px; + background:color-mix(in srgb,var(--am-surface) 86%,var(--am-page-bg)); +} +.software-summary-cards{ + display:grid; + grid-template-columns:repeat(3,minmax(0,1fr)); + gap:14px; + margin-bottom:18px; +} +.software-summary-card{ + display:flex; + flex-direction:column; + gap:7px; + padding:17px 18px; + border:1px solid var(--am-border); + border-radius:8px; + background:var(--am-surface); +} +.software-summary-card span{ + color:color-mix(in srgb,var(--am-text) 68%,transparent); +} +.software-summary-card strong{ + font-size:1.65rem; +} +@media(max-width:800px){ + .asset-software-toolbar, + .software-section-heading{ + flex-direction:column; + } + .software-summary-cards{ + grid-template-columns:1fr; + } +} + + +/* v0.5.0.6: dedicated software pages */ +.software-summary-link{ + color:inherit; + text-decoration:none; + transition:border-color .15s ease, transform .15s ease, box-shadow .15s ease; +} +.software-summary-link:hover{ + color:inherit; + border-color:color-mix(in srgb,var(--am-primary) 50%,var(--am-border)); + transform:translateY(-1px); + box-shadow:0 4px 14px rgba(0,0,0,.08); +} +.software-summary-link small{ + color:color-mix(in srgb,var(--am-text) 65%,transparent); +} +.software-list-links{ + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:14px; +} +.software-list-link{ + display:grid; + grid-template-columns:1fr auto; + gap:7px 16px; + align-items:center; + padding:16px; + border:1px solid var(--am-border); + border-radius:8px; + background:color-mix(in srgb,var(--am-surface) 88%,var(--am-page-bg)); + color:var(--am-text); + text-decoration:none; +} +.software-list-link strong, +.software-list-link > span:not(.button){ + grid-column:1; +} +.software-list-link .button{ + grid-column:2; + grid-row:1/3; +} +.software-dedicated-table{ + max-height:calc(100vh - 230px); + overflow:auto; +} +@media(max-width:800px){ + .software-list-links{ + grid-template-columns:1fr; + } +} + +/* v0.5.0.6: dark mode inventory text */ +html[data-theme="dark"] .inventory-panel, +html[data-theme="dark"] .inventory-panel h2, +html[data-theme="dark"] .inventory-panel h3, +html[data-theme="dark"] .inventory-header, +html[data-theme="dark"] .inventory-meta, +html[data-theme="dark"] .inventory-tab-content, +html[data-theme="dark"] .asset-software-toolbar, +html[data-theme="dark"] .software-inventory-meta{ + color:var(--am-text) !important; +} +html[data-theme="dark"] .inventory-tab{ + color:#d7e0e8 !important; + background:transparent; +} +html[data-theme="dark"] .inventory-tab:hover{ + color:#ffffff !important; + background:rgba(255,255,255,.06); +} +html[data-theme="dark"] .inventory-tab.active{ + color:#ffffff !important; + background:color-mix(in srgb,var(--am-primary) 24%,transparent); + border-bottom-color:var(--am-primary); +} +html[data-theme="dark"] .inventory-tab-content .empty-state{ + color:#cbd5df; +} +html[data-theme="dark"] .inventory-tab-content .empty-state strong{ + color:#f2f5f8; +} + +/* v0.5.0.6: dark mode toasts */ +html[data-theme="dark"] .toast{ + color:#edf2f7; + background:#202a34; + border-color:#465462; + box-shadow:0 .55rem 1.6rem rgba(0,0,0,.48); +} +html[data-theme="dark"] .toast-message{ + color:#edf2f7; +} +html[data-theme="dark"] .toast-close{ + color:#dbe4ec; +} +html[data-theme="dark"] .toast-close:hover{ + color:#ffffff; + background:rgba(255,255,255,.08); +} +html[data-theme="dark"] .toast-success{ + background:#20382b; + border-left-color:#58c77b; +} +html[data-theme="dark"] .toast-warning{ + background:#453818; + border-left-color:#e7b94e; +} +html[data-theme="dark"] .toast-error{ + background:#482626; + border-left-color:#ef7373; +} +html[data-theme="dark"] .toast-success .toast-symbol{color:#8be5a5} +html[data-theme="dark"] .toast-warning .toast-symbol{color:#f4d37d} +html[data-theme="dark"] .toast-error .toast-symbol{color:#ff9b9b} + + +/* v0.5.0.7 MeshCentral presence */ +.presence-badge{ + display:inline-flex; + align-items:center; + gap:7px; + min-height:27px; + padding:3px 9px; + border:1px solid var(--am-border); + border-radius:999px; + background:color-mix(in srgb,var(--am-surface) 86%,var(--am-page-bg)); + color:var(--am-text); + font-size:.78rem; + font-weight:700; + white-space:nowrap; +} +.presence-dot{ + width:9px; + height:9px; + flex:0 0 9px; + border-radius:50%; + background:#8b98a5; + box-shadow:0 0 0 3px rgba(139,152,165,.15); +} +.presence-online{ + border-color:color-mix(in srgb,var(--am-success) 50%,var(--am-border)); + background:color-mix(in srgb,var(--am-success) 15%,var(--am-surface)); +} +.presence-online .presence-dot{ + background:#23a455; + box-shadow:0 0 0 3px rgba(35,164,85,.18); +} +.presence-offline{ + border-color:color-mix(in srgb,var(--am-danger) 42%,var(--am-border)); + background:color-mix(in srgb,var(--am-danger) 11%,var(--am-surface)); +} +.presence-offline .presence-dot{ + background:#c43d3d; + box-shadow:0 0 0 3px rgba(196,61,61,.16); +} +.presence-unknown .presence-dot{ + background:#8b98a5; +} +html[data-theme="dark"] .presence-online{ + background:#203d2b; + color:#bce8c9; +} +html[data-theme="dark"] .presence-offline{ + background:#482727; + color:#ffc2c2; +} +html[data-theme="dark"] .presence-unknown{ + background:#2b3540; + color:#d4dde5; +} + + +/* v0.5.0.8 column management dark mode */ +html[data-theme="dark"] .column-context-menu{ + background:var(--am-surface); + color:var(--am-text); + border-color:var(--am-border); + box-shadow:0 12px 30px rgba(0,0,0,.45); +} +html[data-theme="dark"] .column-context-menu button{ + background:transparent; + color:var(--am-text); + border-color:transparent; +} +html[data-theme="dark"] .column-context-menu button:hover, +html[data-theme="dark"] .column-context-menu button:focus-visible{ + background:var(--am-hover); + color:var(--am-link); +} +html[data-theme="dark"] .column-settings-dialog, +html[data-theme="dark"] .column-settings-dialog form{ + background:var(--am-surface); + color:var(--am-text); +} +html[data-theme="dark"] .column-settings-list label{ + background:var(--am-surface-2,var(--am-page-bg)); + color:var(--am-text); + border-color:var(--am-border); +} +html[data-theme="dark"] .column-settings-list label:hover{ + background:var(--am-hover); +} + + +/* v0.5.0.9 bulk job selector */ +.bulk-job-controls{ + display:inline-flex; + align-items:stretch; + gap:6px; +} +.bulk-job-type{ + min-width:190px; + min-height:38px; + padding:7px 34px 7px 10px; + border:1px solid var(--am-border); + border-radius:6px; + background:var(--am-surface); + color:var(--am-text); + font:inherit; +} +.bulk-job-type:disabled{ + opacity:.55; + cursor:not-allowed; +} +html[data-theme="dark"] .bulk-job-type{ + background:var(--am-surface); + color:var(--am-text); + border-color:var(--am-border); + color-scheme:dark; +} +html[data-theme="dark"] .bulk-job-type option{ + background:var(--am-surface); + color:var(--am-text); +} +@media (max-width:900px){ + .bulk-job-controls{ + width:100%; + } + .bulk-job-type{ + flex:1 1 auto; + min-width:0; + } +} + + +/* v0.5.1.0 general job model */ +.job-data-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px} +.job-data-grid>div{min-width:0} +.job-data-grid .sync-log{min-height:110px;max-height:340px} +@media(max-width:800px){.job-data-grid{grid-template-columns:1fr}} + + +/* v0.5.1.1 browser-local timestamps */ +time.local-time{font:inherit;color:inherit;white-space:nowrap;} + +/* v0.5.1.2 job restart controls */ +.button-small{min-height:30px;padding:4px 9px;font-size:.9rem;white-space:nowrap} + +/* v0.5.1.3 software inventory selection */ +.software-delete-toolbar{display:flex;align-items:center;justify-content:flex-end;gap:12px;margin:0 0 12px;flex-wrap:wrap}.software-delete-toolbar .button-danger{background:#b42318;color:#fff;border-color:#b42318}.software-delete-toolbar .button-danger:disabled{opacity:.5;cursor:not-allowed}html[data-theme="dark"] .software-delete-toolbar .button-danger{background:#d92d20;border-color:#d92d20;color:#fff} + +/* Category field bulk management (v0.5.1.7) */ +.wide-dialog { width: min(920px, calc(100vw - 2rem)); max-height: calc(100vh - 2rem); } +.dialog-tabs { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; } +.dialog-tabs .is-active { font-weight: 700; outline: 2px solid currentColor; } +.field-choice-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: .45rem 1rem; max-height: 45vh; overflow: auto; padding: .5rem; border: 1px solid var(--border-color, #ccc); border-radius: .4rem; } +.settings-bulk-grid { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(180px, 1fr); gap: .75rem 1rem; margin: 1rem 0; } +@media (max-width: 650px) { .settings-bulk-grid { grid-template-columns: 1fr; } } + +.job-definition-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.job-definition-form label{display:flex;flex-direction:column;gap:.4rem}.job-definition-form .form-wide{grid-column:1/-1}.code-editor{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;line-height:1.45;tab-size:4;min-height:28rem}.checkbox-label{flex-direction:row!important;align-items:center}@media(max-width:800px){.job-definition-form{grid-template-columns:1fr}.job-definition-form .form-wide{grid-column:auto}} + +/* v0.5.3.3: readable category initials in dark mode */ +html[data-theme="dark"] .cards .card .placeholder{ + background:#e7f0f6; + color:#173b55; + text-shadow:none; +} + +.job-enabled-top{justify-content:flex-start!important;width:auto!important;align-self:start}.job-enabled-top input{width:auto!important;margin:0}.job-parameters-editor{border-top:1px solid var(--border-color,#d8dde2);padding-top:1rem}.job-parameter-row{display:grid;grid-template-columns:1.1fr 1.4fr 1fr 1.2fr;gap:.75rem;padding:1rem;margin:.75rem 0;border:1px solid #d8dde2;border-radius:.5rem}.job-parameter-row .form-wide{grid-column:1/-1}.job-parameter-row .button-danger{align-self:end}.job-parameter-asset{margin:.75rem 0;border:1px solid #d8dde2;border-radius:.5rem;padding:.75rem}.job-parameter-asset summary{cursor:pointer}.job-asset-param{padding:.5rem 0;border-bottom:1px solid #d8dde2}@media(max-width:900px){.job-parameter-row{grid-template-columns:1fr}} + +/* v0.5.3.8 asset job definition parameter list */ +.job-definition-asset-list{display:grid;gap:.75rem;margin-top:1rem} +.job-parameter-asset>summary{display:flex;justify-content:space-between;align-items:center;gap:1rem;cursor:pointer;padding:.85rem 1rem} +.job-parameter-asset>summary span:first-child{display:flex;flex-direction:column;gap:.2rem} +.job-parameter-asset>summary small{font-weight:400} +.job-parameter-empty{padding:0 1rem 1rem} + +/* v0.5.5.6 software job list size */ +.software-job-limit-form{ + display:flex; + align-items:center; + gap:8px; +} +.software-job-limit-form label{ + margin:0; + white-space:nowrap; +} +.software-job-limit-form select{ + width:auto; + min-width:86px; +} + +/* v0.5.5.7 bulk restart for recent jobs */ +.software-job-heading-actions{ + display:flex; + align-items:center; + justify-content:flex-end; + gap:12px; + flex-wrap:wrap; +} +.software-job-heading-actions #restart-selected-jobs-button:disabled{ + opacity:.5; + cursor:not-allowed; +} +@media(max-width:700px){ + .software-job-heading-actions{ + width:100%; + justify-content:flex-start; + } +} + +/* v0.5.5.9 optimized software lists and configurable aggregation */ +.software-summary-controls{ + display:flex; + align-items:center; + justify-content:flex-end; + gap:12px; + flex-wrap:wrap; +} +.software-grouping-form{ + display:flex; + align-items:center; + gap:8px; +} +.software-grouping-form label{ + margin:0; + white-space:nowrap; +} +.software-grouping-form select{ + width:auto; + min-width:260px; +} +@media(max-width:800px){ + .software-summary-controls{ + width:100%; + justify-content:flex-start; + } + .software-grouping-form{ + width:100%; + align-items:flex-start; + flex-direction:column; + } + .software-grouping-form select{ + width:100%; + min-width:0; + } +} + +/* v0.5.5.12: keep white record-count labels readable in dark mode */ +html[data-theme="dark"] .record-count{ + background:#fff !important; + color:#26323d !important; + border-color:#c9d3dc !important; +} +/* v0.5.5.13: chart totals stay readable on the light badge in dark mode. */ +html[data-theme="dark"] .chart-total{ + color:#111827 !important; +} + +/* v0.5.5.14: active software inventory exclusion list */ +.software-exclusion-list{ + display:flex; + flex-direction:column; + gap:10px; +} +.software-exclusion-row{ + display:grid; + grid-template-columns:minmax(150px,220px) minmax(280px,1fr) auto; + align-items:end; + gap:12px; + padding:12px; + border:1px solid var(--am-border); + border-radius:8px; + background:color-mix(in srgb,var(--am-surface) 90%,var(--am-page-bg)); +} +.software-exclusion-row label{ + margin:0; +} +.software-exclusion-row label > span{ + display:block; + margin-bottom:6px; + font-weight:600; +} +.software-exclusion-remove{ + white-space:nowrap; +} +.software-exclusion-actions{ + display:flex; + align-items:center; + gap:14px; + margin-top:12px; +} +@media (max-width:760px){ + .software-exclusion-row{ + grid-template-columns:1fr; + align-items:stretch; + } + .software-exclusion-remove{ + justify-self:start; + } + .software-exclusion-actions{ + align-items:flex-start; + flex-direction:column; + } +} + +/* Asset job-status exclusion filter: checkboxes stay left-aligned and vertical. */ +.job-filter-status-fieldset{border:1px solid var(--border-color,#415166);border-radius:8px;padding:.85rem 1rem;margin:0}.job-filter-status-fieldset legend{padding:0 .35rem;font-weight:700}.job-filter-status-options{display:flex;flex-direction:column;align-items:flex-start;gap:.5rem;width:100%;margin-top:.65rem}.job-filter-status-options .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;max-width:none!important;text-align:left!important}.job-filter-status-options .checkbox-label input{flex:0 0 auto!important;margin:0!important}.job-filter-status-actions{justify-content:flex-start;margin-top:.75rem} + +/* 0.5.5.19: multi-job asset filter */ +.job-filter-job-options{display:flex;flex-direction:column;align-items:flex-start;gap:.5rem;width:100%;margin-top:.65rem} +.job-filter-job-options .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;max-width:none!important;text-align:left!important} +.job-filter-job-options .checkbox-label input{flex:0 0 auto!important;margin:0!important} +.job-filter-start-choice{display:inline-flex;align-items:center;gap:.5rem;margin:0 .35rem} +.job-filter-start-choice select{min-width:220px} +.job-filter-source{display:block;margin-top:.3rem;line-height:1.25} + +/* Administrator editor for the protected MeshCentral node ID. */ +.mesh-node-id-editor{display:grid;gap:.55rem}.mesh-node-id-input{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.mesh-node-id-actions{display:flex;flex-wrap:wrap;gap:.55rem;align-items:center}.mesh-node-id-actions .button{width:auto;margin:0}.mesh-node-id-editor small{font-weight:400;margin:0} diff --git a/app/static/img/manufacturer-logo.png b/app/static/img/manufacturer-logo.png new file mode 100644 index 0000000..b629030 Binary files /dev/null and b/app/static/img/manufacturer-logo.png differ diff --git a/app/static/js/asset-columns.js b/app/static/js/asset-columns.js new file mode 100644 index 0000000..e878837 --- /dev/null +++ b/app/static/js/asset-columns.js @@ -0,0 +1,238 @@ +(() => { + function init() { + const table = document.getElementById('asset-table'); + if (!table || table.dataset.columnManagerReady === '1') return; + table.dataset.columnManagerReady = '1'; + + const scroll = document.getElementById('asset-table-scroll'); + const topScroll = document.getElementById('asset-table-scroll-top'); + const topInner = topScroll?.firstElementChild; + const dialog = document.getElementById('column-settings-dialog'); + const list = document.getElementById('column-settings-list'); + const openButton = document.getElementById('column-settings-button'); + const showAllButton = document.getElementById('show-all-columns'); + const resetButton = document.getElementById('reset-columns'); + const menu = document.getElementById('column-context-menu'); + const hideMenuButton = document.getElementById('context-hide-column'); + const openMenuButton = document.getElementById('context-open-columns'); + + const keyBase = table.dataset.storageKey || 'asset-columns-all'; + const hiddenKey = `${keyBase}-hidden`; + const styleId = `asset-column-visibility-${keyBase.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + let contextField = null; + let resizeFrame = 0; + let lastContainerWidth = -1; + + const headers = () => + [...table.querySelectorAll('thead tr:first-child th[data-field]')]; + + const fieldLabel = field => { + const th = table.querySelector( + `thead tr:first-child th[data-field="${CSS.escape(field)}"]` + ); + return th?.dataset.label || th?.textContent?.trim() || field; + }; + + const allFields = () => headers().map(th => th.dataset.field); + + function getHidden() { + try { + const value = JSON.parse(window.AssetBrowserState?.get(hiddenKey) || '[]'); + return Array.isArray(value) ? value : []; + } catch (_) { + return []; + } + } + + function setHidden(fields) { + window.AssetBrowserState?.set(hiddenKey, JSON.stringify([...new Set(fields)])); + } + + function columnWidth(header) { + const configured = Number.parseInt(header.dataset.columnWidth || '', 10); + return Number.isFinite(configured) && configured > 0 ? configured : 180; + } + + function visibleTableWidth(hidden) { + const fieldWidth = headers().reduce( + (sum, header) => + hidden.has(header.dataset.field) ? sum : sum + columnWidth(header), + 0 + ); + const fixedWidth = Number.parseInt(table.dataset.fixedColumnWidth || '224', 10); + return Math.max(640, fieldWidth + fixedWidth); + } + + function updateTopScrollbar(tableWidth) { + if (!scroll || !topScroll || !topInner) return; + topInner.style.width = `${tableWidth}px`; + topScroll.hidden = tableWidth <= scroll.clientWidth + 1; + } + + function buildList() { + if (!list) return; + const hidden = new Set(getHidden()); + const fragment = document.createDocumentFragment(); + + allFields().forEach(field => { + const label = document.createElement('label'); + label.className = 'checkbox-option'; + + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = !hidden.has(field); + input.addEventListener('change', () => + input.checked ? showField(field) : hideField(field) + ); + + const span = document.createElement('span'); + span.textContent = fieldLabel(field); + label.append(input, span); + fragment.appendChild(label); + }); + + list.replaceChildren(fragment); + } + + function applyHidden() { + const hidden = new Set(getHidden()); + let style = document.getElementById(styleId); + if (!style) { + style = document.createElement('style'); + style.id = styleId; + document.head.appendChild(style); + } + + style.textContent = [...hidden] + .map(field => + `#asset-table [data-field="${CSS.escape(field)}"]{display:none!important}` + ) + .join('\n'); + + const tableWidth = visibleTableWidth(hidden); + table.style.width = `${tableWidth}px`; + table.style.minWidth = `${tableWidth}px`; + table.dataset.calculatedWidth = String(tableWidth); + buildList(); + + requestAnimationFrame(() => updateTopScrollbar(tableWidth)); + window.dispatchEvent(new Event('asset-table-columns-changed')); + } + + function hideField(field) { + if (!field) return; + const hidden = getHidden(); + const visibleFields = allFields().filter(item => !hidden.includes(item)); + if (visibleFields.length <= 1 || hidden.includes(field)) return; + hidden.push(field); + setHidden(hidden); + applyHidden(); + } + + function showField(field) { + setHidden(getHidden().filter(item => item !== field)); + applyHidden(); + } + + let syncing = false; + scroll?.addEventListener('scroll', () => { + if (syncing || !topScroll) return; + syncing = true; + topScroll.scrollLeft = scroll.scrollLeft; + syncing = false; + }, { passive: true }); + + topScroll?.addEventListener('scroll', () => { + if (syncing || !scroll) return; + syncing = true; + scroll.scrollLeft = topScroll.scrollLeft; + syncing = false; + }, { passive: true }); + + headers().forEach(th => { + th.addEventListener('contextmenu', event => { + event.preventDefault(); + contextField = th.dataset.field; + if (!menu) return; + menu.hidden = false; + const maxX = window.innerWidth - menu.offsetWidth - 8; + const maxY = window.innerHeight - menu.offsetHeight - 8; + menu.style.left = `${Math.max(8, Math.min(event.clientX, maxX))}px`; + menu.style.top = `${Math.max(8, Math.min(event.clientY, maxY))}px`; + }); + }); + + hideMenuButton?.addEventListener('click', () => { + hideField(contextField); + menu.hidden = true; + }); + + openMenuButton?.addEventListener('click', () => { + menu.hidden = true; + buildList(); + dialog?.showModal(); + }); + + document.addEventListener('click', event => { + if (menu && !menu.contains(event.target)) menu.hidden = true; + }); + + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && menu) menu.hidden = true; + }); + + openButton?.addEventListener('click', () => { + buildList(); + dialog?.showModal(); + }); + + showAllButton?.addEventListener('click', () => { + setHidden([]); + applyHidden(); + }); + + resetButton?.addEventListener('click', () => { + setHidden([]); + window.AssetBrowserState?.remove?.(keyBase); + applyHidden(); + window.location.reload(); + }); + + function scheduleScrollbarUpdate() { + cancelAnimationFrame(resizeFrame); + resizeFrame = requestAnimationFrame(() => { + const tableWidth = Number.parseInt(table.dataset.calculatedWidth || '0', 10); + if (tableWidth) updateTopScrollbar(tableWidth); + }); + } + + window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true }); + + if ('ResizeObserver' in window && scroll) { + const resizeObserver = new ResizeObserver(entries => { + const width = Math.round(entries[0]?.contentRect?.width || 0); + if (width === lastContainerWidth) return; + lastContainerWidth = width; + scheduleScrollbarUpdate(); + }); + resizeObserver.observe(scroll); + } + + window.addEventListener('asset-table-content-reloaded', event => { + if (!event.detail?.table || event.detail.table === table) { + applyHidden(); + } + }); + + applyHidden(); + window.dispatchEvent(new CustomEvent('asset-table-columns-ready', { + detail: { table } + })); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, { once: true }); + } else { + init(); + } +})(); diff --git a/app/static/js/asset-duplicates.js b/app/static/js/asset-duplicates.js new file mode 100644 index 0000000..5dbbcbc --- /dev/null +++ b/app/static/js/asset-duplicates.js @@ -0,0 +1,112 @@ +(() => { + function initialize() { + const table = document.getElementById('asset-table'); + const openButton = document.getElementById('duplicate-search-button'); + const dialog = document.getElementById('duplicate-search-dialog'); + const select = document.getElementById('duplicate-field-select'); + const applyButton = document.getElementById('duplicate-apply-button'); + const clearButton = document.getElementById('duplicate-clear-button'); + const ignoreEmpty = document.getElementById('duplicate-ignore-empty'); + const caseInsensitive = document.getElementById('duplicate-case-insensitive'); + const summary = document.getElementById('duplicate-search-summary'); + if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return; + table.dataset.duplicateSearchReady = '1'; + + const dataRows = () => [...table.tBodies[0].rows].filter(row => !row.querySelector('.empty-state')); + const normallyVisible = row => { + if (row.classList.contains('duplicate-hidden')) return false; + if (row.hidden || row.classList.contains('hidden')) return false; + const style = getComputedStyle(row); + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + const availableFields = () => [...table.querySelectorAll('thead tr:first-child th[data-field]')] + .filter(th => th.style.display !== 'none') + .map(th => ({ + field: th.dataset.field, + label: th.dataset.label || th.textContent.trim() + })); + + function fillSelect() { + const previous = select.value; + select.innerHTML = ''; + availableFields().forEach(item => { + const option = document.createElement('option'); + option.value = item.field; + option.textContent = item.label; + select.appendChild(option); + }); + if ([...select.options].some(option => option.value === previous)) { + select.value = previous; + } + } + + function clearDuplicateFilter() { + dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match')); + summary.hidden = true; + window.dispatchEvent(new Event('asset-table-filter-changed')); + } + + function normalize(value) { + let result = value.trim().replace(/\s+/g, ' '); + if (caseInsensitive.checked) result = result.toLocaleLowerCase(); + return result; + } + + function applyDuplicateFilter() { + // Remove only our previous result first. Normal column filters stay intact. + dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match')); + const field = select.value; + if (!field) return; + + const candidates = dataRows().filter(normallyVisible); + const groups = new Map(); + candidates.forEach(row => { + const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`); + const value = normalize(cell?.textContent || ''); + if (ignoreEmpty.checked && !value) return; + const rows = groups.get(value) || []; + rows.push(row); + groups.set(value, rows); + }); + + const duplicateRows = new Set(); + let duplicateGroups = 0; + groups.forEach(rows => { + if (rows.length > 1) { + duplicateGroups += 1; + rows.forEach(row => duplicateRows.add(row)); + } + }); + + candidates.forEach(row => { + if (duplicateRows.has(row)) { + row.classList.add('duplicate-match'); + } else { + row.classList.add('duplicate-hidden'); + } + }); + + summary.textContent = `${duplicateRows.size} Datensätze in ${duplicateGroups} Dublettengruppen gefunden.`; + summary.hidden = false; + window.dispatchEvent(new Event('asset-table-filter-changed')); + dialog.close(); + } + + openButton.addEventListener('click', () => { + // Search always starts from the current normal filter result, not from + // a previous duplicate result. + clearDuplicateFilter(); + fillSelect(); + dialog.showModal(); + }); + applyButton.addEventListener('click', applyDuplicateFilter); + clearButton.addEventListener('click', clearDuplicateFilter); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } + window.addEventListener('asset-table-content-reloaded', initialize); +})(); diff --git a/app/static/js/asset-inventory.js b/app/static/js/asset-inventory.js new file mode 100644 index 0000000..974a2aa --- /dev/null +++ b/app/static/js/asset-inventory.js @@ -0,0 +1,41 @@ +(() => { + const tabs = Array.from(document.querySelectorAll('[data-inventory-tab]')); + const panels = Array.from(document.querySelectorAll('[data-inventory-panel]')); + tabs.forEach((tab) => tab.addEventListener('click', () => { + const target = tab.dataset.inventoryTab; + tabs.forEach((item) => item.classList.toggle('active', item === tab)); + panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.inventoryPanel === target)); + })); + + const filter = document.getElementById('software-inventory-filter'); + const table = document.getElementById('software-inventory-table'); + if (filter && table) { + filter.addEventListener('input', () => { + const needle = filter.value.trim().toLocaleLowerCase(); + table.querySelectorAll('tbody tr').forEach((row) => { + row.hidden = needle && !row.textContent.toLocaleLowerCase().includes(needle); + }); + }); + } +})(); + +(() => { + const tree = document.querySelector('.inventory-tree'); + if (!tree) return; + const state = window.AssetBrowserState; + const assetId = location.pathname.match(/\/assets\/(\d+)/)?.[1] || 'unknown'; + const storageKey = `inventory-tree:${assetId}`; + let openPaths = new Set(); + if (state) { + try { openPaths = new Set(JSON.parse(state.get(storageKey) || '[]')); } catch (_) {} + } + tree.querySelectorAll('details[data-inventory-tree-path]').forEach((node) => { + const path = node.dataset.inventoryTreePath; + node.open = openPaths.has(path); + node.addEventListener('toggle', () => { + if (!state) return; + if (node.open) openPaths.add(path); else openPaths.delete(path); + state.set(storageKey, JSON.stringify([...openPaths])); + }); + }); +})(); diff --git a/app/static/js/asset-page-boot.js b/app/static/js/asset-page-boot.js new file mode 100644 index 0000000..fc50214 --- /dev/null +++ b/app/static/js/asset-page-boot.js @@ -0,0 +1,61 @@ +(() => { + const page = document.getElementById('asset-list-page'); + const loader = document.getElementById('asset-page-loader'); + const table = document.getElementById('asset-table'); + if (!page || !table) return; + + const required = [ + 'tableToolsReady', + 'browserStateReady', + 'columnOrderReady', + 'columnManagerReady' + ]; + let revealed = false; + let fallbackTimer = 0; + + function ready() { + return required.every(marker => table.dataset[marker] === '1'); + } + + function reveal() { + if (revealed) return; + revealed = true; + clearTimeout(fallbackTimer); + + const finish = () => requestAnimationFrame(() => { + page.classList.remove('asset-list-initializing'); + page.setAttribute('aria-busy', 'false'); + document.documentElement.classList.remove('asset-page-booting'); + loader?.setAttribute('hidden', ''); + window.dispatchEvent(new CustomEvent('asset-page-ready', { + detail: { table } + })); + }); + + if (document.fonts?.ready) { + document.fonts.ready.then(finish, finish); + } else { + finish(); + } + } + + function check() { + if (ready()) reveal(); + } + + [ + 'asset-table-structure-ready', + 'asset-table-state-ready', + 'asset-table-column-order-ready', + 'asset-table-columns-ready', + 'asset-list-count-updated' + ].forEach(name => window.addEventListener(name, check)); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', check, { once: true }); + } else { + check(); + } + + fallbackTimer = window.setTimeout(reveal, 2500); +})(); diff --git a/app/static/js/asset-perf.js b/app/static/js/asset-perf.js new file mode 100644 index 0000000..dc9cbdf --- /dev/null +++ b/app/static/js/asset-perf.js @@ -0,0 +1,103 @@ +(() => { + const prefix = '[AssetManager][Performance]'; + const pageStart = performance.now(); + const active = new Map(); + const results = []; + + const now = () => Math.round((performance.now() - pageStart) * 10) / 10; + const round = value => Math.round(value * 10) / 10; + + function start(name, details = {}) { + active.set(name, performance.now()); + console.debug(prefix, `+${now()} ms`, `START ${name}`, details); + } + + function end(name, details = {}) { + const started = active.get(name); + const duration = started == null ? null : performance.now() - started; + active.delete(name); + const result = { + Modul: name, + Start_ms: started == null ? null : round(started - pageStart), + Dauer_ms: duration == null ? null : round(duration), + ...details + }; + results.push(result); + const logger = duration != null && duration >= 50 ? console.warn : console.debug; + logger(prefix, `+${now()} ms`, `ENDE ${name}`, result); + return duration; + } + + function measure(name, callback, details = {}) { + const started = performance.now(); + try { + return callback(); + } finally { + const duration = performance.now() - started; + const result = { + Modul: name, + Start_ms: round(started - pageStart), + Dauer_ms: round(duration), + ...details + }; + results.push(result); + const logger = duration >= 50 ? console.warn : console.debug; + logger(prefix, `+${now()} ms`, name, result); + } + } + + async function measureAsync(name, callback, details = {}) { + const started = performance.now(); + try { + return await callback(); + } finally { + const duration = performance.now() - started; + const result = { + Modul: name, + Start_ms: round(started - pageStart), + Dauer_ms: round(duration), + ...details + }; + results.push(result); + const logger = duration >= 50 ? console.warn : console.debug; + logger(prefix, `+${now()} ms`, name, result); + } + } + + function report(name, duration, details = {}, level = null) { + const result = { + Modul: name, + Start_ms: details.Start_ms ?? null, + Dauer_ms: round(duration), + ...details + }; + delete result.Start_ms; + result.Start_ms = details.Start_ms ?? null; + results.push(result); + + try { + const safeName = name.replace(/[^a-zA-Z0-9:_-]+/g, '-'); + performance.mark(`${safeName}:end`); + performance.measure(safeName, { + start: Math.max(0, performance.now() - duration), + end: performance.now() + }); + } catch (_) {} + + const logger = level === 'warn' || (level == null && duration >= 50) + ? console.warn + : console.debug; + logger(prefix, `+${now()} ms`, name, result); + return result; + } + + function summary() { + const sorted = [...results].sort((a, b) => (b.Dauer_ms || 0) - (a.Dauer_ms || 0)); + console.groupCollapsed(`${prefix} Zusammenfassung – langsamste Vorgänge`); + console.table(sorted.slice(0, 30)); + console.groupEnd(); + return sorted; + } + + window.AssetPerf = { start, end, measure, measureAsync, report, summary, results }; +})(); \ No newline at end of file diff --git a/app/static/js/browser-state.js b/app/static/js/browser-state.js new file mode 100644 index 0000000..6d33197 --- /dev/null +++ b/app/static/js/browser-state.js @@ -0,0 +1,113 @@ +(() => { + const perf = window.AssetPerf; + perf?.start('browser-state:gesamtes Modul'); + const persistent = document.body?.dataset.persistBrowserState === 'true'; + const storage = persistent ? window.localStorage : window.sessionStorage; + const prefix = 'assetmanager:'; + + const api = { + persistent, + get(key) { + try { return storage.getItem(prefix + key); } catch (_) { return null; } + }, + set(key, value) { + try { storage.setItem(prefix + key, value); } catch (_) {} + }, + remove(key) { + try { storage.removeItem(prefix + key); } catch (_) {} + } + }; + window.AssetBrowserState = api; + + function tableKey(table, index) { + return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`; + } + + function filterControls(table) { + return [...table.querySelectorAll('thead input, thead select')].filter(control => { + const type = (control.type || '').toLowerCase(); + return !['checkbox', 'radio', 'button', 'submit'].includes(type); + }); + } + + function controlKey(control, index) { + const cell = control.closest('th'); + return control.name || control.dataset.field || cell?.dataset.field || `filter-${index}`; + } + + function restoreTable(table, index) { + if (table.dataset.browserStateReady === '1') return; + if (table.dataset.serverFilters === '1') { + table.dataset.browserStateReady = '1'; + return; + } + const restoreStarted = performance.now(); + const controls = filterControls(table); + if (!controls.length) return; + + table.dataset.browserStateReady = '1'; + const key = `${tableKey(table, index)}:filters`; + let values = {}; + try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {} + + controls.forEach((control, controlIndex) => { + const field = controlKey(control, controlIndex); + if (Object.prototype.hasOwnProperty.call(values, field)) { + // Restore without dispatching one input/change pair per column. + control.value = values[field]; + } + const save = () => { + const current = {}; + filterControls(table).forEach((item, itemIndex) => { + current[controlKey(item, itemIndex)] = item.value; + }); + api.set(key, JSON.stringify(current)); + }; + control.addEventListener('input', save); + control.addEventListener('change', save); + }); + + // One filtering pass after every stored value is in place. + if (typeof table.assetApplyFilters === 'function') { + table.assetApplyFilters(); + } + table.dataset.browserStateRestored = '1'; + window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } })); + const duration = performance.now() - restoreStarted; + const logger = duration >= 50 ? console.warn : console.debug; + logger('[AssetManager][Performance]', 'browser-state:restoreTable', { + dauer_ms: Math.round(duration * 10) / 10, + filter: controls.length, + gespeicherte_werte: Object.keys(values).length + }); + } + + function initializeTables() { + document.querySelectorAll('table').forEach(restoreTable); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeTables, { once: true }); + } else { + initializeTables(); + } + + // Filter controls are added by table-tools.js. Initialize only on its explicit + // completion event instead of observing every DOM mutation in the document. + window.addEventListener('asset-table-structure-ready', initializeTables); + + // A programmatic reset does not emit input/change events. Remove the + // corresponding persisted filter state explicitly so it cannot return after + // a page refresh. + window.addEventListener('asset-table-filters-cleared', event => { + const table = event.detail?.table; + if (!(table instanceof HTMLTableElement)) return; + + const tables = [...document.querySelectorAll('table')]; + const index = tables.indexOf(table); + const key = `${tableKey(table, index >= 0 ? index : 0)}:filters`; + api.remove(key); + }); + + perf?.end('browser-state:gesamtes Modul'); +})(); diff --git a/app/static/js/issue-dialog.js b/app/static/js/issue-dialog.js new file mode 100644 index 0000000..e2e052f --- /dev/null +++ b/app/static/js/issue-dialog.js @@ -0,0 +1,15 @@ +(() => { + const dialog = document.getElementById('issue-dialog'); + const form = document.getElementById('issue-form'); + if (!dialog || !form) return; + const dateInput = form.querySelector('input[name="assigned_on"]'); + if (dateInput && !dateInput.value) dateInput.value = new Date().toISOString().slice(0, 10); + document.querySelectorAll('[data-issue-action]').forEach(button => { + button.addEventListener('click', () => { + form.action = button.dataset.issueAction; + const target = document.getElementById('issue-return-to'); + if (target) target.value = button.dataset.returnTo || 'detail'; + dialog.showModal(); + }); + }); +})(); diff --git a/app/static/js/list-tools.js b/app/static/js/list-tools.js new file mode 100644 index 0000000..c8e8869 --- /dev/null +++ b/app/static/js/list-tools.js @@ -0,0 +1,132 @@ +(() => { + const perf = window.AssetPerf; + perf?.start('list-tools:gesamtes Modul'); + const tables = new Map(); + + function isDataRow(row) { + if (!row || row.closest('thead')) return false; + if (row.classList.contains('column-filter-row') || row.classList.contains('filter-row')) return false; + if (row.querySelector('.empty-state')) return false; + return true; + } + + function isVisible(row) { + if (row.hidden || row.classList.contains('hidden')) return false; + return true; + } + + function countTarget(table, index) { + const toolbar = table.closest('main')?.querySelector('.toolbar'); + if (!toolbar) return null; + + let actions = toolbar.querySelector('.toolbar-actions'); + if (!actions) { + actions = document.createElement('div'); + actions.className = 'toolbar-actions'; + toolbar.appendChild(actions); + } + + let target = actions.querySelector(`[data-table-count-for="${index}"]`); + if (!target) { + target = document.createElement('span'); + target.className = 'record-count'; + target.dataset.tableCountFor = String(index); + actions.insertBefore(target, actions.firstChild); + } + return target; + } + + function update(table, index) { + const updateStarted = performance.now(); + const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow); + const visible = rows.filter(isVisible).length; + const target = countTarget(table, index); + if (target) { + const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen'; + target.textContent = template + .replace('{visible}', String(visible)) + .replace('{total}', String(rows.length)); + target.title = document.body.dataset.recordCountTitle || ''; + } + window.dispatchEvent(new CustomEvent('asset-list-count-updated', { + detail: { table, visible, total: rows.length } + })); + const duration = performance.now() - updateStarted; + const logger = duration >= 50 ? console.warn : console.debug; + logger('[AssetManager][Performance]', 'list-tools:update', { + dauer_ms: Math.round(duration * 10) / 10, + sichtbar: visible, + gesamt: rows.length + }); + } + + function schedule(table, index) { + const state = tables.get(table) || {}; + if (state.frame) cancelAnimationFrame(state.frame); + state.frame = requestAnimationFrame(() => { + state.frame = null; + update(table, index); + }); + tables.set(table, state); + } + + function initialize() { + document.querySelectorAll('table.data-table').forEach((table, index) => { + if (table.dataset.listToolsReady === '1') { + schedule(table, index); + return; + } + table.dataset.listToolsReady = '1'; + schedule(table, index); + + table.addEventListener('input', () => setTimeout(() => schedule(table, index), 0)); + table.addEventListener('change', () => setTimeout(() => schedule(table, index), 0)); + + const body = table.tBodies[0]; + if (body) { + const observer = new MutationObserver(mutations => { + const started = performance.now(); + schedule(table, index); + const duration = performance.now() - started; + console.debug('[AssetManager][Performance]', 'list-tools:MutationObserver', { + dauer_ms: Math.round(duration * 10) / 10, + mutationen: mutations.length + }); + }); + observer.observe(body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['hidden', 'style', 'class'] + }); + } + }); + + if (document.getElementById('asset-table')) { + document.body.classList.add('single-scroll-list'); + } else { + document.body.classList.remove('single-scroll-list'); + } + } + + document.addEventListener('input', () => { + document.querySelectorAll('table.data-table').forEach((table, index) => { + setTimeout(() => schedule(table, index), 0); + }); + }); + document.addEventListener('change', () => { + document.querySelectorAll('table.data-table').forEach((table, index) => { + setTimeout(() => schedule(table, index), 0); + }); + }); + window.addEventListener('asset-table-filter-changed', initialize); + window.addEventListener('asset-table-content-reloaded', initialize); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); + } else { + initialize(); + } + + perf?.end('list-tools:gesamtes Modul'); +})(); diff --git a/app/static/js/local-time.js b/app/static/js/local-time.js new file mode 100644 index 0000000..eafd0c8 --- /dev/null +++ b/app/static/js/local-time.js @@ -0,0 +1,74 @@ +(() => { + 'use strict'; + + const language = document.documentElement.lang || navigator.language || 'de-DE'; + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const formatter = new Intl.DateTimeFormat(language, { + dateStyle: 'short', + timeStyle: 'medium' + }); + + function parseUtc(value) { + if (!value) return null; + let normalized = String(value).trim(); + if (!normalized) return null; + // Project database timestamps without an offset are UTC by convention. + if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(normalized)) { + normalized = normalized.replace(' ', 'T') + 'Z'; + } + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + } + + function formatUtc(value, fallback = '') { + const date = parseUtc(value); + return date ? formatter.format(date) : (fallback || value || ''); + } + + function localizeElement(element) { + const value = element.getAttribute('datetime') || element.dataset.utc; + if (!value) return; + const formatted = formatUtc(value, element.textContent); + element.textContent = formatted; + element.title = `${value} UTC · ${zone}`; + element.dataset.localTimeApplied = 'true'; + const cell = element.closest('[data-sort-value]'); + if (cell) cell.dataset.sortValue = value; + } + + const isoPattern = /(? formatUtc(match, match)); + if (converted !== original) { + element.dataset.localizingLog = 'true'; + element.textContent = converted; + delete element.dataset.localizingLog; + } + element.title = zone; + } + + function scan(root = document) { + if (root.matches?.('time[data-utc], time[datetime].local-time')) localizeElement(root); + root.querySelectorAll?.('time[data-utc], time[datetime].local-time').forEach(localizeElement); + if (root.matches?.('[data-localize-log]')) localizeLogText(root); + root.querySelectorAll?.('[data-localize-log]').forEach(localizeLogText); + } + + window.AssetManagerTime = {formatUtc, parseUtc, zone, scan}; + scan(document); + + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE) scan(node); + }); + const owner = mutation.target.nodeType === Node.ELEMENT_NODE + ? mutation.target.closest?.('[data-localize-log]') + : mutation.target.parentElement?.closest?.('[data-localize-log]'); + if (owner) localizeLogText(owner); + } + }); + observer.observe(document.body, {childList: true, subtree: true, characterData: true}); +})(); diff --git a/app/static/js/presence.js b/app/static/js/presence.js new file mode 100644 index 0000000..0b5e652 --- /dev/null +++ b/app/static/js/presence.js @@ -0,0 +1,58 @@ +(() => { + const elements = [...document.querySelectorAll('[data-presence-asset]')]; + if (!elements.length) return; + + function formattedDate(value) { + return value ? (window.AssetManagerTime?.formatUtc(value, value) || value) : '–'; + } + + function updateElement(element, item) { + const state = ['online', 'offline'].includes(item.state) ? item.state : 'unknown'; + element.classList.remove('presence-online', 'presence-offline', 'presence-unknown'); + element.classList.add(`presence-${state}`); + element.dataset.filterValue = element.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] || state; + const label = element.querySelector('[data-presence-label]'); + if (label) { + label.textContent = + element.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] || state; + } + const lastSeenText = formattedDate(item.last_seen_at); + const lastSeenPrefix = element.dataset.lastSeenLabel || 'Last seen'; + element.title = `${label?.textContent || state} · ${lastSeenPrefix}: ${lastSeenText}`; + + const meta = document.querySelector(`[data-presence-meta="${CSS.escape(String(item.asset_id))}"]`); + if (meta) { + const stateElement = meta.querySelector('[data-presence-meta-state]'); + const updatedElement = meta.querySelector('[data-presence-updated]'); + const lastOnlineElement = meta.querySelector('[data-presence-last-online]'); + const lastSeenElement = meta.querySelector('[data-presence-last-seen]'); + if (stateElement) stateElement.textContent = label?.textContent || state; + if (updatedElement) updatedElement.textContent = formattedDate(item.updated_at); + if (lastOnlineElement) lastOnlineElement.textContent = formattedDate(item.last_online_at); + if (lastSeenElement) lastSeenElement.textContent = formattedDate(item.last_seen_at); + } + } + + async function refreshPresence() { + try { + const response = await fetch('/api/assets/presence', {cache: 'no-store'}); + if (!response.ok) return; + const payload = await response.json(); + const byId = new Map((payload.items || []).map(item => [String(item.asset_id), item])); + elements.forEach(element => { + const item = byId.get(String(element.dataset.presenceAsset)); + if (item) updateElement(element, item); + }); + + document.querySelectorAll('table.data-table').forEach(table => { + table.assetRebuildFilterCache?.(); + table.assetApplyFilters?.(); + }); + } catch (_) { + // Keep the last known state visible when the API is temporarily unavailable. + } + } + + refreshPresence(); + window.setInterval(refreshPresence, 5000); +})(); diff --git a/app/static/js/software-inventory-selection.js b/app/static/js/software-inventory-selection.js new file mode 100644 index 0000000..b7bbd73 --- /dev/null +++ b/app/static/js/software-inventory-selection.js @@ -0,0 +1,44 @@ +(()=>{ + function init(root=document){ + root.querySelectorAll('[data-software-selection]').forEach(container=>{ + if(container.dataset.selectionReady==='1') return; + container.dataset.selectionReady='1'; + const table=container.querySelector('table'); + const form=container.querySelector('form[data-software-delete-form]'); + const master=container.querySelector('[data-software-select-all]'); + const button=container.querySelector('[data-software-delete-button]'); + const countNode=container.querySelector('[data-software-selection-count]'); + if(!table||!form) return; + const boxes=()=>[...table.querySelectorAll('tbody input[data-software-entry-checkbox]')]; + const visibleBoxes=()=>boxes().filter(box=>!box.closest('tr')?.hidden); + const update=()=>{ + const selected=boxes().filter(box=>box.checked); + if(button) button.disabled=selected.length===0; + if(countNode) countNode.textContent=String(selected.length); + if(master){ + const visible=visibleBoxes(); + const selectedVisible=visible.filter(box=>box.checked).length; + master.checked=visible.length>0&&selectedVisible===visible.length; + master.indeterminate=selectedVisible>0&&selectedVisible{ visibleBoxes().forEach(box=>box.checked=master.checked); update(); }); + table.addEventListener('change',event=>{ if(event.target.matches('[data-software-entry-checkbox]')) update(); }); + form.addEventListener('submit',event=>{ + const selected=boxes().filter(box=>box.checked); + if(!selected.length){ event.preventDefault(); return; } + form.querySelectorAll('input[data-generated-entry-id]').forEach(node=>node.remove()); + selected.forEach(box=>{ const input=document.createElement('input'); input.type='hidden'; input.name='entry_ids'; input.value=box.value; input.dataset.generatedEntryId='1'; form.appendChild(input); }); + const template=form.dataset.confirmTemplate||'Delete {count} selected software inventory entries?'; + const warning=form.dataset.inventoryOnlyWarning||''; + const message=template.replace('{count}',String(selected.length))+(warning?'\n\n'+warning:''); + if(!window.confirm(message)) event.preventDefault(); + }); + window.addEventListener('asset-table-filter-changed',event=>{ if(event.detail?.table===table) update(); }); + update(); + }); + } + document.addEventListener('DOMContentLoaded',()=>init()); + window.AssetManagerSoftwareSelection={init}; +})(); diff --git a/app/static/js/software-job-selection.js b/app/static/js/software-job-selection.js new file mode 100644 index 0000000..5c89a0c --- /dev/null +++ b/app/static/js/software-job-selection.js @@ -0,0 +1,74 @@ +(() => { + function init() { + const table = document.querySelector('table[data-storage-key="software-jobs"]'); + const master = document.getElementById('select-all-restartable-jobs'); + const button = document.getElementById('restart-selected-jobs-button'); + const form = document.getElementById('restart-selected-jobs-form'); + const idContainer = document.getElementById('restart-selected-job-ids'); + if (!table || !master || !button || !form || !idContainer) return; + + const boxes = () => [...table.querySelectorAll('tbody .software-job-restart-select:not(:disabled)')]; + const isVisible = box => { + const row = box.closest('tr'); + return Boolean(row && !row.hidden && getComputedStyle(row).display !== 'none'); + }; + const visibleBoxes = () => boxes().filter(isVisible); + const selectedBoxes = () => boxes().filter(box => box.checked && isVisible(box)); + + function update() { + boxes().filter(box => !isVisible(box)).forEach(box => { box.checked = false; }); + const visible = visibleBoxes(); + const selected = selectedBoxes(); + const count = selected.length; + const baseLabel = button.dataset.baseLabel || button.textContent.trim(); + + button.disabled = count === 0; + button.textContent = count > 0 ? `${baseLabel} (${count})` : baseLabel; + master.disabled = visible.length === 0; + master.checked = visible.length > 0 && count === visible.length; + master.indeterminate = count > 0 && count < visible.length; + } + + master.addEventListener('change', () => { + visibleBoxes().forEach(box => { box.checked = master.checked; }); + update(); + }); + + table.addEventListener('change', event => { + if (event.target.matches('.software-job-restart-select')) update(); + }); + + button.addEventListener('click', () => { + const selected = selectedBoxes(); + if (!selected.length) return; + const template = form.dataset.confirmTemplate || 'Restart {count} selected jobs?'; + if (!window.confirm(template.replace('{count}', String(selected.length)))) return; + + idContainer.replaceChildren(); + selected.forEach(box => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = 'job_ids'; + input.value = box.value; + idContainer.appendChild(input); + }); + form.requestSubmit(); + }); + + const observer = new MutationObserver(update); + table.querySelectorAll('tbody tr').forEach(row => { + observer.observe(row, {attributes: true, attributeFilter: ['style', 'class', 'hidden']}); + }); + window.addEventListener('asset-table-filter-changed', event => { + if (!event.detail?.table || event.detail.table === table) update(); + }); + window.addEventListener('software-job-status-changed', update); + update(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, {once: true}); + } else { + init(); + } +})(); diff --git a/app/static/js/software-job-status.js b/app/static/js/software-job-status.js new file mode 100644 index 0000000..81eab65 --- /dev/null +++ b/app/static/js/software-job-status.js @@ -0,0 +1,103 @@ +(() => { + const terminalStatuses = new Set(['success', 'failed', 'partial', 'timeout', 'cancelled']); + const dispatchingStatuses = new Set(['sending', 'running']); + const bulkBlockedStatuses = new Set(['success', 'sending', 'running']); + + function init() { + const table = document.querySelector('table[data-storage-key="software-jobs"]'); + if (!table) return; + + const rows = () => [...table.querySelectorAll('tbody tr[data-job-id]')]; + const activeRows = () => rows().filter(row => !terminalStatuses.has(row.dataset.jobStatus || 'created')); + + function renderRestartCell(row, status) { + const cell = row.querySelector('[data-job-restart-cell]'); + if (!cell) return; + const currentlyBlocked = Boolean(cell.querySelector('.muted')); + const mustBlock = dispatchingStatuses.has(status); + if (currentlyBlocked === mustBlock) return; + + cell.replaceChildren(); + if (mustBlock) { + const placeholder = document.createElement('span'); + placeholder.className = 'muted'; + placeholder.textContent = '—'; + cell.appendChild(placeholder); + return; + } + + const form = document.createElement('form'); + form.method = 'post'; + form.action = `/jobs/${row.dataset.jobId}/restart`; + form.className = 'inline-form'; + form.addEventListener('submit', event => { + const message = table.dataset.restartConfirm || ''; + if (message && !window.confirm(message)) event.preventDefault(); + }); + const button = document.createElement('button'); + button.type = 'submit'; + button.className = 'button button-secondary button-small'; + button.textContent = table.dataset.restartLabel || '↻'; + form.appendChild(button); + cell.appendChild(form); + } + + function applyStatus(item) { + const row = table.querySelector(`tbody tr[data-job-id="${CSS.escape(String(item.id))}"]`); + if (!row) return; + const status = String(item.status || 'created'); + const previous = row.dataset.jobStatus || ''; + row.dataset.jobStatus = status; + + const field = row.querySelector('[data-job-status-field]'); + if (field) { + field.textContent = item.status_label || status; + field.className = `job-status job-status-${status}`; + } + + const checkbox = row.querySelector('.software-job-restart-select'); + if (checkbox) { + const hasNode = row.dataset.jobHasNode === '1'; + checkbox.disabled = !hasNode || bulkBlockedStatuses.has(status); + if (checkbox.disabled) checkbox.checked = false; + } + renderRestartCell(row, status); + + if (previous !== status) { + window.dispatchEvent(new CustomEvent('software-job-status-changed', {detail: {row, status}})); + } + } + + let timer = null; + async function refresh() { + const active = activeRows(); + if (!active.length) return; + const ids = active.map(row => Number(row.dataset.jobId)).filter(Number.isInteger); + try { + const response = await fetch('/api/software-jobs/statuses', { + method: 'POST', + cache: 'no-store', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(ids), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + (payload.jobs || []).forEach(applyStatus); + if (activeRows().length) timer = window.setTimeout(refresh, 3000); + } catch (_) { + timer = window.setTimeout(refresh, 5000); + } + } + + if (activeRows().length) timer = window.setTimeout(refresh, 1200); + window.addEventListener('beforeunload', () => { + if (timer) window.clearTimeout(timer); + }, {once: true}); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init, {once: true}); + } else { + init(); + } +})(); diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js new file mode 100644 index 0000000..03370eb --- /dev/null +++ b/app/static/js/table-tools.js @@ -0,0 +1,503 @@ +(() => { + const collator = new Intl.Collator(document.documentElement.lang || 'de', { + numeric: true, + sensitivity: 'base' + }); + + function controlValue(control) { + if (control.tagName === 'SELECT') { + const selected = control.options?.[control.selectedIndex]; + return [ + control.value || '', + selected?.textContent || '' + ].join(' '); + } + + if (control.type === 'checkbox' || control.type === 'radio') { + return control.checked + ? [ + control.value || '', + control.getAttribute('aria-label') || '', + control.closest('label')?.textContent || '' + ].join(' ') + : ''; + } + + return [ + control.value || '', + control.getAttribute('placeholder') || '', + control.getAttribute('aria-label') || '' + ].join(' '); + } + + function textOutsideControls(cell) { + const clone = cell.cloneNode(true); + clone.querySelectorAll('input, select, textarea, option').forEach(node => node.remove()); + return (clone.textContent || '').trim(); + } + + function textValue(cell) { + if (!cell) return ''; + + const explicitFilterValue = cell.dataset.filterValue || ''; + + if (cell.dataset.sortValue !== undefined) { + return `${cell.dataset.sortValue} ${explicitFilterValue}`.trim(); + } + + const controls = [...cell.querySelectorAll('input, select, textarea')]; + if (controls.length) { + const controlText = controls.map(controlValue).join(' ').trim(); + const additionalText = textOutsideControls(cell); + return `${explicitFilterValue} ${controlText} ${additionalText}`.trim(); + } + + return `${explicitFilterValue} ${cell.textContent || ''}`.trim(); + } + + function parsedValue(value) { + const text = value.trim(); + if (!text) return { type: 'empty', value: '' }; + + if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) { + const timestamp = Date.parse(text.replace(' ', 'T')); + if (!Number.isNaN(timestamp)) return { type: 'date', value: timestamp }; + } + + if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(text)) { + const [day, month, year] = text.split('.').map(Number); + return { type: 'date', value: new Date(year, month - 1, day).getTime() }; + } + + if (/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(text)) { + return { type: 'ipv4', value: text.split('.').map(Number) }; + } + + const numeric = text.replace(/\s/g, '').replace(',', '.'); + if (/^[-+]?\d+(?:\.\d+)?$/.test(numeric)) { + return { type: 'number', value: Number(numeric) }; + } + + return { type: 'text', value: text }; + } + + function compareValues(a, b) { + const left = parsedValue(a); + const right = parsedValue(b); + + if (left.type === 'empty' && right.type !== 'empty') return 1; + if (right.type === 'empty' && left.type !== 'empty') return -1; + + if (left.type === right.type && (left.type === 'number' || left.type === 'date')) { + return left.value - right.value; + } + + if (left.type === 'ipv4' && right.type === 'ipv4') { + for (let index = 0; index < 4; index += 1) { + const difference = left.value[index] - right.value[index]; + if (difference) return difference; + } + return 0; + } + + return collator.compare(String(left.value), String(right.value)); + } + + function initTable(table) { + const thead = table.tHead; + const tbody = table.tBodies[0]; + if (!thead || !tbody || !thead.rows.length) return; + if (table.dataset.tableToolsReady === '1') return; + + const headerRow = thead.rows[0]; + + const isAssetTable = table.id === 'asset-table'; + const usesServerFilters = table.dataset.serverFilters === '1'; + let serverFilterTimer = null; + + // Normal tables get stable synthetic keys. The asset table already has + // real data-field keys on its movable columns; selection and action + // columns must remain without a field key. + if (!isAssetTable) { + [...headerRow.cells].forEach((header, index) => { + if (!header.dataset.field) { + header.dataset.field = `__col_${index}`; + header.dataset.syntheticField = '1'; + } + }); + + [...tbody.rows].forEach(row => { + [...row.cells].forEach((cell, index) => { + const header = headerRow.cells[index]; + if (header && !cell.dataset.field) { + cell.dataset.field = header.dataset.field; + cell.dataset.syntheticField = '1'; + } + }); + }); + } + + const filterRow = document.createElement('tr'); + filterRow.className = 'column-filter-row'; + filterRow.dataset.tableFilterRow = '1'; + + const filterDefinitions = []; + + function filterStatusTarget() { + const wrapper = table.closest( + '#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table' + ); + return wrapper || table; + } + + function ensureFilterStatus() { + let status = table._filterStatusElement; + if (status?.isConnected) return status; + + status = document.createElement('div'); + status.className = 'table-filter-status'; + status.hidden = true; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + + const icon = document.createElement('span'); + icon.className = 'table-filter-status-icon'; + icon.textContent = '⌕'; + icon.setAttribute('aria-hidden', 'true'); + + const label = document.createElement('strong'); + label.className = 'table-filter-status-label'; + + const details = document.createElement('span'); + details.className = 'table-filter-status-details'; + + const clearButton = document.createElement('button'); + clearButton.type = 'button'; + clearButton.className = 'button button-secondary table-filter-clear'; + clearButton.textContent = + document.body.dataset.clearFiltersLabel || 'Filter zurücksetzen'; + clearButton.addEventListener('click', () => { + filterDefinitions.forEach(definition => { + definition.input.value = ''; + }); + applyFilters(); + if (usesServerFilters) submitServerFilters(true); + + // Keep the persisted browser state in sync with the visible table. + // Programmatic value changes do not trigger input/change automatically. + window.dispatchEvent(new CustomEvent('asset-table-filters-cleared', { + detail: { table } + })); + + filterDefinitions[0]?.input?.focus(); + }); + + status.append(icon, label, details, clearButton); + + const target = filterStatusTarget(); + target.parentNode?.insertBefore(status, target); + table._filterStatusElement = status; + return status; + } + + function updateFilterPresentation(active, visibleRows, totalRows) { + filterDefinitions.forEach(definition => { + const hasValue = Boolean(definition.input.value.trim()); + definition.input.classList.toggle('filter-input-active', hasValue); + definition.input.closest('th')?.classList.toggle('filter-cell-active', hasValue); + }); + + const status = ensureFilterStatus(); + const label = status.querySelector('.table-filter-status-label'); + const details = status.querySelector('.table-filter-status-details'); + const activeCount = active.length; + + status.hidden = activeCount === 0; + status.classList.toggle('is-active', activeCount > 0); + + if (activeCount > 0) { + const activeTemplate = + document.body.dataset.filtersActiveTemplate || '{count} Filter aktiv'; + const statusTemplate = + document.body.dataset.filterStatusTemplate || + 'Gefiltert: {visible} von {total} Datensätzen'; + + label.textContent = activeTemplate.replace('{count}', String(activeCount)); + details.textContent = statusTemplate + .replace('{visible}', String(visibleRows)) + .replace('{total}', String(totalRows)); + } + } + + function cellForDefinition(row, definition) { + if (definition.field) { + return row.querySelector(`td[data-field="${CSS.escape(definition.field)}"]`); + } + return row.cells[definition.columnIndex] || null; + } + + function rebuildMissingCellKeys() { + if (isAssetTable) return; + + [...tbody.rows].forEach(row => { + [...row.cells].forEach((cell, index) => { + const header = headerRow.cells[index]; + if (header && !cell.dataset.field) { + cell.dataset.field = header.dataset.field; + } + }); + }); + } + + function numericFilterMatches(rawValue, rawTerm) { + const value = Number(String(rawValue).trim().replace(',', '.')); + if (!Number.isFinite(value)) return false; + + const term = String(rawTerm || '').trim().toLocaleLowerCase(); + if (!term) return true; + + const normalize = input => input.replace(',', '.').trim(); + let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/); + if (match) { + const expected = Number(normalize(match[2])); + if (match[1] === '<') return value < expected; + if (match[1] === '<=') return value <= expected; + if (match[1] === '>') return value > expected; + if (match[1] === '>=') return value >= expected; + return value === expected; + } + + match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/); + if (!match) { + match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/); + } + if (match) { + const first = Number(normalize(match[1])); + const second = Number(normalize(match[2])); + const minimum = Math.min(first, second); + const maximum = Math.max(first, second); + return value >= minimum && value <= maximum; + } + + const exact = Number(normalize(term)); + return Number.isFinite(exact) ? value === exact : false; + } + + + function submitServerFilters(immediate = false) { + if (!usesServerFilters) return; + if (serverFilterTimer) window.clearTimeout(serverFilterTimer); + + const navigate = () => { + const target = new URL( + table.dataset.serverFilterUrl || window.location.pathname, + window.location.origin + ); + const current = new URL(window.location.href); + const limit = current.searchParams.get('job_limit'); + if (limit) target.searchParams.set('job_limit', limit); + + filterDefinitions.forEach(definition => { + if (!definition.serverName) return; + const value = definition.input.value.trim(); + if (value) target.searchParams.set(`job_filter_${definition.serverName}`, value); + }); + + try { + window.sessionStorage.setItem( + 'assetmanager:software-jobs:last-server-filter', + document.activeElement?.name || '' + ); + } catch (_) {} + window.location.assign(target.toString()); + }; + + if (immediate) navigate(); + else serverFilterTimer = window.setTimeout(navigate, 700); + } + + function applyFilters() { + rebuildMissingCellKeys(); + + const active = filterDefinitions + .map(definition => ({ + definition, + term: definition.input?.value.trim().toLocaleLowerCase() || '' + })) + .filter(item => item.term); + + if (usesServerFilters) { + const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10); + const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length; + updateFilterPresentation(active, tbody.rows.length, total); + window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { + detail: { + table, + visible: tbody.rows.length, + total, + activeFilters: active.length + } + })); + return; + } + + let visibleRows = 0; + + [...tbody.rows].forEach(row => { + const hidden = active.some(({ definition, term }) => { + const cell = cellForDefinition(row, definition); + const value = textValue(cell); + if (definition.numericFilter) { + return !numericFilterMatches(value, term); + } + return !value.toLocaleLowerCase().includes(term); + }); + + row.hidden = hidden; + if (!hidden) visibleRows += 1; + }); + + updateFilterPresentation(active, visibleRows, tbody.rows.length); + + window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { + detail: { + table, + visible: visibleRows, + total: tbody.rows.length, + activeFilters: active.length + } + })); + } + + [...headerRow.cells].forEach((header, columnIndex) => { + const field = header.dataset.field || ''; + const serverName = header.dataset.serverFilterName || ''; + + if (!header.dataset.noSort) { + header.classList.add('sortable-column'); + header.title = header.dataset.sortTitle || 'Zum Sortieren anklicken'; + + header.addEventListener('click', event => { + if (event.target.closest('input,button,a,select,textarea,label')) return; + + const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc'; + + [...headerRow.cells].forEach(item => { + delete item.dataset.sortDirection; + item.classList.remove('sort-asc', 'sort-desc'); + }); + + header.dataset.sortDirection = direction; + header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc'); + + const rows = [...tbody.rows]; + rows.sort((left, right) => { + const definition = { field, columnIndex }; + const result = compareValues( + textValue(cellForDefinition(left, definition)), + textValue(cellForDefinition(right, definition)) + ); + return direction === 'asc' ? result : -result; + }); + + const fragment = document.createDocumentFragment(); + rows.forEach(row => fragment.appendChild(row)); + tbody.appendChild(fragment); + applyFilters(); + }); + } + + const filterCell = document.createElement('th'); + if (field) filterCell.dataset.field = field; + filterCell.dataset.columnIndex = String(columnIndex); + + if (!header.dataset.noFilter && (!usesServerFilters || serverName)) { + const input = document.createElement('input'); + input.type = 'search'; + input.placeholder = header.dataset.filterPlaceholder || 'Filtern …'; + input.setAttribute('aria-label', `Filter ${textValue(header)}`); + input.autocomplete = 'off'; + if (usesServerFilters) { + input.name = `job_filter_${serverName}`; + input.value = header.dataset.serverFilterValue || ''; + input.addEventListener('input', () => { + applyFilters(); + submitServerFilters(false); + }); + input.addEventListener('search', () => { + applyFilters(); + submitServerFilters(false); + }); + input.addEventListener('keydown', event => { + if (event.key !== 'Enter') return; + event.preventDefault(); + submitServerFilters(true); + }); + } else { + input.addEventListener('input', applyFilters); + input.addEventListener('search', applyFilters); + } + + filterCell.appendChild(input); + filterDefinitions.push({ + field, + columnIndex, + input, + serverName, + numericFilter: header.dataset.numericFilter === '1' + }); + } + + filterRow.appendChild(filterCell); + }); + + thead.appendChild(filterRow); + + // Editable cells are always read live, so changed values immediately + // participate in filtering without a stale cache. + tbody.addEventListener('input', event => { + if (event.target.matches('input,select,textarea')) applyFilters(); + }); + + tbody.addEventListener('change', event => { + if (event.target.matches('input,select,textarea')) applyFilters(); + }); + + table.assetApplyFilters = applyFilters; + table.assetRebuildFilterCache = rebuildMissingCellKeys; + table.dataset.tableToolsReady = '1'; + ensureFilterStatus(); + + // Covers browser-restored values after reload/back navigation. + requestAnimationFrame(() => { + applyFilters(); + if (usesServerFilters) { + try { + const lastName = window.sessionStorage.getItem('assetmanager:software-jobs:last-server-filter'); + const lastInput = lastName + ? filterDefinitions.find(definition => definition.input.name === lastName)?.input + : null; + if (lastInput) { + lastInput.focus(); + lastInput.setSelectionRange(lastInput.value.length, lastInput.value.length); + } + window.sessionStorage.removeItem('assetmanager:software-jobs:last-server-filter'); + } catch (_) {} + } + }); + + window.dispatchEvent(new CustomEvent('asset-table-structure-ready', { + detail: { table } + })); + } + + document.querySelectorAll('table.data-table').forEach(initTable); + + window.addEventListener('asset-table-content-reloaded', event => { + const table = event.detail?.table; + if (table?.assetRebuildFilterCache) { + table.assetRebuildFilterCache(); + table.assetApplyFilters?.(); + } + }); +})(); diff --git a/app/static/js/toasts.js b/app/static/js/toasts.js new file mode 100644 index 0000000..aba2bbb --- /dev/null +++ b/app/static/js/toasts.js @@ -0,0 +1,53 @@ +(() => { + const container = document.getElementById('toast-container'); + if (!container) return; + + const body = document.body; + const allowedPositions = new Set([ + 'top-left', 'top-center', 'top-right', + 'bottom-left', 'bottom-center', 'bottom-right' + ]); + const position = allowedPositions.has(body.dataset.toastPosition) + ? body.dataset.toastPosition + : 'top-right'; + const seconds = Math.max(1, Math.min(120, Number.parseInt(body.dataset.toastDuration || '6', 10) || 6)); + container.classList.add(`toast-position-${position}`); + + const params = new URLSearchParams(window.location.search); + const entries = [ + ['toast_error', 'error'], + ['toast_warning', 'warning'], + ['toast_success', 'success'] + ]; + + function showToast(message, level) { + if (!message) return; + const toast = document.createElement('div'); + toast.className = `toast toast-${level}`; + const symbol = level === 'success' ? '✓' : level === 'warning' ? '⚠' : '✕'; + toast.innerHTML = `${symbol}`; + toast.querySelector('.toast-message').textContent = message; + toast.querySelector('.toast-close').addEventListener('click', () => toast.remove()); + container.appendChild(toast); + window.setTimeout(() => { + toast.classList.add('toast-hide'); + window.setTimeout(() => toast.remove(), 350); + }, seconds * 1000); + } + + let changed = false; + entries.forEach(([key, level]) => { + const values = params.getAll(key); + values.forEach(value => showToast(value, level)); + if (values.length) { + params.delete(key); + changed = true; + } + }); + + if (changed) { + const query = params.toString(); + const cleanUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`; + window.history.replaceState({}, document.title, cleanUrl); + } +})(); diff --git a/app/static/uploads/.gitkeep b/app/static/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/templates/_chart.html b/app/templates/_chart.html new file mode 100644 index 0000000..079e756 --- /dev/null +++ b/app/templates/_chart.html @@ -0,0 +1,34 @@ +
+
+

{{ item.chart.name }}

{% if item.chart.description %}

{{ item.chart.description }}

{% endif %}
+ {{ item.total }} Geräte +
+ {% if not item['items'] %} +
Keine passenden Daten vorhanden.
+ {% elif item.chart.chart_type in ['donut', 'pie'] %} +
+
{{ item.total }}
+
+ {% for label, value in item['items'] %} +
{{ label }}{{ value }}
+ {% endfor %} +
+
+ {% elif item.chart.chart_type == 'stacked_bar' %} +
{% for stack in item.stack_values %}{{ stack }}{% endfor %}
+
+ {% for label, values in item['items'] %} + {% set row_total = values.values()|sum %} +
{{ label }}
+ {% for stack in item.stack_values %}{% set count = values.get(stack, 0) %}{% if count %}
{{ count }}
{% endif %}{% endfor %} +
{{ row_total }}
+ {% endfor %} +
+ {% else %} +
+ {% for label, value in item['items'] %} +
{{ label }}
{{ value }}
{{ value }}
+ {% endfor %} +
+ {% endif %} +
\ No newline at end of file diff --git a/app/templates/asset_detail.html b/app/templates/asset_detail.html new file mode 100644 index 0000000..1b786f5 --- /dev/null +++ b/app/templates/asset_detail.html @@ -0,0 +1,302 @@ +{% extends "base.html" %} +{% macro inventory_label(label) -%} + {%- set text = label|string -%} + {{- t(text, text) if text.startswith('inventory.') else text -}} +{%- endmacro %} +{% macro inventory_icon(label) -%} + {%- set key = (label|string)|lower -%} + {%- if 'system_overview' in key or 'window' in key or key in ['computer', 'system', 'hardware'] -%}🖥️ + {%- elif 'processor' in key or 'cpu' in key -%}🧠 + {%- elif 'memory' in key or 'ram' in key -%}🧩 + {%- elif 'storage' in key or 'drive' in key or 'disk' in key or 'volume' in key or 'partition' in key -%}💾 + {%- elif 'graphic' in key or 'gpu' in key or 'video' in key or 'display' in key -%}🎨 + {%- elif 'monitor' in key or 'screen' in key -%}🖥️ + {%- elif 'network' in key or 'ethernet' in key or 'wifi' in key or 'adapter' in key or 'wlan' in key -%}🌐 + {%- elif 'battery' in key -%}🔋 + {%- elif 'bios' in key or 'uefi' in key or 'firmware' in key -%}⚙️ + {%- elif 'board' in key or 'motherboard' in key or 'mainboard' in key -%}🔌 + {%- elif 'audio' in key or 'sound' in key -%}🔊 + {%- elif 'usb' in key -%}🔗 + {%- elif 'printer' in key -%}🖨️ + {%- elif 'software' in key or 'application' in key -%}📦 + {%- elif 'os_' in key or 'operating' in key -%}🪟 + {%- elif 'antivirus' in key or 'security' in key or 'tpm' in key -%}🛡️ + {%- elif 'mesh' in key or 'agent' in key or 'connection' in key -%}🔗 + {%- else -%}📁 + {%- endif -%} +{%- endmacro %} +{% macro inventory_node(label, value, level=0, path='root') %} + {% set node_path = path ~ '/' ~ (label|string)|replace(' ', '_')|replace('/', '_') %} + {% if value is mapping %} +
+ {{ inventory_label(label) }}{{ value|length }} +
{% for key, child in value.items() %}{{ inventory_node(key, child, level + 1, node_path) }}{% endfor %}
+
+ {% elif value is sequence and value is not string %} +
+ {{ inventory_label(label) }}{{ value|length }} +
+ {% for child in value %} + {% if child is mapping %}{% set child_label = child.get('inventory.property.name') or child.get('Name') or child.get('name') or child.get('Caption') or child.get('caption') or child.get('DeviceID') or child.get('Tag') or (t('inventory.entry', 'Entry') ~ ' ' ~ loop.index) %}{% else %}{% set child_label = t('inventory.entry', 'Entry') ~ ' ' ~ loop.index %}{% endif %} + {{ inventory_node(child_label, child, level + 1, node_path) }} + {% endfor %} +
+
+ {% else %} +
{{ inventory_label(label) }}{% if value == 'inventory.value.unknown' %}{{ t('inventory.value.unknown', 'Unknown') }}{% elif value is sameas true %}{{ t('common.yes') }}{% elif value is sameas false %}{{ t('common.no') }}{% else %}{{ value if value is not none and value != '' else '–' }}{% endif %}
+ {% endif %} +{% endmacro %} +{% block content %} +
+

{{ asset.name }}

+
+ {% if asset.mesh_node_id %} + {{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }} + {% endif %} + {% if is_admin %} + {% if issue_status_name and asset.status == issue_status_name %}
+ {% else %}{% endif %} + ⧉ {{ t('assets.duplicate') }} +
+ ✎ {{ t('common.edit') }} + {% endif %} + 🕘 {{ t('assets.history') }} ({{ history_count }}) +
+
+ +
+
{% if asset.image_path %}{% elif asset.category.image_path %}{% endif %}
+
{% for f in active_fields %}{% if f.field_name not in ['room_number', 'parent_asset_id'] %}{% set d=f.definition %}{% set v=custom_values.get(f.field_name) if d and not d.is_system else asset|attr(f.field_name) %}{% if v %}
{{ t(d.translation_key, d.label) if d else f.label }}
{{ v }}
{% endif %}{% endif %}{% endfor %}{% if asset.room_number %}
{{ t('assets.room') }}
{{ asset.room_number }}
{% endif %} +{% if parent_asset %}
{{ t('assets.parent') }}
{{ parent_asset.name }}
{% endif %} +{% if child_assets %}
{{ t('assets.child_assets') }}
{% for child in child_assets %}{{ child.name }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %} +
+
+ +{% if is_admin %} +
+
+
+

{{ t('mesh.link.section_title', 'MeshCentral link') }}

+

{{ t('mesh.link.section_help', 'The node ID can be changed manually without deleting the asset. Other asset fields are updated only by the next MeshCentral synchronization.') }}

+
+
+ + ✎ {% if asset.mesh_node_id %}{{ t('mesh.link.change', 'Change link') }}{% else %}{{ t('mesh.link.initialize', 'Initialize link') }}{% endif %} + + {% if asset.mesh_node_id %} +
+ +
+ {% endif %} +
+
+
+ {{ t('field.mesh_node_id', 'MeshCentral node ID') }}: {{ asset.mesh_node_id or '–' }} + {% if asset.mesh_sync_status %}{{ t('assets.status', 'Status') }}: {{ t('mesh.sync_status.' ~ asset.mesh_sync_status, asset.mesh_sync_status) }}{% endif %} +
+
+{% endif %} + +{% if is_admin and asset.mesh_node_id %} +
+
+
+

{{ t('jobs.run_definition', 'Job ausführen') }}

+

{{ t('jobs.run_definition_help', 'Es werden nur aktive Jobdefinitionen angezeigt, die zur erkannten Plattform dieses Assets passen.') }}

+
+ {% if job_definitions %} +
+ + +
+ {% else %} + {{ t('jobs.no_compatible_definitions', 'Für diese Plattform ist keine aktive Jobdefinition verfügbar.') }} + {% endif %} +
+
+{% endif %} + +{% if is_admin %} +
+

{{ t('jobparams.asset_title') }}

+

{{ t('jobparams.asset_help') }}

+ {% if job_definitions %} +
+ {% for definition in job_definitions %} +
+ + {{ definition.name }}{% if definition.description %}{{ definition.description }}{% endif %} + {{ t('jobparams.parameter_count', count=definition.parameters|length) }} + + {% if definition.parameters %} +
+ {% for parameter in definition.parameters %} + {% set override = job_parameter_overrides.get(parameter.id) %} +
+ + +
+ {% endfor %} +
+
+ {% else %} +

{{ t('jobparams.none_defined') }}

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

{{ t('jobs.no_compatible_definitions') }}

+ {% endif %} +
+{% endif %} + +{% if asset.mesh_node_id %} +
+
+
+

{{ t('inventory.title') }}

+

{{ t('inventory.subtitle') }}

+
+ {{ t('inventory.download_json') }} +
+
+ {{ t('mesh.node_id') }}: {{ asset.mesh_node_id }} + {{ t('inventory.snapshot') }}: {% if asset.mesh_inventory_updated_at or asset.mesh_last_sync %}{% else %}{{ '–' }}{% endif %} + {{ t('assets.status') }}: {% if asset.mesh_sync_status %}{{ t('mesh.sync_status.' ~ asset.mesh_sync_status, asset.mesh_sync_status) }}{% else %}{{ t('common.unknown') }}{% endif %} + + {{ t('presence.title') }}: + {{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }} + · {{ t('presence.updated') }}: + {% if asset.mesh_presence_updated_at %}{% else %}{{ '–' }}{% endif %} + · {{ t('presence.last_seen') }}: + {% if asset.mesh_last_seen %}{% else %}{{ '–' }}{% endif %} + · {{ t('presence.last_online') }}: + {% if asset.mesh_last_online_at %}{% else %}{{ '–' }}{% endif %} + +
+ +
+ + + + +
+ +
+ {% if hardware_inventory %} +
{% for key, value in hardware_inventory.items() %}{{ inventory_node(key, value) }}{% endfor %}
+ {% else %}
{{ t('inventory.no_hardware') }}
{% endif %} +
+ +
+
+
+

{{ t('software.installed') }}

+ {% if installed_software %} +

{{ t('software.inventory_count', '{count} software entries stored.', count=installed_software|length) }}

+ {% else %} +

{{ t('software.inventory_not_initialized') }}

+ {% endif %} +
+ {% if is_admin %} +
+ + +
+ {% endif %} +
+ + {% if latest_software_job %} +
+ {{ t('software.last_job') }}: #{{ latest_software_job.id }} + {{ t('software.status') }}: {{ t('software.status.' ~ latest_software_job.status, latest_software_job.status) }} + {{ t('software.last_inventory') }}: {% if latest_software_job.finished_at or latest_software_job.created_at %}{% else %}{{ '–' }}{% endif %} +
+ {% endif %} + + {% if installed_software %} +
+ {% if is_admin %}
+ + {{ t('software.selected_count') }}: 0 + +
{% endif %} +
+ + + {% if is_admin %}{% endif %} + + + + + + + + + + {% for entry in installed_software %} + + {% if is_admin %}{% endif %} + + + + + + + + + {% endfor %} + +
{{ t('common.name') }}{{ t('about.version') }}{{ t('software.publisher') }}{{ t('software.architecture') }}{{ t('software.platform') }}{{ t('software.install_date') }}{{ t('software.last_seen') }}
{{ entry.name }}{{ entry.version or '' }}{{ entry.publisher or '' }}{{ entry.architecture or '' }}{{ t('software.platform.' ~ (entry.platform or 'unknown'), entry.platform or 'unknown') }}{{ entry.install_date or '' }}{% if entry.last_seen_at %}{% else %}{{ '' }}{% endif %}
+
+
+ {% else %} +
+ {{ t('software.inventory_not_initialized') }} + {{ t('software.inventory_initialize_help') }} +
+ {% endif %} +
+ +
+ {% if raw_inventory %}
{{ raw_inventory|tojson(indent=2) }}
{% else %}
{{ t('inventory.no_raw_data') }}
{% endif %} +
+ +
+
{{ t('mesh.last_sync') }}
{{ asset.mesh_last_sync or '–' }}
{{ t('mesh.message') }}
{{ asset.mesh_sync_message or '–' }}
+ {% if asset.mesh_conflicts %}

{{ t('mesh.field_conflicts') }}

{% for field, conflict in asset.mesh_conflicts.items() %}{% endfor %}
{{ t('common.field') }}{{ t('mesh.local_value') }}MeshCentral{{ t('mesh.rule') }}
{{ field }}{{ conflict.local }}{{ conflict.meshcentral }}{{ conflict.rule }}
{% endif %} +
+
+{% endif %} + + + +{% if is_admin %}

{{ t('assets.issue_asset') }}

{% endif %} + + +{% endblock %} diff --git a/app/templates/asset_form.html b/app/templates/asset_form.html new file mode 100644 index 0000000..0960a34 --- /dev/null +++ b/app/templates/asset_form.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %}{% block content %} +

{% if duplicate_mode %}{{t('assets.duplicate')}}{% elif asset %}{{t('common.edit')}} {{t('app.assets')}}{% else %}{{t('assets.create')}}{% endif %}

+{% if duplicate_mode %}

The original asset data was copied. Asset tag and MeshCentral node ID were cleared.

{% endif %} +
+ +{% if selected %}{% for f in selected.visible_fields if f.active %}{% set d=f.definition %}{% set custom=d and not d.is_system %}{% set value=custom_values.get(f.field_name) if custom else (asset|attr(f.field_name) if asset else '') %} +{% endfor %}{% endif %} +
+{% if asset and not duplicate_mode %} + +{% endif %} +{% endblock %} diff --git a/app/templates/asset_history.html b/app/templates/asset_history.html new file mode 100644 index 0000000..8e7efca --- /dev/null +++ b/app/templates/asset_history.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} +{% block content %} +
+

Historie: {{ asset.name }}

+ +
+

Jede geänderte Feldbezeichnung wird mit altem und neuem Wert gespeichert.

+
+ + + + {% for entry in entries %} + {% for label, change in entry.changes.items() %} + + {% endfor %} + {% else %}{% endfor %} + +
ZeitpunktQuelleBenutzerFeldAlter WertNeuer Wert
{{ entry.changed_at }}{{ entry.source }}{{ entry.changed_by or '' }}{{ label }}{{ change.old }}{{ change.new }}
Noch keine Änderungen protokolliert.
+
+{% endblock %} diff --git a/app/templates/asset_mesh_link.html b/app/templates/asset_mesh_link.html new file mode 100644 index 0000000..9069ccf --- /dev/null +++ b/app/templates/asset_mesh_link.html @@ -0,0 +1,76 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ t('mesh.link.title', 'Change MeshCentral link') }}

+

{{ t('mesh.link.page_help', 'Select an unassigned MeshCentral device. The node ID is changed immediately; other asset fields are updated only during the next synchronization.') }}

+
+ +
+ +
+

{{ t('mesh.link.asset_comparison', 'Asset and current link') }}

+
+ {{ t('common.name', 'Name') }}: {{ asset.name }} + {{ t('field.manufacturer', 'Manufacturer') }}: {{ asset.manufacturer or '–' }} + {{ t('field.serial_number', 'Serial number') }}: {{ asset.serial_number or '–' }} + {{ t('field.mesh_node_id', 'MeshCentral node ID') }}: {{ asset.mesh_node_id or '–' }} +
+ {% if current_device %} +

+ {{ t('mesh.link.current_device', 'Current MeshCentral device') }}: + {{ current_device.name }} + {% if current_device.manufacturer %} · {{ current_device.manufacturer }}{% endif %} + {% if current_device.serial_number %} · {{ current_device.serial_number }}{% endif %} +

+ {% elif asset.mesh_node_id %} +

{{ t('mesh.link.current_missing', 'The currently stored node ID was not found in MeshCentral. You can replace it with one of the available devices below.') }}

+ {% endif %} +
+ +
+

{{ t('mesh.link.available_devices', 'Available MeshCentral devices') }}

+

{{ t('mesh.link.available_help', 'Only node IDs that are not assigned to another asset are shown.') }}

+ + {% if available_devices %} +
+
+ + + + + + + + + + + + + + {% for device in available_devices %} + + + + + + + + + + {% endfor %} + +
{{ t('common.select', 'Select') }}{{ t('common.name', 'Name') }}{{ t('field.manufacturer', 'Manufacturer') }}{{ t('field.serial_number', 'Serial number') }}{{ t('field.mesh_group', 'MeshCentral group') }}{{ t('field.operating_system', 'Operating system') }}{{ t('field.mesh_node_id', 'MeshCentral node ID') }}
{{ device.name or '–' }}{{ device.manufacturer or '–' }}{{ device.serial_number or '–' }}{{ device.group or '–' }}{{ device.operating_system or '–' }}{{ device.node_id }}
+
+
+ + {{ t('common.cancel', 'Cancel') }} +
+
+ {% else %} +
{{ t('mesh.link.no_available_devices', 'No unassigned MeshCentral devices are currently available.') }}
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/asset_software.html b/app/templates/asset_software.html new file mode 100644 index 0000000..b8a6512 --- /dev/null +++ b/app/templates/asset_software.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %}{% block content %} +

{{ t('software.asset_title', 'Software: {name}').replace('{name}', asset.name) }}

{% if is_admin and asset.mesh_node_id %}
{% endif %}{{ t('software.back_to_asset') }}
+
+
+
+

{{ t('software.installed') }}

+

{% if software %}{{ t('software.inventory_count', '{count} software entries stored.', count=software|length) }}{% else %}{{ t('software.inventory_not_initialized') }}{% endif %}

+
+ {{ software|length }} +
{% for s in software %}{% else %}{% endfor %}
{{ t('common.name') }}{{ t('about.version') }}{{ t('software.publisher') }}{{ t('software.architecture') }}{{ t('software.install_date') }}{{ t('software.last_seen') }}
{{ s.name }}{{ s.version }}{{ s.publisher }}{{ s.architecture }}{{ s.install_date or '' }}{% if s.last_seen_at %}{% else %}{{ '' }}{% endif %}
{{ t('software.inventory_not_initialized') }}{{ t('software.inventory_initialize_help') }}
+

{{ t('software.jobs') }}

{% for j in jobs %}{% else %}{% endfor %}
{{ t('software.time') }}{{ t('jobs.type') }}{{ t('jobs.action') }}{{ t('jobs.priority') }}{{ t('software.status') }}{{ t('software.message') }}{{ t('software.log') }}{{ t('jobs.actions') }}
{% if j.created_at %}{% else %}{{ '' }}{% endif %}{{ t(j.display_job_type_key, j.display_job_type_fallback) }}{{ t('jobs.action.' ~ j.action, j.action) }}{{ j.priority }}{{ t('software.status.' ~ j.status, j.status) }}{{ j.message or '' }}{{ t('software.show') }}{% if is_admin and j.status not in ['sending', 'running'] %}
{% else %}{% endif %}
{{ t('software.no_jobs') }}
+{% endblock %} \ No newline at end of file diff --git a/app/templates/asset_tree.html b/app/templates/asset_tree.html new file mode 100644 index 0000000..5469e71 --- /dev/null +++ b/app/templates/asset_tree.html @@ -0,0 +1,82 @@ +{% extends 'base.html' %} + +{% macro category_icon(category_name) %} + {% set category = (category_name or '')|lower %} + + {% if 'server' in category %} + + {% elif 'netz' in category or 'switch' in category or 'router' in category or 'firewall' in category %} + + {% elif 'druck' in category or 'printer' in category %} + + {% elif 'monitor' in category or 'display' in category %} + + {% elif 'mobil' in category or 'telefon' in category or 'smartphone' in category or 'tablet' in category %} + + {% elif 'computer' in category or 'pc' in category or 'notebook' in category or 'laptop' in category %} + + {% else %} + + {% endif %} + +{% endmacro %} + +{% macro render_node(node, by_parent) %} +
  • +
    + {{ category_icon(node.category.name) }} +
    + {{ node.name }} +
    {{ node.ip_address or t('assets.no_ip') }}
    +
    {{ node.location or t('assets.no_location') }}{% if node.room_number %} · {{ t('assets.room') }} {{ node.room_number }}{% endif %}
    +
    {{ node.category.name }}
    +
    +
    + {% set children = by_parent.get(node.id, []) %} + {% if children %}
      {% for child in children %}{{ render_node(child, by_parent) }}{% endfor %}
    {% endif %} +
  • +{% endmacro %} + +{% block content %} +

    {{ t('app.asset_tree') }}

    +
    +
    + + + + + + +
    + + {{ t('common.reset') }} +
    +
    + +
    + {{ t('app.categories') }} +
    + {% for category in categories %} + + {% endfor %} +
    + {{ t('tree.no_category_selection') }} +
    +
    +{% if roots %}
      {% for root in roots %}{{ render_node(root, by_parent) }}{% endfor %}
    {% else %}

    {{ t('tree.no_matches') }}

    {% endif %} +{% endblock %} diff --git a/app/templates/assets.html b/app/templates/assets.html new file mode 100644 index 0000000..ae7935f --- /dev/null +++ b/app/templates/assets.html @@ -0,0 +1,512 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    + + {{ t('assets.loading', 'Assets werden geladen …') }} + {{ t('assets.loading_hint', 'Tabellenansicht und gespeicherte Einstellungen werden vorbereitet.') }} +
    +
    +
    +
    +

    {{ t('app.assets', 'Assets') }}{% if selected %}: {{ selected.name }}{% endif %}

    +
    + {% if is_admin and assets %} + + + +
    + + + +
    + {% endif %} + + {% if is_admin %}{% endif %} + + + ⇅ {{ t('settings.import_export', 'Import / Export') }} +{% if is_admin %} + ➕ {{ t('assets.create', 'Asset anlegen') }}{% endif %} +
    +
    +
    {{ t('common.all', 'Alle') }}{% for c in categories %}{{ c.name }}{% endfor %}
    +{% if job_filter %} +
    + {{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}: + {% for spec in job_filter.specs %}{% if spec.label_key %}{{ t(spec.label_key, spec.label) }}{% else %}{{ spec.label }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} + · {{ t('assets.job_filter.excluded_summary', 'Ausgeschlossen') }}: + {% if job_filter.excluded_statuses %} + {% for status in job_filter.excluded_statuses %}{{ t('software.status.' ~ status, status) }}{% if not loop.last %}, {% endif %}{% endfor %} + {% else %} + {{ t('assets.job_filter.excluded_none', 'keine Status') }} + {% endif %} + {% if job_filter.revision_mode == 'current' %}· {{ t('assets.job_filter.current_revision', 'nur aktuelle Revision') }}{% endif %} + · {{ t('assets.total', '{count} Assets').format(count=total_assets) }} + {{ t('assets.job_filter.clear', 'Jobfilter entfernen') }} + {% if is_admin and assets and job_filter.startable_specs %} + {% if job_filter.startable_specs|length > 1 %} + + {% endif %} + + {% endif %} +
    +{% endif %} +{% if selected %}

    {{ t('assets.column_order_hint') }}

    {% endif %} +{% if is_admin %} +
    {% if selected %}{% endif %}
    +
    {% if selected %}{% endif %}
    +
    +
    + + + +
    +{% if job_filter %} +
    + {% for selection in job_filter.selections %}{% endfor %} + + + {% for status in job_filter.excluded_statuses %}{% endfor %} + {% if selected %}{% endif %} +
    +{% endif %} +{% endif %} + +
    + + + {% if is_admin %}{% endif %} + {% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %} + {% if not has_name %}{% endif %} + {% if not selected %}{% endif %} + {% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}{% endfor %} + {% if job_filter %} + + + + + {% endif %} + + + + + {% for a in assets %} + {% if is_admin %}{% endif %} + {% if not has_name %}{% endif %} + {% if not selected %}{% endif %} + {% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}{% endfor %} + {% if job_filter %}{% set job_state = job_state_by_asset.get(a.id) %} + + + + + {% endif %} + + + {% else %}{% endfor %} + +
    {{ t('field.name', 'Bezeichnung') }}{{ t('common.category', 'Kategorie') }}{{ f.label }}{{ t('assets.job_filter.status', 'Jobstatus') }}{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}{{ t('assets.job_filter.revision', 'Revision') }}{{ t('presence.title') }}{{ t('common.actions', 'Aktionen') }}
    {{ a.name }}{{ a.category.name }}{% if f.field_name == 'name' %}{{ a.name }}{% else %}{{ a|attr(f.field_name) or '' }}{% endif %}{{ t('software.status.' ~ job_state.status, t('assets.job_filter.never_run', 'Noch nie ausgeführt') if job_state.status == 'never_run' else job_state.status) }}{% if job_filter.specs|length > 1 and job_state.job_label %}{{ job_state.job_label }}{% endif %}{% if job_state.last_execution %}{% else %}{% endif %}{% if job_state.last_success %}{% else %}{% endif %}{% if job_state.definition_revision %}{{ job_state.definition_revision }}{% else %}{% endif %} + {% if a.mesh_node_id %} + {{ t('presence.' ~ (a.mesh_online_state or 'unknown')) }} + {% else %} + + {% endif %} + {% if is_admin %} + {% if issue_status_name and a.status == issue_status_name %}
    {% else %}{% endif %} + +
    {% else %}{{ t('common.read_only', 'Nur lesen') }}{% endif %}
    {{ t('assets.none', 'Keine Assets vorhanden.') }}
    + +

    {{ t('assets.total', '{count} Assets').format(count=total_assets) }}

    + + + +{% if is_admin %} + +
    +
    +

    {{ t('assets.job_filter.title', 'Assets nach Jobstatus filtern') }}

    + +
    +

    {{ t('assets.job_filter.help', 'Die Jobinformationen werden nur bei aktivem Filter geladen. Die normale Assetliste bleibt unverändert schnell.') }}

    + {% if selected %}{% endif %} +
    + {{ t('assets.job_filter.jobs', 'Jobs') }} +

    {{ t('assets.job_filter.jobs_help', 'Mehrere Jobs werden als alternative Ausführungen zusammengefasst. Maßgeblich ist der zuletzt ausgeführte ausgewählte Job.') }}

    +
    + + {% for definition in job_filter_definitions %} + + {% endfor %} +
    +
    +
    + {{ t('assets.job_filter.excluded_statuses', 'Auszuschließende Status') }} +

    {{ t('assets.job_filter.excluded_help', 'Markierte Status werden nicht in der Assetliste angezeigt. Ohne Markierung werden alle Assets angezeigt.') }}

    +
    + {% for status in job_filter_statuses %} + + {% endfor %} +
    +
    + + +
    +
    + +
    + {{ t('assets.job_filter.clear', 'Jobfilter entfernen') }} + +
    +
    +
    +{% endif %} + + +
    +
    +

    {{ t('duplicates.search_title') }}

    + +
    +

    {{ t('duplicates.visible_only_help') }}

    + + + + +
    + + + +
    +
    +
    + + +
    +
    +

    {{ t('assets.columns', 'Spalten') }}

    + +
    +

    {{ t('assets.columns_hint', 'Spalten ein- oder ausblenden. Die Auswahl wird in diesem Browser gespeichert.') }}

    +
    +
    + + + +
    +
    +
    + + +{% if is_admin %} + +{% endif %} + + +{% if is_admin %} + +{% endif %} +{% if is_admin %}

    {{ t('assets.issue_asset') }}

    {% endif %} + + + +
    + +{% endblock %} diff --git a/app/templates/assets_bulk_edit.html b/app/templates/assets_bulk_edit.html new file mode 100644 index 0000000..07b35c7 --- /dev/null +++ b/app/templates/assets_bulk_edit.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} +{% block content %} +

    Daten mehrerer Assets ändern

    Die Änderung wird auf {{ assets|length }} markierte Assets angewendet.

    Abbrechen
    +
    +
    + {% for id in asset_ids %}{% endfor %} + {% if category_id %}{% endif %} +
    + + +
    +

    Ein leerer Wert entfernt den bisherigen Wert. Inventarnummer, Seriennummer und MeshCentral Node-ID sind aus Sicherheitsgründen von der Massenänderung ausgeschlossen.

    +
    Ausgewählte Assets anzeigen
      {% for asset in assets %}
    • {{ asset.name }} ({{ asset.category.name }})
    • {% endfor %}
    +
    Abbrechen
    +
    +
    + +{% endblock %} diff --git a/app/templates/assets_import.html b/app/templates/assets_import.html new file mode 100644 index 0000000..ba76a9c --- /dev/null +++ b/app/templates/assets_import.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block content %} +
    +

    {{ t('assets.import_title') }}

    + +
    + +
    +
    + + + {% if selected %} +

    {{ t('assets.import_template') }}

    + {% endif %} + + + + +

    + Standardmäßig werden alle bekannten und für den Excel-Import freigegebenen Felder übernommen, + auch wenn sie in der jeweiligen Kategorie nicht aktiv oder sichtbar sind. + Aktiviere diese Option nur, wenn der Import auf die aktiven Felder der Kategorie begrenzt werden soll. +

    + +
    + + +
    +

    Der Test-Import prüft dieselbe Datei mit derselben Logik, führt aber am Ende einen vollständigen Datenbank-Rollback durch.

    +
    +
    + +{% if result %} +
    +

    {% if result.dry_run %}Ergebnis des Test-Imports{% else %}Importergebnis{% endif %}

    + {% if result.dry_run %} +
    Testlauf: Es wurden keine Daten gespeichert. Die Zähler und Zeilenergebnisse zeigen, was ein echter Import ausführen würde.
    + {% endif %} +
    +
    Neu angelegt
    {{ result.created }}
    +
    Aktualisiert
    {{ result.updated }}
    +
    Unverändert
    {{ result.skipped }}
    +
    + {% if result.unknown_headers %} +

    Nicht erkannte Spalten

    +

    {{ result.unknown_headers|join(', ') }}

    + {% endif %} + {% if result.row_results %} +

    Ergebnis je Excel-Zeile

    +
    + + + + {% for row in result.row_results %} + + + + + + + {% endfor %} + +
    Excel-ZeileAsset-IDAktionBegründung
    {{ row.row }}{{ row.asset_id }}{{ row.action }}{{ row.reason }}
    +
    + {% endif %} + {% if result.errors %} +

    Fehlerübersicht

    +
      {% for error in result.errors %}
    • {{ error }}
    • {% endfor %}
    + {% endif %} +
    +{% endif %} +{% endblock %} diff --git a/app/templates/assets_merge_order.html b/app/templates/assets_merge_order.html new file mode 100644 index 0000000..9b217d1 --- /dev/null +++ b/app/templates/assets_merge_order.html @@ -0,0 +1,62 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('duplicates.merge') }}

    +

    {{ t('duplicates.order_help') }}

    +
    + {{ t('common.cancel') }} +
    + +
    +
    + {{ t('common.important') }}: {{ t('duplicates.order_notice') }} +
    +
    + + + + + + + + + {% for asset in assets %} + + + + + + + + + + + + {% endfor %} + +
    {{ t('common.order') }}{{ t('assets.asset_id') }}{{ t('common.category') }}{{ t('field.name') }}{{ t('field.model') }}{{ t('field.serial_number') }}{{ t('field.hostname') }}{{ t('field.notes') }}{{ t('field.mesh_node_id') }}
    {{ asset.id }}{{ asset.category.name }}{{ asset.name }}{{ asset.model or '' }}{{ asset.serial_number or '' }}{{ asset.hostname or '' }}{{ asset.notes or '' }}{{ asset.mesh_node_id or '' }}
    +
    +
    + {{ t('common.cancel') }} + +
    +
    + + +{% endblock %} diff --git a/app/templates/assets_merge_preview.html b/app/templates/assets_merge_preview.html new file mode 100644 index 0000000..daeda8a --- /dev/null +++ b/app/templates/assets_merge_preview.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('duplicates.preview_title') }}

    +

    {{ t('duplicates.preview_help').format(id=assets[0].id) }}

    +
    +
    + +
    +

    {{ t('duplicates.result') }}

    + + + + {% for item in preview %} + + {% endfor %} + +
    {{ t('common.field') }}{{ t('duplicates.result') }}{{ t('duplicates.source') }}
    {{ item.label }}{{ item.value }}Asset {{ item.source_asset_id }}
    +
    + +
    +

    {{ t('common.order') }}

    +
      + {% for asset in assets %} +
    1. Asset {{ asset.id }} – {{ asset.name }}{% if loop.first %} ({{ t('duplicates.survives') }}){% else %} ({{ t('duplicates.will_delete') }}){% endif %}
    2. + {% endfor %} +
    +
    + +
    + {% for asset in assets %}{% endfor %} +
    + + +
    +
    +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..bdc91c6 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,126 @@ +{% set cfg = application_config() %}{% set appinfo = application_info() %}{% set general = cfg.get('general', {}) %}{% set colors = general.get('colors', {}) %}{% set auth = cfg.get('authentication', {}) %}{% set session_user = request.session.get('user') if request else None %}{% set current_language = (session_user.language_code if session_user else request.session.get('language_code', general.get('default_language', 'en'))) %}{% set theme_mode = session_user.theme_mode if session_user and session_user.theme_mode in ['light','dark'] else general.get('default_theme', 'light') %} + +{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %} +
    +{% if general.get('logo') %}Logo{% endif %}{{ general.get('title', 'AssetManager') }}{% if general.get('company_name') %}– {{ general.get('company_name') }}{% endif %} + +
    +{% if session_user %} +
    + + + +
    +{% endif %} + +
    🌐 {{ current_language|upper }} ▾
    {% for language in available_languages() %}
    {% endfor %}
    +
    👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾
    {% if session_user %}{{ t('app.profile') }}{{ t('app.logout') }}{% else %}{% if auth.get('mode', 'none') == 'none' %}Authentication disabled{% endif %}{{ t('app.login') }}{% endif %}
    +
    + +
    +
    +

    {{ general.get('title', 'AssetManager') }}

    + +
    + {% set fixed_manufacturer_logo = manufacturer_logo_path() %} + {% if fixed_manufacturer_logo %}{% endif %} +
    +
    {{ t('about.version') }}
    {{ application_version() }}
    + {% if appinfo.author %}
    {{ t('about.author') }}
    {{ appinfo.author }}
    {% endif %} + {% if appinfo.organization %}
    {{ t('about.organization') }}
    {{ appinfo.organization }}
    {% endif %} + {% if appinfo.contact %}
    {{ t('about.contact') }}
    {{ appinfo.contact }}
    {% endif %} + {% if appinfo.license %}
    {{ t('about.license') }}
    {{ appinfo.license }}
    {% endif %} + {% if appinfo.website %}
    {{ t('about.website') }}
    {{ appinfo.website }}
    {% endif %} + {% if appinfo.repository %}
    {{ t('about.repository') }}
    {{ appinfo.repository }}
    {% endif %} + {% if appinfo.copyright %}
    {{ t('about.copyright') }}
    {{ appinfo.copyright }}
    {% endif %} +
    +
    +
    +
    + +
    + diff --git a/app/templates/categories.html b/app/templates/categories.html new file mode 100644 index 0000000..01ca8e7 --- /dev/null +++ b/app/templates/categories.html @@ -0,0 +1,296 @@ +{% extends 'base.html' %} +{% block content %} +
    +

    {{ t('app.categories') }}

    +
    + + + + {{ t('categories.create') }} +
    +
    + +
    + + +
    +
    +

    {{ t('categories.bulk.dialog_title', 'Markierte Kategorien ändern') }}

    + +
    +

    + + +

    {{ t('categories.bulk.name_hint', 'Der Name kann nur geändert werden, wenn genau eine Kategorie markiert ist.') }}

    + + + +
    + + +
    +
    +
    + + +
    +
    +

    {{ t('categories.fields.manage', 'Felder verwalten') }}

    + +
    +

    + + +
    + + + + +
    + + +
    +

    {{ t('categories.fields.add_help', 'Die ausgewählten Felder werden in allen markierten Kategorien aktiviert. Bereits vorhandene Zuordnungen bleiben erhalten.') }}

    +
    + {% for definition in definitions %} + + {% endfor %} +
    +
    + + + + + + + +
    + + +
    +
    +
    + +
    + + + + + + + + + + + + + + + {% for c in categories %} + {% set count = asset_counts.get(c.id, 0) %} + {% set active_fields = c.visible_fields|selectattr('active')|list %} + {% set list_fields = active_fields|selectattr('show_in_list')|list %} + + + + + + + + + + + {% endfor %} + +
    + + {{ t('common.name') }}{{ t('common.assets') }}{{ t('categories.fields_total', 'Felder gesamt') }}{{ t('categories.active_fields') }}{{ t('categories.list_fields', 'In Liste') }}{{ t('categories.attributes', 'Aktive Attribute') }}{{ t('common.actions') }}
    + + {{ c.name }}{{ count }}{{ c.visible_fields|length }}{{ active_fields|length }}{{ list_fields|length }} + {% for field in active_fields %} + {{ field_label(field) }} + {% else %} + {{ t('categories.no_active_fields', 'Keine aktiven Felder') }} + {% endfor %} + +
    + {{ t('common.edit') }} + + +
    +
    +
    +
    +
    + + +{% endblock %} diff --git a/app/templates/category_form.html b/app/templates/category_form.html new file mode 100644 index 0000000..07f7a46 --- /dev/null +++ b/app/templates/category_form.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %}{% block content %} +

    {% if category %}{{ t('categories.edit') }}{% else %}{{ t('categories.create') }}{% endif %}

    +
    + + + +

    {{ t('categories.fields_title') }}

    +

    Labels, descriptions, data types and read-only settings are managed centrally under Settings → Fields.

    + +{% for definition in definitions %}{% set cfg=selected_fields.get(definition.field_name) %} + + + + +{% endfor %}
    {{ t('common.active') }}{{ t('categories.database_field') }}{{ t('categories.display_name') }}{{ t('categories.required') }}{{ t('categories.in_list') }}{{ t('categories.readonly') }}
    {{definition.field_name}}{{ t(definition.translation_key, definition.label) }}{% if definition.description %}
    {{definition.description}}
    {% endif %}
    {{ t('common.yes') if definition.readonly else t('common.no') }}
    +
    {% endblock %} diff --git a/app/templates/chart_form.html b/app/templates/chart_form.html new file mode 100644 index 0000000..a2043ca --- /dev/null +++ b/app/templates/chart_form.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% block content %} +

    {% if chart %}{{ t('charts.edit') }}{% else %}{{ t('charts.create') }}{% endif %}

    +{% if chart and chart.is_system %}

    {{ t('charts.system_help') }}

    {% endif %} +
    + + +
    + + + + + + +
    + + + +
    + {{ t('charts.status_filter') }} +

    {{ t('charts.status_filter_help') }}

    +
    + {% for option in status_options %} + + {% else %}

    {{ t('charts.no_status_values') }}

    {% endfor %} +
    +
    + + +
    {{ t('common.cancel') }}
    +
    +{% endblock %} diff --git a/app/templates/charts.html b/app/templates/charts.html new file mode 100644 index 0000000..d519d31 --- /dev/null +++ b/app/templates/charts.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %}{% block content %} +

    {{ t('app.charts') }}

    {{ t('charts.visible_assets_hint') }}

    {% if is_admin %}⚙ {{ t('charts.manage') }}{% endif %}
    +
    {% for item in charts %}{% include '_chart.html' %}{% else %}
    {{ t('charts.none') }}
    {% endfor %}
    +{% endblock %} diff --git a/app/templates/charts_manage.html b/app/templates/charts_manage.html new file mode 100644 index 0000000..9aef471 --- /dev/null +++ b/app/templates/charts_manage.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% block content %} +
    +

    {{ t('charts.manage') }}

    + + {{ t('charts.create') }} +
    +
    + + + + {% for chart in charts %} + + + + + + + + + {% else %}{% endfor %} + +
    {{ t('common.name') }}{{ t('charts.type') }}{{ t('charts.grouping') }}{{ t('charts.status_filter') }}{{ t('charts.dashboard') }}{{ t('common.actions') }}
    {{ chart.name }}{% if chart.is_system %}
    {{ t('charts.system_chart') }}{% endif %}{% if chart.description %}
    {{ chart.description }}{% endif %}
    {{ chart_types.get(chart.chart_type, chart.chart_type) }}{{ chart_fields.get(chart.group_field, chart.group_field) }}{% if chart.stack_field %} / {{ chart_fields.get(chart.stack_field, chart.stack_field) }}{% endif %}{% if chart.excluded_statuses %}{{ t('charts.excluded') }}: {{ chart.excluded_statuses|join(', ') }}{% else %}–{% endif %}{{ t('common.yes') if chart.dashboard_visible else t('common.no') }} + ✏ {{ t('common.edit') }} +
    + {% if not chart.is_system %}
    {% endif %} +
    {{ t('charts.none') }}
    +
    +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..5919921 --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% block content %} +

    {{ t('dashboard.overview') }}

    {% if is_admin %}➕ {{ t('assets.create') }}{% endif %}
    + +{% if dashboard_charts %}

    {{ t('app.charts') }}

    {{ t('charts.show_all') }}
    {% for item in dashboard_charts %}{% include '_chart.html' %}{% endfor %}
    {% endif %} +

    {{ t('dashboard.recently_changed') }}

    +

    {{ t('dashboard.list_fields_hint') }}

    +
    {% for f in fields if f.field_name != 'name' %}{% endfor %} +{% for a in assets %}{% for f in fields if f.field_name != 'name' %}{% endfor %}{% else %}{% endfor %} +
    {{ t('assets.name') }}{{ t('assets.category') }}{{ t('field.' ~ f.field_name, f.label) }}{{ t('common.actions') }}
    {{a.name}}{{a.category.name}}{{a|attr(f.field_name) or ''}}{% if is_admin %}{% if issue_status_name and a.status == issue_status_name %}
    {% else %}{% endif %}
    {% else %}{{ t('common.read_only') }}{% endif %}
    {{ t('assets.none') }}
    +{% if is_admin %}

    {{ t('assets.issue_asset') }}

    {% endif %} +{% endblock %} diff --git a/app/templates/field_definition_form.html b/app/templates/field_definition_form.html new file mode 100644 index 0000000..5971f9b --- /dev/null +++ b/app/templates/field_definition_form.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %}{% block content %}

    {% if definition %}Edit field{% else %}Add field{% endif %}

    +
    +{% if not definition %}{% else %}{% endif %} + + +
    {% if definition and not definition.is_system %}{% endif %}
    +
    {% for key,label in [('searchable','Searchable'),('filterable','Filterable'),('chart_enabled','Available for charts'),('excel_import','Excel import'),('excel_export','Excel export'),('bulk_edit','Bulk edit')] %}{% endfor %}
    +
    {% endblock %} diff --git a/app/templates/field_definitions.html b/app/templates/field_definitions.html new file mode 100644 index 0000000..f5c5b16 --- /dev/null +++ b/app/templates/field_definitions.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %}{% block content %} + +

    Central labels and data types apply immediately to all categories, forms, tables and exports.

    +
    +{% for d in definitions %}{% endfor %} +
    FieldLabelDescriptionData typeRead onlySystemStored values{{ t('common.actions') }}
    {{d.field_name}}{{d.label}}{{d.description or ''}}{{data_types.get(d.data_type,d.data_type)}}{{t('common.yes') if d.readonly else t('common.no')}}{{t('common.yes') if d.is_system else t('common.no')}}{{value_counts.get(d.id,0)}}{{t('common.edit')}}{% if not d.is_system %}
    {% endif %}
    {% endblock %} diff --git a/app/templates/job_definition_form.html b/app/templates/job_definition_form.html new file mode 100644 index 0000000..110b5a5 --- /dev/null +++ b/app/templates/job_definition_form.html @@ -0,0 +1,182 @@ +{% extends 'base.html' %} +{% block content %} +

    {{ t('jobdefs.edit') if definition else t('jobdefs.new') }}

    {{ t('jobdefs.admin_only') }}

    {{ t('common.back') }}
    +
    + + + + + + + + +
    + +
    +
    +

    {{ t('jobdefs.script_help.title') }}

    +

    {{ t('jobdefs.script_help.common') }}

    +
    +

    {{ t('jobdefs.script_help.variables') }}

    + {% raw %}{{JobId}}, {{AssetName}}, {{Hostname}}, {{IPAddress}}, {{CurrentUser}}, {{SerialNumber}}, {{Manufacturer}}, {{Model}}, {{Department}}, {{Location}}, {{MeshNodeId}}{% endraw %} +

    {{ t('jobdefs.script_help.parameters') }} {% raw %}{{Param.ParameterKey}}{% endraw %}

    +
    +
    +

    {{ t('jobdefs.script_help.powershell') }}

    +
    $count = 3
    +Write-JobLog "Gefundene Dateien: $count"
    +$JobResult["processed_count"] = $count
    +
    +
    +

    {{ t('jobdefs.script_help.python') }}

    +
    count = 3
    +print(f"Processed files: {count}")
    +job_result["processed_count"] = count
    +
    +
    +

    {{ t('jobdefs.script_help.bash') }}

    +
    count=3
    +echo "Processed files: $count"
    +{% raw %}echo "Asset: {{Hostname}}"{% endraw %}
    +
    +
    +

    {{ t('jobdefs.script_help.cmd') }}

    +
    set "COUNT=3"
    +echo Processed files: %COUNT%
    +{% raw %}echo Asset: {{Hostname}}{% endraw %}
    +
    +

    {{ t('jobdefs.script_help.wrapper_sources') }}

    +
    +
    + +

    {{ t('jobparams.title') }}

    {{ t('jobparams.help') }}

    + +
    + + + + +
    {% if definition %}{% endif %}
    +
    +{% if test_result %}

    {{ t('jobdefs.test_result') }}

    {{ test_result }}
    {% endif %} +{% if script_preview is not none %}

    {{ t('jobdefs.preview') }}

    {{ t('jobdefs.preview_help') }}

    {{ script_preview }}
    {% if preview_values %}
    {{ t('jobdefs.preview.sample_notice') }}
    {% for key,value in preview_values.items() %}{{ key }} = {{ value }}
    +{% endfor %}
    {% endif %}
    {% endif %} + + +{% endblock %} diff --git a/app/templates/job_definitions.html b/app/templates/job_definitions.html new file mode 100644 index 0000000..a118b7f --- /dev/null +++ b/app/templates/job_definitions.html @@ -0,0 +1,82 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('jobdefs.title') }}

    +

    {{ t('jobdefs.description') }}

    +
    + {{ t('jobdefs.new') }} +
    + +
    +
    + + + + + {% if search_text or not active_only %}{{ t('jobdefs.filter.reset') }}{% endif %} +
    +
    + +
    + + + + + + + + + + + + + + {% for row in definition_rows %} + {% set item = row.item %} + + + + + + + + + + {% else %} + + {% endfor %} + +
    {{ t('jobdefs.status') }}{{ t('common.name') }}{{ t('jobdefs.platform') }}{{ t('jobdefs.interpreter') }}{{ t('jobdefs.source') }}{{ t('jobdefs.revision') }}{{ t('common.actions') }}
    +
    + {{ t('jobdefs.status.active') if item.enabled else t('jobdefs.status.inactive') }} + {% if item.is_system %}{{ t('jobdefs.system') }}{% endif %} +
    +
    {{ item.name }}
    {{ item.description or '' }}
    {{ t('jobdefs.platform.' ~ item.platform, item.platform) }}{{ t('jobdefs.interpreter.' ~ item.interpreter, item.interpreter) }}{{ t('jobdefs.source.' ~ item.source_type, item.source_type) }}{{ item.revision }} +
    + {{ t('common.edit') }} + {% if row.can_delete %} +
    + +
    + {% else %} + + {% endif %} +
    +
    {{ t('jobdefs.none_filtered') if search_text or active_only else t('jobdefs.none') }}
    +
    +

    {{ t('jobdefs.delete.behavior_title') }}

    {{ t('jobdefs.delete.behavior') }}

    + + +{% endblock %} diff --git a/app/templates/jobs/bash_test.txt b/app/templates/jobs/bash_test.txt new file mode 100644 index 0000000..ddb70b0 --- /dev/null +++ b/app/templates/jobs/bash_test.txt @@ -0,0 +1,8 @@ +echo "AssetManager test job" +echo "JobId={{JobId}}" +echo "AssetName={{AssetName}}" +echo "Hostname={{Hostname}}" +echo "IPAddress={{IPAddress}}" +echo "Department={{Department}}" +echo "Location={{Location}}" +echo "MeshNodeId={{MeshNodeId}}" diff --git a/app/templates/jobs/bash_wrapper.sh b/app/templates/jobs/bash_wrapper.sh new file mode 100644 index 0000000..1d6a8f1 --- /dev/null +++ b/app/templates/jobs/bash_wrapper.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +CALLBACK_URL='{{CallbackUrl}}' +JOB_ID='{{JobId}}' +callback() { + local status="$1" code="$2" message="$3" attempt + for attempt in 1 2 3; do + if curl -fsS --max-time 60 -X POST -H 'Content-Type: application/json' --data "{\"status\":\"${status}\",\"exit_code\":${code},\"completed\":true,\"message\":\"${message}\"}" "$CALLBACK_URL" >/dev/null; then + return 0 + fi + echo "Callback attempt ${attempt} of 3 failed" >&2 + if [ "$attempt" -lt 3 ]; then sleep $((attempt * 2)); fi + done + return 1 +} +trap 'code=$?; callback failed "$code" "Job failed" || true; exit "$code"' ERR +echo "$(date --iso-8601=seconds) job=${JOB_ID} started" +# --- Eigene Befehle hier einfügen --- + +# --- Ende eigene Befehle --- +callback success 0 'Job completed' diff --git a/app/templates/jobs/cmd_test.txt b/app/templates/jobs/cmd_test.txt new file mode 100644 index 0000000..db9fbdd --- /dev/null +++ b/app/templates/jobs/cmd_test.txt @@ -0,0 +1,8 @@ +echo AssetManager test job +echo JobId={{JobId}} +echo AssetName={{AssetName}} +echo Hostname={{Hostname}} +echo IPAddress={{IPAddress}} +echo Department={{Department}} +echo Location={{Location}} +echo MeshNodeId={{MeshNodeId}} diff --git a/app/templates/jobs/cmd_wrapper.cmd b/app/templates/jobs/cmd_wrapper.cmd new file mode 100644 index 0000000..64d0c00 --- /dev/null +++ b/app/templates/jobs/cmd_wrapper.cmd @@ -0,0 +1,15 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion +set "JOB_ID={{JobId}}" +set "CALLBACK_URL={{CallbackUrl}}" +rem --- Eigene Befehle hier einfügen --- + +rem --- Ende eigene Befehle --- +set "EXIT_CODE=%ERRORLEVEL%" +if not "%EXIT_CODE%"=="0" goto :failed +powershell -NoProfile -ExecutionPolicy Bypass -Command "$body=@{status='success';exit_code=0;completed=$true;message='Job completed'}|ConvertTo-Json -Compress; $last=$null; for($attempt=1;$attempt -le 3;$attempt++){try{Invoke-RestMethod -Method Post -Uri '%CALLBACK_URL%' -ContentType 'application/json' -Body $body -TimeoutSec 60|Out-Null; exit 0}catch{$last=$_.Exception; if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}}}; Write-Error ('Callback failed after 3 attempts: '+$last.Message); exit 1" +if errorlevel 1 exit /b 1 +exit /b 0 +:failed +powershell -NoProfile -ExecutionPolicy Bypass -Command "$body=@{status='failed';exit_code=%EXIT_CODE%;completed=$true;message='Job failed'}|ConvertTo-Json -Compress; for($attempt=1;$attempt -le 3;$attempt++){try{Invoke-RestMethod -Method Post -Uri '%CALLBACK_URL%' -ContentType 'application/json' -Body $body -TimeoutSec 60|Out-Null; break}catch{if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}}}" +exit /b %EXIT_CODE% diff --git a/app/templates/jobs/powershell_test.txt b/app/templates/jobs/powershell_test.txt new file mode 100644 index 0000000..a165dbc --- /dev/null +++ b/app/templates/jobs/powershell_test.txt @@ -0,0 +1,8 @@ + Write-JobLog "AssetManager test job" + Write-JobLog "JobId={{JobId}}" + Write-JobLog "AssetName={{AssetName}}" + Write-JobLog "Hostname={{Hostname}}" + Write-JobLog "IPAddress={{IPAddress}}" + Write-JobLog "Department={{Department}}" + Write-JobLog "Location={{Location}}" + Write-JobLog "MeshNodeId={{MeshNodeId}}" diff --git a/app/templates/jobs/powershell_wrapper.ps1 b/app/templates/jobs/powershell_wrapper.ps1 new file mode 100644 index 0000000..c712e19 --- /dev/null +++ b/app/templates/jobs/powershell_wrapper.ps1 @@ -0,0 +1,45 @@ +$ErrorActionPreference = 'Stop' +$log = Join-Path $env:ProgramData 'AssetManager\commands.log' +$clientLog = [System.Collections.Generic.List[string]]::new() +$JobResult = @{} +New-Item -ItemType Directory -Force -Path (Split-Path $log) | Out-Null +function Send-JobCallback([hashtable]$Data) { + $json = $Data | ConvertTo-Json -Depth 8 -Compress + $bodyBytes = [Text.Encoding]::UTF8.GetBytes($json) + $lastError = $null + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-RestMethod -Method Post -Uri '{{CallbackUrl}}' -ContentType 'application/json; charset=utf-8' -Body $bodyBytes -TimeoutSec 60 | Out-Null + if ($attempt -gt 1) { Write-JobLog ("Callback accepted on attempt $attempt of 3") } + return + } catch { + $lastError = $_.Exception + Write-JobLog ("Callback attempt $attempt of 3 failed: " + $_.Exception.Message) + if ($attempt -lt 3) { Start-Sleep -Seconds (2 * $attempt) } + } + } + throw ("Callback failed after 3 attempts: " + $lastError.Message) +} +function Write-JobLog([string]$Message) { + $line = "$(Get-Date -Format o) job={{JobId}} $Message" + $clientLog.Add($line) + Add-Content -Path $log -Value $line -Encoding UTF8 + Write-Output $line +} +try { + Write-JobLog 'Job started' + try { Send-JobCallback @{status='running'; completed=$false; message='Job started on client'; log=($clientLog -join "`n")} } catch { Write-JobLog ("Initial callback failed: " + $_.Exception.Message) } + # --- Eigene Befehle hier einfügen --- + + # --- Ende eigene Befehle --- + Write-JobLog 'Job completed' + $bodyData = @{status='success'; exit_code=0; completed=$true; message='Job completed'; log=($clientLog -join "`n")} + if ($JobResult -is [System.Collections.IDictionary]) { foreach ($key in $JobResult.Keys) { $bodyData[$key] = $JobResult[$key] } } + $body = $bodyData | ConvertTo-Json -Depth 8 -Compress + Send-JobCallback $bodyData +} catch { + Write-JobLog ("Job failed: " + $_.Exception.Message) + $body = @{status='failed'; exit_code=1; completed=$true; message=$_.Exception.Message; log=($clientLog -join "`n"); stderr=($_ | Out-String)} | ConvertTo-Json -Compress + try { Send-JobCallback @{status='failed'; exit_code=1; completed=$true; message=$_.Exception.Message; log=($clientLog -join "`n"); stderr=($_ | Out-String)} } catch {} + exit 1 +} diff --git a/app/templates/jobs/python_test.txt b/app/templates/jobs/python_test.txt new file mode 100644 index 0000000..46659f7 --- /dev/null +++ b/app/templates/jobs/python_test.txt @@ -0,0 +1,8 @@ + print("AssetManager test job") + print("JobId={{JobId}}") + print("AssetName={{AssetName}}") + print("Hostname={{Hostname}}") + print("IPAddress={{IPAddress}}") + print("Department={{Department}}") + print("Location={{Location}}") + print("MeshNodeId={{MeshNodeId}}") diff --git a/app/templates/jobs/python_wrapper.py.txt b/app/templates/jobs/python_wrapper.py.txt new file mode 100644 index 0000000..c8972fa --- /dev/null +++ b/app/templates/jobs/python_wrapper.py.txt @@ -0,0 +1,45 @@ +import datetime +import json +import time +import traceback +import urllib.request + + +def callback(payload): + data = json.dumps(payload).encode("utf-8") + last_error = None + for attempt in range(1, 4): + request = urllib.request.Request( + "{{CallbackUrl}}", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + urllib.request.urlopen(request, timeout=60).read() + return + except Exception as exc: + last_error = exc + print(f"Callback attempt {attempt} of 3 failed: {exc}") + if attempt < 3: + time.sleep(2 * attempt) + raise RuntimeError(f"Callback failed after 3 attempts: {last_error}") + + +job_result = {} + + +try: + print(datetime.datetime.now(datetime.timezone.utc).isoformat(), "job={{JobId}} started") + # --- Eigene Befehle hier einfügen --- + + # --- Ende eigene Befehle --- + payload = {"status": "success", "exit_code": 0, "completed": True, "message": "Job completed"} + if isinstance(job_result, dict): + payload.update(job_result) + callback(payload) +except Exception as exc: + try: + callback({"status": "failed", "exit_code": 1, "completed": True, "message": str(exc), "stderr": traceback.format_exc()}) + finally: + raise diff --git a/app/templates/ldap_search.html b/app/templates/ldap_search.html new file mode 100644 index 0000000..e9cee58 --- /dev/null +++ b/app/templates/ldap_search.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('ldap.title') }}

    +

    {{ t('ldap.description') }}

    +
    + +
    + +
    + +
    + + {{ t('ldap.show_all') }} +
    +

    {{ t('ldap.search_help') }}

    +
    + +{% if loaded %} +
    +
    +

    {{ t('ldap.users_found', count=results|length) }}

    +
    + +
    +
    +
    + + + + + + + + + + + {% for item in results %} + + + + + + + + + {% endfor %} + +
    {{ t('ldap.username') }}{{ t('ldap.display_name') }}{{ t('ldap.email') }}{{ t('ldap.status') }}{{ t('ldap.dn') }}
    {{ item.username }}{{ item.display_name }}{{ item.email }}{% if item.existing %}{{ t('ldap.exists') }}{% else %}{{ t('ldap.can_save') }}{% endif %}{{ item.dn }}
    +
    +
    + +{% endif %} +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..f0e6647 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% block content %} +
    +

    {{ t('login.title') }}

    +
    + + + +
    +
    +{% endblock %} diff --git a/app/templates/meshcentral_sync.html b/app/templates/meshcentral_sync.html new file mode 100644 index 0000000..47fd8f8 --- /dev/null +++ b/app/templates/meshcentral_sync.html @@ -0,0 +1,75 @@ +{% extends 'base.html' %}{% block content %} +

    {{t('mesh.title')}}

    +
    {{linked}}{{t('mesh.linked_assets')}}
    {{conflicts}}{{t('mesh.conflicts')}}
    {{missing}}{{t('mesh.missing')}}
    + +

    {{t('mesh.live_log')}}

    {{t('mesh.ready')}}
    {{t('mesh.ready_message')}}
    + +
    +

    1. {{ t('mesh.connection') }}

    +

    {{ t('mesh.password_env_help') }}

    +
    +
    + + + + + + + +
    +
    + + + + + +
    +

    {{ t('mesh.asset_matching') }}

    + +
    +
    + +
    +

    2. {{ t('mesh.field_mappings') }}

    +

    {{ t('mesh.mappings_help') }} {{ t('mesh.mappings_examples') }}: node.osdesc, sys.hardware.windows.cpu[].Name, node.name|name.

    +
    +
    +{% for m in mappings %} + + + + + + + + + + + + +{% endfor %} +
    {{ t('common.active') }}{{ t('mesh.priority') }}{{ t('mesh.target_field') }}Source Path{{ t('mesh.transformation') }}{{ t('mesh.multi_values') }}{{ t('mesh.separator') }}{{ t('mesh.update_rule') }}{{ t('common.description') }}
    {% if m.is_system %}{{ t('mesh.system_mapping') }}{% else %}{{ t('mesh.custom_mapping') }}{% endif %}{% if not m.is_system %}{% endif %}
    + +
    +
    + +
    +

    3. {{ t('mesh.new_mapping') }}

    +

    {{ t('mesh.new_mapping_help') }}

    +
    +
    + + + + + + + + +
    +
    +
    + +

    {{t('mesh.recent_runs')}}

    {% for run in runs %}{% else %}{% endfor %}
    {{t('mesh.start_time')}}{{t('assets.status')}}{{t('mesh.found')}}{{t('mesh.created')}}{{t('mesh.changed')}}{{t('mesh.conflicts')}}{{t('mesh.errors')}}{{t('mesh.message')}}
    {{run.status}}{{run.devices_found}}{{run.created_count}}{{run.updated_count}}{{run.conflict_count}}{{run.error_count}}{{run.message or ''}}
    {{t('mesh.no_runs')}}
    + +{% endblock %} diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..a4e9c4d --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,87 @@ +{% extends 'base.html' %} +{% block content %} + + +
    +

    {{ t('profile.title') }}

    + {{ t('app.logout') }} +
    + +
    +
    +
    +
    {{ t('profile.username') }}
    {{ user.username }}
    +
    {{ t('profile.display_name') }}
    {{ user.display_name or '' }}
    +
    {{ t('profile.email') }}
    {{ user.email or '' }}
    +
    {{ t('profile.source') }}
    {{ user.auth_source }}
    +
    {{ t('profile.last_login') }}
    {% if user.last_login %}{% else %}{{ '' }}{% endif %}
    +
    +
    + +
    +
    +

    {{ t('common.language') }}

    + + + +
    + +
    +

    {{ t('appearance.theme') }}

    +

    {{ t('appearance.profile_help') }}

    + +
    + + +
    + +
    + +

    {{ t('profile.browser_storage') }}

    {{ t('profile.browser_storage_help') }}

    + +
    +

    {{ t('profile.toast_settings') }}

    + + + +
    +{% if user.auth_source == 'local' %} +
    +

    {{ t('profile.change_password') }}

    + + + +
    + {% endif %} +
    +
    +{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..9fceb2e --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,260 @@ +{% extends 'base.html' %} +{% block content %} +{% set appinfo = application_info() %} + + +

    {{ t("app.settings") }}

    +
    +
    +

    {{ t("settings.appearance") }}

    + + + + {% if settings.general.logo %}
    {{ t('settings.current_logo') }}
    {% endif %} + + + {% if settings.general.favicon %} +
    + {{ t('settings.current_favicon', 'Aktuelles Browser-Tab-Logo') }} + +
    + {% endif %} + + +
    + + {% set theme_colors = settings.general.get('colors', {}) %} +
    +
    +

    {{ t('appearance.colors_title') }}

    {{ t('appearance.colors_help') }}

    + +
    +
    +
    + {% set color_fields = [ + ('primary_color','appearance.primary_color','#0878bd'), + ('primary_hover_color','appearance.primary_hover_color','#075f94'), + ('header_color','appearance.header_color','#063c66'), + ('navigation_color','appearance.navigation_color','#ffffff'), + ('page_background','appearance.page_background','#f2f4f6'), + ('surface_color','appearance.surface_color','#ffffff'), + ('text_color','appearance.text_color','#1f2933'), + ('border_color','appearance.border_color','#d7dde3'), + ('success_color','appearance.success_color','#198754'), + ('warning_color','appearance.warning_color','#d97706'), + ('danger_color','appearance.danger_color','#b42318') + ] %} + {% for name, label_key, default in color_fields %} + + {% endfor %} +
    +
    +

    {{ t('appearance.color_preview') }}

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +

    {{ t('settings.system_information') }}

    +

    {{ t('settings.system_information_file_help') }}

    +
    +
    {{ t('settings.appinfo_path') }}
    /app/config/APPINFO.json
    +
    {{ t('about.version') }}
    {{ application_version() }}
    +
    {{ t('about.author') }}
    {{ appinfo.author or '—' }}
    +
    {{ t('about.organization') }}
    {{ appinfo.organization or '—' }}
    +
    {{ t('about.contact') }}
    {{ appinfo.contact or '—' }}
    +
    {{ t('about.website') }}
    {% if appinfo.website %}{{ appinfo.website }}{% else %}—{% endif %}
    +
    {{ t('about.repository') }}
    {% if appinfo.repository %}{{ appinfo.repository }}{% else %}—{% endif %}
    +
    {{ t('about.license') }}
    {{ appinfo.license or '—' }}
    +
    {{ t('about.copyright') }}
    {{ appinfo.copyright or '—' }}
    +
    + +

    {{ t('settings.storage_information', 'Speicher und Laufzeit') }}

    +

    {{ t('settings.storage_information_help', 'Die folgenden Pfade und Zustände werden zur Laufzeit im Container ermittelt.') }}

    +
    +
    {{ t('settings.database', 'Datenbank') }}
    +
    ✓ {{ t('settings.database_connected', 'PostgreSQL verbunden') }}
    + +
    {{ t('settings.config_directory', 'Konfigurationsverzeichnis') }}
    +
    {{ system_storage.config.path }}
    + +
    {{ t('settings.upload_directory', 'Uploadverzeichnis') }}
    +
    {{ system_storage.uploads.path }}
    + +
    {{ t('settings.log_directory', 'Logverzeichnis') }}
    +
    {{ system_storage.logs.path }}
    + +
    {{ t('settings.backup_directory', 'Backupverzeichnis') }}
    +
    {{ system_storage.backups.path }}
    + +
    {{ t('settings.backup_writable', 'Backupverzeichnis beschreibbar') }}
    +
    + {% if system_storage.backups.writable %} + ✓ {{ t('common.yes', 'Ja') }} + {% else %} + ✕ {{ t('common.no', 'Nein') }} + {% if system_storage.backups.error %}
    {{ system_storage.backups.error }}{% endif %} + {% endif %} +
    + +
    {{ t('settings.backup_mount_detected', 'Eigenes Docker-Mount erkannt') }}
    +
    {% if system_storage.backups.is_mount %}✓ {{ t('common.yes', 'Ja') }}{% else %}— {{ t('common.no', 'Nein') }}{% endif %}
    + +
    {{ t('settings.latest_backup', 'Letztes Backup') }}
    +
    + {% if system_storage.latest_backup %} + + {% else %}—{% endif %} +
    + +
    {{ t('settings.backup_count', 'Anzahl vorhandener Backups') }}
    +
    {{ system_storage.backup_count }}
    + +
    {{ t('settings.backup_storage_used', 'Belegter Backup-Speicher') }}
    +
    {{ '%.2f'|format(system_storage.backup_total_size / 1024 / 1024) }} MB
    +
    +
    + +
    +

    {{ t('settings.custom_css') }}

    +

    {{ t('settings.custom_css_help') }}

    + +
    +
    +

    {{ t('settings.mesh_mapping') }}

    + {% set mesh = settings.meshcentral %} + {% set mapping = mesh.get('category_mapping', {}) %} +

    {{ t('settings.mesh_mapping_help') }}

    + +
    + {% set mesh_types = [ + ('1', 'settings.mesh_type_desktop'), + ('2', 'settings.mesh_type_notebook'), + ('3', 'settings.mesh_type_mobile'), + ('4', 'settings.mesh_type_server'), + ('5', 'settings.mesh_type_5'), + ('6', 'settings.mesh_type_6'), + ('7', 'settings.mesh_type_7'), + ('8', 'settings.mesh_type_linux_other') + ] %} + {% for type_id, type_label in mesh_types %} + + {% endfor %} +
    +
    +
    +

    {{ t('settings.authentication') }}

    +

    {{ t('settings.auth_help') }}

    + + {% set ldap = settings.authentication.get('ldap', {}) %} +
    +

    {{ t('settings.ldap_connection') }}

    +
    + + +
    +
    + + + + +
    +

    {{ t('settings.bind_password_help') }}

    🔎 {{ t('ldap.title') }}

    +
    +
    + +
    + + + +{% endblock %} diff --git a/app/templates/settings_backup.html b/app/templates/settings_backup.html new file mode 100644 index 0000000..ddbb2b2 --- /dev/null +++ b/app/templates/settings_backup.html @@ -0,0 +1,59 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +
    +

    {{ t('backup.title', 'Backups') }}

    +

    {{ t('backup.description', 'Create, download, upload and restore complete AssetManager backups.') }}

    +
    +
    + +
    +
    + +
    +
    + +
    {{ t('backup.directory', 'Backup directory') }}{{ backup_directory }}
    +
    +
    + +
    {{ t('backup.interval', 'Interval') }}{{ t('backup.every_hours', 'Every {hours} hours', hours=backup_interval_hours) }}
    +
    +
    + +
    {{ t('backup.retention', 'Retention') }}{{ t('backup.days', '{days} days', days=backup_retention_days) }}
    +
    +
    + +
    +
    +

    {{ t('backup.automatic_title', 'Automatic backup') }}

    {{ t('backup.automatic_help', 'Backups are created automatically according to the configured interval and deleted after the retention period.') }}

    + {{ t('backup.active', 'Active') }} +
    +

    {{ t('backup.contents_help', 'Each backup contains a PostgreSQL database dump, configuration, APPINFO and uploaded files. Environment variables and the .env file are not included.') }}

    +
    + +
    +

    {{ t('backup.upload_title', 'Upload backup') }}

    {{ t('backup.upload_help', 'Upload an AssetManager backup ZIP to make it available for download or restoration.') }}

    +
    + + +
    +
    + +
    +

    {{ t('backup.available', 'Available backups') }}

    {{ t('backup.available_help', 'Stored backups can be downloaded, restored or deleted here.') }}

    {{ backups|length }}
    + {% if backups %} +
    + + {% for backup in backups %} + + + + {% endfor %}
    {{ t('backup.file', 'File') }}{{ t('backup.created', 'Created') }}{{ t('backup.version', 'Version') }}{{ t('backup.reason', 'Reason') }}{{ t('backup.size', 'Size') }}{{ t('common.actions', 'Actions') }}
    {{ backup.name }}{% if not backup.valid %}
    {{ t('backup.invalid_metadata', 'Metadata could not be read') }}{% endif %}
    {{ backup.version if backup.version else t('backup.version_unknown') }}{{ t('backup.reason_' ~ (backup.reason or 'unknown'), backup.reason or '—') }}{{ '%.2f'|format(backup.size / 1024 / 1024) }} MB
    {{ t('backup.download', 'Download') }}
    +

    {{ t('backup.restore_title', 'Restore backup') }}

    {{ t('backup.restore_warning', 'The current database, configuration and uploads will be replaced. A safety backup is created immediately before restoration.') }}

    + {% else %}
    {{ t('backup.none_title', 'No backups yet') }}

    {{ t('backup.none', 'No backups are available yet.') }}

    {% endif %} +
    +
    +{% endblock %} diff --git a/app/templates/settings_backup_placeholder.html b/app/templates/settings_backup_placeholder.html new file mode 100644 index 0000000..e55c4d2 --- /dev/null +++ b/app/templates/settings_backup_placeholder.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('settings.backup', 'Sicherung') }}

    +
    +
    +
    +

    {{ t('settings.backup_planned', 'Datenbanksicherung ist für v0.3.15.0 vorgesehen') }}

    +

    {{ t('settings.backup_planned_help', 'Die automatische Sicherung alle 8 Stunden, eine Aufbewahrung von 3 Tagen sowie Download, Upload, manuelles Backup und Wiederherstellung werden im nächsten Entwicklungsschritt umgesetzt.') }}

    +

    {{ t('settings.backup_directory', 'Backup-Verzeichnis') }}: /app/data/backups

    +
    +{% endblock %} diff --git a/app/templates/settings_import_export.html b/app/templates/settings_import_export.html new file mode 100644 index 0000000..0d48eb1 --- /dev/null +++ b/app/templates/settings_import_export.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('settings.import_export', 'Import / Export') }}

    +

    {{ t('settings.import_export_help', 'Assets als Excel-Datei exportieren oder aus einer Excel-Datei importieren.') }}

    +
    +
    + +
    +
    +

    {{ t('assets.import', 'Import') }}

    +

    {{ t('settings.import_help', 'Vorhandene Assets über die Asset-ID aktualisieren und neue Assets ohne Asset-ID anlegen.') }}

    + +
    + +
    +

    {{ t('assets.export', 'Export') }}

    +

    {{ t('settings.export_help', 'Den aktuellen Assetbestand mit Asset-ID und allen exportierbaren Feldern herunterladen.') }}

    + +
    +
    +{% endblock %} diff --git a/app/templates/settings_software.html b/app/templates/settings_software.html new file mode 100644 index 0000000..6df8f47 --- /dev/null +++ b/app/templates/settings_software.html @@ -0,0 +1,341 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +
    +

    {{ t('software.settings.title') }}

    +

    {{ t('software.settings.description') }}

    +
    + {{ t('software.settings.back_to_software') }} +
    + +
    +
    +
    +
    +

    {{ t('software.settings.dispatch_title') }}

    +

    {{ t('software.settings.dispatch_help') }}

    +
    + {{ t('software.settings.dispatch_badge') }} +
    + +
    + +
    + {{ t('software.settings.timeout_check') }} + {{ t('software.settings.timeout_check_value') }} +
    +
    +
    + +
    +
    +
    +

    {{ t('software.settings.retry_title') }}

    +

    {{ t('software.settings.retry_help') }}

    +
    + {{ t('software.settings.retry_badge') }} +
    + +
    + + + + + + + + +
    + {{ t('software.settings.retry_statuses') }} + {{ t('software.settings.retry_statuses_value') }} +
    +
    +
    + +
    +
    +
    +

    {{ t('software.settings.callback_title') }}

    +

    {{ t('software.settings.callback_help') }}

    +
    + {{ t('software.settings.callback_badge') }} +
    + +
    + + + + + + + + + + +
    + {{ t('software.settings.health_url') }} + {{ callback_health_url or t('software.settings.automatic_url') }} +
    +
    +
    + +
    +
    +
    +

    {{ t('software.settings.debug_title') }}

    +

    {{ t('software.settings.debug_help') }}

    +
    +
    +
    + + + + +
    + {{ t('software.settings.debug_log_path') }} + {{ debug_log_path }} +
    +
    +
    + +
    +
    +
    +

    {{ t('software.settings.exclusions_title') }}

    +

    {{ t('software.settings.exclusions_help') }}

    +
    + {{ t('software.settings.active') }} +
    + +
    + {% for rule in software_settings.get('inventory_exclusion_rules', []) %} +
    + + + +
    + {% endfor %} +
    + + + +
    + + {{ t('software.settings.exclude_match_help') }} +
    +
    + +
    + +
    +
    + +
    +
    +
    +

    {{ t('software.settings.test_title') }}

    +

    {{ t('software.settings.test_help') }}

    +
    +
    + +
    +
    + +
    + {{ t('software.settings.log_auto_refresh') }} +
    + +
    + +
    +
    +
    +
    {{ debug_log or t('software.settings.no_log') }}
    +
    + +
    +

    {{ t('software.settings.future_title') }}

    +

    {{ t('software.settings.future_help') }}

    +
    + {{ t('software.settings.future_inventory') }} + {{ t('software.settings.future_retention') }} + {{ t('software.settings.future_packages') }} + {{ t('software.settings.future_security') }} +
    +
    +
    + + +{% endblock %} diff --git a/app/templates/software.html b/app/templates/software.html new file mode 100644 index 0000000..98f955e --- /dev/null +++ b/app/templates/software.html @@ -0,0 +1,128 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('software.title') }}

    +

    {{ t('software.central_description') }}

    +
    + {{ t('software.settings.menu') }} +
    + + + +
    +

    {{ t('software.lists') }}

    + +
    + +
    +
    +

    {{ t('software.recent_jobs') }}

    +
    + ▦ {{ t('job_states.open', 'Jobstatus je Asset') }} + +
    + + {% for filter_name, filter_value in job_filters.items() %} + {% if filter_value %}{% endif %} + {% endfor %} + + +
    +
    +
    +

    {{ t('software.jobs_filter_all_help') }}

    +
    +
    + + + + + + + + + + + + + + + + + {% for job in jobs %} + {% set can_bulk_restart = job.status not in ['success', 'sending', 'running'] and job.asset and job.asset.mesh_node_id %} + + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
    {{ t('software.job_id') }}{{ t('software.time') }}{{ t('common.assets') }}{{ t('jobs.type') }}{{ t('software.package') }}{{ t('jobs.action') }}{{ t('jobs.priority') }}{{ t('software.platform') }}{{ t('software.status') }}{{ t('software.details') }}{{ t('jobs.actions') }}
    {{ job.id }}{% if job.created_at %}{% else %}{{ '' }}{% endif %}{{ job.asset.name }}{{ t(job.display_job_type_key, job.display_job_type_fallback) }}{{ job.display_package_name }}{{ t('jobs.action.' ~ job.action, job.action) }}{{ job.priority }}{{ job.platform }}{{ t('software.status.' ~ job.status, job.status) }}{{ t('software.open') }}{% if job.status not in ['sending', 'running'] %}
    {% else %}{% endif %}
    {{ t('software.no_jobs') }}
    +
    +
    + + +{% endblock %} diff --git a/app/templates/software_installations.html b/app/templates/software_installations.html new file mode 100644 index 0000000..c827bb0 --- /dev/null +++ b/app/templates/software_installations.html @@ -0,0 +1,57 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('software.all_installations') }}

    +

    {{ t('software.all_installations_help') }}

    +
    + {{ t('common.back') }} +
    + +
    +
    +
    +

    {{ t('software.detailed_list') }}

    +

    {{ t('software.table_filter_help') }}

    +
    + {{ software_entries|length }} +
    +
    + + {{ t('software.selected_count') }}: 0 + +
    +
    + + + + + + + + + + + + + + {% for entry in software_entries %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
    {{ t('common.assets') }}{{ t('common.name') }}{{ t('about.version') }}{{ t('software.publisher') }}{{ t('software.architecture') }}{{ t('software.platform') }}{{ t('software.install_date') }}{{ t('software.last_seen') }}
    {{ entry.asset_name }}{{ entry.name }}{{ entry.version or '' }}{{ entry.publisher or '' }}{{ entry.architecture or '' }}{{ t('software.platform.' ~ (entry.platform or 'unknown'), entry.platform or 'unknown') }}{{ entry.install_date or '' }}{% if entry.last_seen_at %}{% else %}{{ '' }}{% endif %}
    {{ t('software.no_global_inventory') }}
    +
    +
    +{% endblock %} diff --git a/app/templates/software_job.html b/app/templates/software_job.html new file mode 100644 index 0000000..fad5530 --- /dev/null +++ b/app/templates/software_job.html @@ -0,0 +1,42 @@ +{% extends 'base.html' %}{% block content %} +

    {{ t('jobs.title', 'Job {id}').replace('{id}', job.id|string) }}

    {% if can_restart %}
    {% endif %}{{ t('common.back') }}
    +
    {{ t('software.job_id') }}
    {{ job.id }}
    {{ t('common.assets') }}
    {{ job.asset.name }}
    {{ t('jobs.type') }}
    {{ t(job.display_job_type_key, job.display_job_type_fallback) }}
    {{ t('software.package') }}
    {{ job.display_package_name }}
    {{ t('jobs.action') }}
    {{ t('jobs.action.' ~ job.action, job.action) }}
    {{ t('jobs.priority') }}
    {{ job.priority }}
    {{ t('software.status') }}
    {{ t('software.status.' ~ job.status, job.status) }}
    {{ t('software.platform') }}
    {{ job.platform }}
    {{ t('software.created') }}
    {% if job.created_at %}{% else %}{{ '' }}{% endif %}
    {{ t('jobs.created_by') }}
    {{ job.created_by or '—' }}
    {{ t('jobs.scheduled_at') }}
    {% if job.scheduled_at %}{% else %}{{ '—' }}{% endif %}
    {{ t('jobs.attempts') }}
    {{ job.attempt_count }} / {{ job.max_attempts }}
    {{ t('software.sent') }}
    {% if job.sent_at %}{% else %}{{ '' }}{% endif %}
    {{ t('software.finished') }}
    {% if job.finished_at %}{% else %}{{ '' }}{% endif %}
    {{ t('software.exit_code') }}
    {{ job.exit_code if job.exit_code is not none else '' }}
    {{ t('software.message') }}
    {{ job.message or '' }}
    +

    {{ t('jobs.parameters') }}

    {{ job.parameters | tojson(indent=2) }}

    {{ t('jobs.result') }}

    {{ job.result_data | tojson(indent=2) }}
    +

    {{ t('jobs.history') }}

    {% for event in events %}{% else %}{% endfor %}
    {{ t('software.time') }}{{ t('jobs.attempt') }}{{ t('jobs.event') }}{{ t('software.status') }}{{ t('software.message') }}{{ t('jobs.created_by') }}
    {{ event.attempt }}{{ t('jobs.event.' ~ event.event_type, event.event_type) }}{{ t('software.status.' ~ event.status, event.status or '—') }}{{ event.message or '' }}{{ event.created_by or '—' }}
    {{ t('jobs.history.empty') }}
    +

    {{ t('software.diagnostics') }}

    {{ t('software.local_log') }}: {% if job.platform == 'windows' %}C:\ProgramData\AssetManager\commands.log{% elif job.platform == 'linux' %}/var/log/assetmanager-commands.log{% elif job.platform == 'macos' %}/Library/Logs/AssetManager-commands.log{% else %}{% endif %}

    {{ t('software.callback_health') }}: /api/software-callback/health

    {{ t('software.callback_token_warning') }}

    {% if job.resolved_script %}{% endif %}
    {{ t('jobs.meshctrl_command') }}
    +

    {{ t('software.client_log') }}

    {{ job.log_text or t('software.no_callback') }}

    {{ t('software.meshctrl') }}

    {{ t('software.stdout') }}

    {{ job.meshctrl_stdout or '' }}

    {{ t('software.stderr') }}

    {{ job.meshctrl_stderr or '' }}
    + +{% endblock %} \ No newline at end of file diff --git a/app/templates/software_job_states.html b/app/templates/software_job_states.html new file mode 100644 index 0000000..624163f --- /dev/null +++ b/app/templates/software_job_states.html @@ -0,0 +1,54 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('job_states.title', 'Jobstatus je Asset') }}

    +

    {{ t('job_states.description', 'Aktueller kompakter Jobstatus pro Asset und Job. Die vollständige Historie bleibt in den einzelnen Jobs erhalten.') }}

    +
    + +
    + +
    +
    + + + + + + + + + + + + + + + + + + {% for row in job_states %} + {% set state = row.state %} + + + + + + + + + + + + + + {% else %} + + {% endfor %} + +
    {{ t('common.assets', 'Assets') }}{{ t('job_states.job', 'Job') }}{{ t('jobs.type', 'Job-Art') }}{{ t('software.status', 'Status') }}{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}{{ t('job_states.revision', 'Letzte Revision') }}{{ t('job_states.success_revision', 'Erfolgsrevision') }}{{ t('job_states.execution_count', 'Ausführungen') }}{{ t('job_states.last_job', 'Letzter Job') }}{{ t('job_states.updated_at', 'Aktualisiert') }}
    {{ row.asset.name }}{{ row.job_label }}{{ t(row.job_type_key, row.job_type_fallback) }}{{ t('software.status.' ~ state.last_status, state.last_status) }}{% if row.last_execution %}{% else %}{% endif %}{% if state.last_success_at %}{% else %}{% endif %}{% if state.definition_revision is not none %}{{ state.definition_revision }}{% else %}{% endif %}{% if state.last_success_revision is not none %}{{ state.last_success_revision }}{% else %}{% endif %}{{ state.execution_count }}{% if state.last_job_id %}{{ state.last_job_id }}{% else %}{% endif %}{% if state.updated_at %}{% else %}{% endif %}
    {{ t('job_states.empty', 'Noch keine Jobstatusdaten vorhanden.') }}
    +
    +
    +{% endblock %} diff --git a/app/templates/software_summary.html b/app/templates/software_summary.html new file mode 100644 index 0000000..dbe38e8 --- /dev/null +++ b/app/templates/software_summary.html @@ -0,0 +1,61 @@ +{% extends 'base.html' %} +{% block content %} +
    +
    +

    {{ t('software.aggregated_list') }}

    +

    {{ t('software.aggregated_list_help') }}

    +
    + {{ t('common.back') }} +
    + +
    +
    +
    +

    {{ t('software.summary_table') }}

    +

    {{ t('software.numeric_filter_help') }}

    +
    +
    +
    + + + +
    + {{ software_summary|length }} +
    +
    +
    + + + + + + + + + + + + + {% for row in software_summary %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
    {{ t('common.name') }}{{ t('software.publisher') }}{{ t('software.platform') }}{{ t('software.versions') }}{{ t('software.version_count') }}{{ t('software.asset_count') }}{{ t('software.entry_count') }}{{ t('software.last_seen') }}
    {{ row.name }}{{ row.publisher }}{{ t('software.platform.' ~ (row.platform or 'unknown'), row.platform or 'unknown') }}{{ row.versions }}{{ row.version_count }}{{ row.asset_count }}{{ row.entries }}{% if row.last_seen_at %}{% else %}{{ '' }}{% endif %}
    {{ t('software.no_global_inventory') }}
    +
    +
    +{% endblock %} diff --git a/app/templates/status_options.html b/app/templates/status_options.html new file mode 100644 index 0000000..0ed318a --- /dev/null +++ b/app/templates/status_options.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %}{% block content %} +

    {{ t('app.status_values') }}

    {{ t('status.help') }}

    +
    +{% for option in options %}{% endfor %} +
    {{ t('common.order') }}{{ t('assets.status') }}{{ t('common.active') }}{{ t('status.on_issue') }}{{ t('status.on_return') }}{{ t('common.actions') }}
    {{ t('common.new') }}
    +{% endblock %} diff --git a/app/templates/translations.html b/app/templates/translations.html new file mode 100644 index 0000000..437dd24 --- /dev/null +++ b/app/templates/translations.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %}{% block content %} +

    {{ t('translations.title') }}

    +
    +
    + + +
    +
    +
    +
    +
    {% for language in languages %}{% endfor %}{% for key, values in rows.items() %}{% for language in languages %}{% endfor %}{% endfor %}
    {{ t('translations.key') }}{{ language.native_name }} ({{ language.code }})
    {{ key }}{% if key in protected_keys %}{{ t('translations.builtin') }}{% endif %}
    +
    +
    +

    {{ t('translations.import_excel') }}

    +

    {{ t('translations.add') }}

    {% for language in languages %}{% endfor %}
    + +{% endblock %} diff --git a/app/templates/users.html b/app/templates/users.html new file mode 100644 index 0000000..90ff3a9 --- /dev/null +++ b/app/templates/users.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %}{% block content %} +

    {{ t('users.management') }}

    {{ t('users.permissions_help') }}

    +

    {{ t('users.create_local') }}

    +
    +{% for user in users %}{% else %}{% endfor %} +
    {{ t('users.user') }}{{ t('profile.source') }}{{ t('profile.last_login') }}{{ t('users.permissions_groups') }}
    {{ user.username }}{% if user.is_protected %}
    🔐 {{ t('users.protected_local_admin') }}{% endif %}
    {{ user.display_name or '' }}{% if user.email %} · {{ user.email }}{% endif %}{% if user.is_protected %}
    {{ t('users.protected_local_admin_help') }}{% endif %}
    {{ user.auth_source }}{% if user.last_login %}{% else %}{{ '' }}{% endif %}
    {% if user.auth_source == 'local' %}{% endif %}
    {{ t('users.asset_visibility') }}

    {{ t('users.asset_visibility_help') }}

    {{ t('users.scope_or_hint') }}

    {{ t('users.visible_mesh_groups') }}
    {% for group in mesh_groups %}{% else %}{{ t('users.no_mesh_groups') }}{% endfor %}
    {% if not user.is_protected %}
    {% endif %}
    {{ t('users.none') }}
    +

    {{ t('users.relogin_hint') }}

    +{% endblock %} diff --git a/app/version.py b/app/version.py new file mode 100644 index 0000000..d540d31 --- /dev/null +++ b/app/version.py @@ -0,0 +1,2 @@ +APP_VERSION = "0.5.5.27" +__version__ = APP_VERSION diff --git a/data/backup/.gitkeep b/data/backup/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/backups/.gitkeep b/data/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/config/.gitkeep b/data/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/logs/.gitkeep b/data/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/postgres/.gitkeep b/data/postgres/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/scripts/.gitkeep b/data/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/uploads/.gitkeep b/data/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6087ccc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +name: assetmanager + +services: + db: + image: postgres:16-alpine + container_name: assetmanager-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-assetmanager} + POSTGRES_USER: ${POSTGRES_USER:-assetmanager} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + volumes: + - ./data/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-assetmanager} -d ${POSTGRES_DB:-assetmanager}"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: . + container_name: assetmanager-app + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager} + APP_TITLE: AssetManager + APP_CONFIG: /app/config/config.json + APPINFO_PATH: /app/config/APPINFO.json + BACKUP_DIR: /data/backups + BACKUP_INTERVAL_HOURS: ${BACKUP_INTERVAL_HOURS:-8} + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-3} + MESHCENTRAL_PASSWORD: ${MESHCENTRAL_PASSWORD:-} + SYNC_LOG_DIR: /app/data/logs/sync + DIAGNOSTIC_DIR: /app/data/logs/diagnostics + LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-} + SESSION_SECRET: ${SESSION_SECRET:-} + LOCAL_ADMIN_USERNAME: ${LOCAL_ADMIN_USERNAME:-} + LOCAL_ADMIN_PASSWORD: ${LOCAL_ADMIN_PASSWORD:-} + + ports: + - "${APP_PORT:-8088}:8000" + volumes: + - ./data/config:/app/config + - ./data/uploads:/app/app/static/uploads + - ./data/logs:/app/data/logs + - ./data/backups:/data/backups + - ./data/scripts:/scripts + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4422c55 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +sqlalchemy==2.0.41 +psycopg[binary]==3.2.9 +jinja2==3.1.6 +python-multipart==0.0.20 +pydantic-settings==2.10.1 +itsdangerous +ldap3 +passlib[bcrypt] +openpyxl diff --git a/tools/check_public_release.py b/tools/check_public_release.py new file mode 100755 index 0000000..ff18427 --- /dev/null +++ b/tools/check_public_release.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Fail when a source tree contains files unsuitable for public release.""" +from __future__ import annotations + +import argparse +import ast +import json +import re +import sys +from pathlib import Path + +TEXT_SUFFIXES = { + ".css", ".env", ".html", ".ini", ".js", ".json", ".md", ".py", + ".sh", ".toml", ".txt", ".yaml", ".yml", ".ps1", ".cmd", ".bat", +} +SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "dist", "THIRD_PARTY_LICENSES"} +ALLOWED_RUNTIME_MARKER = ".gitkeep" +FORBIDDEN_FILES = { + ".env", + "data/config/config.json", + "data/config/APPINFO.json", + "app/main_old.py", + "PRUEFEN-0.5.5.5.sh", +} +FORBIDDEN_RUNTIME_DIRS = { + "data/postgres", "data/uploads", "data/logs", "data/backups", + "data/backup", "data/scripts", "app/static/uploads", +} +# Comparison language is blocked independently of a specific competitor name. +# Real integrations should be described factually instead of "similar to ...". +COMPARISON_PATTERNS = { + "similar-to comparison": re.compile(r"\b(?:ähnlich(?:e|er|es|en|em)?|similar to)\b", re.IGNORECASE), + "modeled-after comparison": re.compile(r"\b(?:nach (?:dem )?Vorbild|modelled after|modeled after)\b", re.IGNORECASE), + "inspired-by comparison": re.compile(r"\b(?:inspiriert von|inspired by)\b", re.IGNORECASE), + "like-in comparison": re.compile(r"\b(?:wie bei|as in)\b", re.IGNORECASE), + "based-on comparison": re.compile(r"\b(?:angelehnt an|based on the UI of)\b", re.IGNORECASE), +} +PRIVATE_DOMAIN_PATTERN = re.compile(r"(?:https?://|wss?://|ldaps?://|@)[^\s/\"'<>]+\.(?:local|lan|internal)\b", re.IGNORECASE) +IPV4_PATTERN = re.compile(r"(? str: + return path.relative_to(root).as_posix() + + +def public_files(root: Path): + for path in root.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(root) + if any(part in SKIP_DIRS for part in relative.parts): + continue + yield path + + +def read_text(path: Path) -> str | None: + if path.suffix.lower() not in TEXT_SUFFIXES and path.name not in {"VERSION", ".gitignore", ".dockerignore", ".gitattributes"}: + return None + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError: + return None + + +def version_from_python(path: Path) -> str: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "APP_VERSION" and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + return node.value.value + return "" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--denylist", type=Path, help="Optional local file with one private literal per line") + args = parser.parse_args() + root = args.root.resolve() + errors: list[str] = [] + local_literals: list[str] = [] + if args.denylist and args.denylist.is_file(): + local_literals = [ + line.strip().casefold() + for line in args.denylist.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + for name in FORBIDDEN_FILES: + if (root / name).is_file(): + errors.append(f"forbidden public file: {name}") + + for directory in FORBIDDEN_RUNTIME_DIRS: + path = root / directory + if not path.exists(): + continue + for item in path.rglob("*"): + if item.is_file() and item.name != ALLOWED_RUNTIME_MARKER: + errors.append(f"runtime/user data must not be public: {rel(item, root)}") + + for path in public_files(root): + relative = rel(path, root) + lower = path.name.lower() + if lower.endswith((".db", ".sqlite", ".sqlite3")): + errors.append(f"database file must not be public: {relative}") + text = read_text(path) + if text is None: + continue + if PRIVATE_KEY_PATTERN.search(text): + errors.append(f"private key material found: {relative}") + if PRIVATE_DOMAIN_PATTERN.search(text): + errors.append(f"private DNS suffix found in {relative}") + for candidate in IPV4_PATTERN.findall(text): + try: + octets = [int(part) for part in candidate.split(".")] + except ValueError: + continue + if any(part > 255 for part in octets): + continue + is_rfc1918 = ( + octets[0] == 10 + or (octets[0] == 172 and 16 <= octets[1] <= 31) + or (octets[0] == 192 and octets[1] == 168) + ) + if is_rfc1918: + errors.append(f"private IPv4 address found in {relative}") + lowered = text.casefold() + for literal in local_literals: + if literal in lowered: + errors.append(f"local denylist value found in {relative}") + if relative not in IGNORE_PRODUCT_SCAN: + for label, pattern in COMPARISON_PATTERNS.items(): + if pattern.search(text): + errors.append(f"{label} found in {relative}") + + license_path = root / "LICENSE.txt" + if not license_path.is_file() or "Apache License" not in license_path.read_text(encoding="utf-8", errors="replace") or "Version 2.0, January 2004" not in license_path.read_text(encoding="utf-8", errors="replace"): + errors.append("LICENSE.txt is not the complete Apache License 2.0 text") + + for appinfo_name in ("APPINFO.json", "APPINFO.example.json"): + path = root / appinfo_name + try: + info = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"cannot read {appinfo_name}: {exc}") + continue + if info.get("license") != "Apache-2.0": + errors.append(f"{appinfo_name} must use SPDX identifier Apache-2.0") + + try: + version = (root / "VERSION").read_text(encoding="utf-8").strip() + py_version = version_from_python(root / "app/version.py") + readme = (root / "README.md").read_text(encoding="utf-8") + if not version or version != py_version: + errors.append(f"version mismatch: VERSION={version!r}, app/version.py={py_version!r}") + if version and version not in readme: + errors.append(f"README.md does not contain current version {version}") + except OSError as exc: + errors.append(f"version check failed: {exc}") + + if errors: + print("Public release check failed:") + for item in sorted(set(errors)): + print(f" - {item}") + return 1 + print(f"Public release check passed for {root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_translations.py b/tools/check_translations.py new file mode 100755 index 0000000..f3ace27 --- /dev/null +++ b/tools/check_translations.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Check template/Python translation keys against BASE_TRANSLATIONS.""" +from pathlib import Path +import ast, re, sys +root=Path(__file__).resolve().parents[1] +source=(root/'app'/'i18n.py').read_text(encoding='utf-8') +tree=ast.parse(source) +keys=set() +for node in ast.walk(tree): + if isinstance(node, ast.Dict): + for key in node.keys: + if isinstance(key, ast.Constant) and isinstance(key.value,str): keys.add(key.value) +used=set() +patterns=[re.compile(r"\bt\(\s*['\"]([^'\"]+)"),re.compile(r"_translate_request\([^,]+,\s*['\"]([^'\"]+)")] +for path in list((root/'app').rglob('*.py'))+list((root/'app'/'templates').rglob('*.html')): + text=path.read_text(encoding='utf-8',errors='replace') + for pattern in patterns: used.update(pattern.findall(text)) +dynamic_prefixes=('field.','jobdefs.interpreter.','jobdefs.platform.','jobdefs.source.','jobs.action.','jobs.event.','jobs.type.','presence.','software.platform.','software.status.','backup.reason_') +missing=sorted(k for k in used if k not in keys and '~' not in k and not k.startswith(dynamic_prefixes)) +jobdef_used=sorted(k for k in used if k.startswith('jobdefs.') and not k.endswith('.')) +jobdef_missing=sorted(k for k in jobdef_used if k not in keys and not k.startswith(('jobdefs.interpreter.','jobdefs.platform.','jobdefs.source.'))) +if jobdef_missing: + print('Missing jobdefs keys:') + for key in jobdef_missing: print(' -',key) + sys.exit(1) +print(f'Defined translation keys: {len(keys)}') +print(f'Statically referenced keys: {len(used)}') +if missing: + print('Legacy/static keys without BASE_TRANSLATIONS entry (warning):') + for key in missing: print(' -',key) +print('Job definition translation check passed.') diff --git a/tools/create_github_source.py b/tools/create_github_source.py new file mode 100755 index 0000000..dbccd6c --- /dev/null +++ b/tools/create_github_source.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Create a sanitized GitHub source ZIP with one assetmanager/ root folder.""" +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile + +ROOT = Path(__file__).resolve().parents[1] +EXCLUDED_NAMES = { + ".git", ".venv", "venv", "__pycache__", ".pytest_cache", ".mypy_cache", + ".ruff_cache", "dist", "THIRD_PARTY_LICENSES", +} +EXCLUDED_FILES = { + ".env", + "data/config/config.json", + "data/config/APPINFO.json", + "app/main_old.py", + "PRUEFEN-0.5.5.5.sh", + "assetmanager.db", + ".public-release-local-patterns", +} +RUNTIME_DIRS = { + "data/postgres", "data/uploads", "data/logs", "data/backups", + "data/backup", "data/scripts", "app/static/uploads", +} + + +def should_exclude(relative: Path) -> bool: + posix = relative.as_posix() + if any(part in EXCLUDED_NAMES for part in relative.parts): + return True + if posix in EXCLUDED_FILES: + return True + if relative.name.startswith(".env") and relative.name != ".env.example": + return True + if relative.suffix.lower() in {".db", ".sqlite", ".sqlite3", ".pyc", ".zip"}: + return True + return any(posix == directory or posix.startswith(directory + "/") for directory in RUNTIME_DIRS) + + +def copy_source(destination: Path) -> None: + for source in ROOT.rglob("*"): + relative = source.relative_to(ROOT) + if should_exclude(relative): + continue + target = destination / relative + if source.is_dir(): + target.mkdir(parents=True, exist_ok=True) + elif source.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + for directory in sorted(RUNTIME_DIRS): + marker = destination / directory / ".gitkeep" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.touch() + + +def make_zip(source_root: Path, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(output, "w", compression=ZIP_DEFLATED, compresslevel=9) as archive: + for path in sorted(source_root.rglob("*")): + if path.is_file(): + archive.write(path, Path("assetmanager") / path.relative_to(source_root)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path) + args = parser.parse_args() + version = (ROOT / "VERSION").read_text(encoding="utf-8").strip() + output = (args.output or ROOT / "dist" / f"assetmanager-github-{version}.zip").resolve() + + with tempfile.TemporaryDirectory(prefix="assetmanager-public-") as temp_name: + staged = Path(temp_name) / "assetmanager" + staged.mkdir() + copy_source(staged) + checker = staged / "tools" / "check_public_release.py" + command = [sys.executable, str(checker), "--root", str(staged)] + denylist = ROOT / ".public-release-local-patterns" + if denylist.is_file(): + command.extend(["--denylist", str(denylist)]) + result = subprocess.run(command) + if result.returncode: + print("GitHub ZIP was not created because the staged source failed validation.", file=sys.stderr) + return result.returncode + make_zip(staged, output) + print(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/export_dependency_licenses.py b/tools/export_dependency_licenses.py new file mode 100755 index 0000000..4dfb5b4 --- /dev/null +++ b/tools/export_dependency_licenses.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Collect license files from the packages installed in the build environment. + +The result is evidence for the concrete image build. It does not replace a +manual compatibility review of new or changed dependencies. +""" +from __future__ import annotations + +import argparse +import importlib.metadata as metadata +import json +import re +import shutil +from pathlib import Path +from typing import Iterable + +LICENSE_PREFIXES = ("license", "licence", "copying", "notice", "copyright", "authors") + + +def safe_name(value: str) -> str: + value = re.sub(r"[^A-Za-z0-9._+-]+", "-", value.strip()) + return value.strip("-.") or "unknown" + + +def is_license_name(name: str) -> bool: + lower = name.lower() + return any(lower.startswith(prefix) for prefix in LICENSE_PREFIXES) + + +def unique_target(directory: Path, relative: Path) -> Path: + candidate = directory / relative + candidate.parent.mkdir(parents=True, exist_ok=True) + if not candidate.exists(): + return candidate + stem, suffix = candidate.stem, candidate.suffix + index = 2 + while True: + numbered = candidate.with_name(f"{stem}-{index}{suffix}") + if not numbered.exists(): + return numbered + index += 1 + + +def one_line(value: object) -> str: + return " ".join(str(value or "").split()) + + +def collect_python(output: Path) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for dist in sorted(metadata.distributions(), key=lambda item: (item.metadata.get("Name") or "").lower()): + name = dist.metadata.get("Name") or "unknown" + version = dist.version or "unknown" + destination = output / "python" / f"{safe_name(name)}-{safe_name(version)}" + copied: list[str] = [] + for item in dist.files or (): + if not is_license_name(Path(str(item)).name): + continue + source = Path(dist.locate_file(item)) + if not source.is_file(): + continue + relative = Path(*Path(str(item)).parts[-2:]) if len(Path(str(item)).parts) > 1 else Path(source.name) + target = unique_target(destination, relative) + shutil.copy2(source, target) + copied.append(target.relative_to(output).as_posix()) + classifiers = [value.removeprefix("License :: ") for value in dist.metadata.get_all("Classifier", []) if value.startswith("License :: ")] + rows.append({ + "ecosystem": "Python", + "name": name, + "version": version, + "license": one_line(dist.metadata.get("License") or "; ".join(classifiers)), + "homepage": one_line(dist.metadata.get("Home-page") or dist.metadata.get("Project-URL")), + "files": ", ".join(copied), + }) + return rows + + +def node_package_dirs(node_modules: Path) -> Iterable[Path]: + if not node_modules.is_dir(): + return + for entry in sorted(node_modules.iterdir(), key=lambda path: path.name.lower()): + if entry.name.startswith(".") or entry.name == ".bin" or not entry.is_dir(): + continue + if entry.name.startswith("@"): + for scoped in sorted(entry.iterdir(), key=lambda path: path.name.lower()): + if scoped.is_dir() and (scoped / "package.json").is_file(): + yield scoped + elif (entry / "package.json").is_file(): + yield entry + + +def license_value(value: object) -> str: + if isinstance(value, str): + return value + if isinstance(value, dict): + return one_line(value.get("type") or value.get("name")) + if isinstance(value, list): + return "; ".join(filter(None, (license_value(item) for item in value))) + return "" + + +def collect_node(output: Path, node_modules: Path) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for package_dir in node_package_dirs(node_modules): + try: + package = json.loads((package_dir / "package.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"WARNING: cannot read {package_dir / 'package.json'}: {exc}") + continue + name = str(package.get("name") or package_dir.name) + version = str(package.get("version") or "unknown") + destination = output / "node" / f"{safe_name(name)}-{safe_name(version)}" + copied: list[str] = [] + for source in sorted(package_dir.iterdir(), key=lambda path: path.name.lower()): + if source.is_file() and is_license_name(source.name): + target = unique_target(destination, Path(source.name)) + shutil.copy2(source, target) + copied.append(target.relative_to(output).as_posix()) + repository = package.get("repository") + if isinstance(repository, dict): + repository = repository.get("url") + rows.append({ + "ecosystem": "Node.js", + "name": name, + "version": version, + "license": license_value(package.get("license") or package.get("licenses")), + "homepage": one_line(package.get("homepage") or repository), + "files": ", ".join(copied), + }) + return rows + + +def write_index(output: Path, rows: list[dict[str, str]]) -> None: + lines = [ + "# Installed dependency licenses", + "", + "Generated from the packages present during this build. Empty metadata fields must be checked against the copied original license files and upstream source.", + "", + "| Ecosystem | Package | Version | Declared license | Copied files |", + "|---|---|---:|---|---|", + ] + for row in sorted(rows, key=lambda item: (item["ecosystem"], item["name"].lower(), item["version"])): + escaped = {key: value.replace("|", "\\|") for key, value in row.items()} + lines.append(f"| {escaped['ecosystem']} | {escaped['name']} | {escaped['version']} | {escaped['license']} | {escaped['files']} |") + (output / "INDEX.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + (output / "INDEX.json").write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--node-modules", type=Path) + args = parser.parse_args() + + output = args.output.resolve() + if output.exists(): + shutil.rmtree(output) + output.mkdir(parents=True) + + rows = collect_python(output) + if args.node_modules: + rows.extend(collect_node(output, args.node_modules.resolve())) + write_index(output, rows) + + missing = [row for row in rows if not row["license"] or not row["files"]] + print(f"Collected metadata for {len(rows)} installed packages in {output}") + if missing: + print(f"WARNING: {len(missing)} package(s) have missing license metadata or no copied license file; review INDEX.md") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/validate_release.py b/tools/validate_release.py new file mode 100755 index 0000000..a7ac81c --- /dev/null +++ b/tools/validate_release.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Lightweight release checks for AssetManager. + +The checks are intentionally read-only and do not require a running database. +A failure exits with status 1 so the script can be used in CI or a release job. +""" +from __future__ import annotations + +import compileall +import importlib.util +import os +from pathlib import Path +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[1] +APP_DIR = ROOT / "app" + + +def fail(message: str) -> None: + print(f"[FAIL] {message}") + raise SystemExit(1) + + +def ok(message: str) -> None: + print(f"[ OK ] {message}") + + +def check_version() -> None: + path = APP_DIR / "version.py" + spec = importlib.util.spec_from_file_location("assetmanager_release_version", path) + if spec is None or spec.loader is None: + fail("app/version.py could not be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + app_version = getattr(module, "APP_VERSION", None) + conventional = getattr(module, "__version__", None) + if not isinstance(app_version, str) or not app_version.strip(): + fail("APP_VERSION is missing or empty") + if conventional != app_version: + fail("__version__ must be an alias of APP_VERSION") + version_file = (ROOT / "VERSION").read_text(encoding="utf-8").strip() + if version_file != app_version: + fail(f"VERSION ({version_file}) and app/version.py ({app_version}) differ") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + if app_version not in readme: + fail(f"README.md does not contain current version {app_version}") + ok(f"Version exports are valid ({app_version})") + + +def check_compile() -> None: + if not compileall.compile_dir(str(APP_DIR), quiet=1, force=True): + fail("Python compilation failed") + ok("Python sources compile") + + +def check_translations() -> None: + script = ROOT / "tools" / "check_translations.py" + result = subprocess.run([sys.executable, str(script)], cwd=ROOT, text=True) + if result.returncode: + fail("Translation check failed") + ok("Translation check passed") + + +def check_templates() -> None: + try: + from jinja2 import Environment, FileSystemLoader + except ImportError as exc: + fail(f"Jinja2 is unavailable: {exc}") + template_dir = APP_DIR / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir))) + failures: list[str] = [] + for path in sorted(template_dir.rglob("*.html")): + name = path.relative_to(template_dir).as_posix() + try: + env.get_template(name) + except Exception as exc: # syntax/load validation + failures.append(f"{name}: {exc}") + if failures: + fail("Template validation failed:\n " + "\n ".join(failures)) + ok("Templates load without syntax errors") + + + +def check_job_definition_frontend() -> None: + form_path = APP_DIR / "templates" / "job_definition_form.html" + content = form_path.read_text(encoding="utf-8") + required_frontend = [ + 'id="insert-job-test-script"', + 'id="job-parameters-json"', + 'id="add-job-parameter"', + 'job-enabled-top', + 'jobdefs.own_script', + ] + missing = [item for item in required_frontend if item not in content] + if missing: + fail("Job-definition frontend validation failed; missing: " + ", ".join(missing)) + + template_dir = APP_DIR / "templates" / "jobs" + required_templates = { + "powershell_wrapper.ps1": ["{{JobId}}", "{{CallbackUrl}}", "Eigene Befehle hier einfügen"], + "python_wrapper.py.txt": ["{{JobId}}", "{{CallbackUrl}}", "Eigene Befehle hier einfügen"], + "bash_wrapper.sh": ["{{JobId}}", "{{CallbackUrl}}", "${status}", "${code}"], + "cmd_wrapper.cmd": ["{{JobId}}", "{{CallbackUrl}}", "Eigene Befehle hier einfügen"], + "powershell_test.txt": ["{{AssetName}}", "{{Hostname}}"], + "python_test.txt": ["{{AssetName}}", "{{Hostname}}"], + "bash_test.txt": ["{{AssetName}}", "{{Hostname}}"], + "cmd_test.txt": ["{{AssetName}}", "{{Hostname}}"], + } + failures: list[str] = [] + for filename, markers in required_templates.items(): + path = template_dir / filename + if not path.is_file(): + failures.append(f"missing file: {filename}") + continue + template_content = path.read_text(encoding="utf-8") + for marker in markers: + if marker not in template_content: + failures.append(f"{filename}: missing {marker}") + if failures: + fail("Job script template validation failed:\n " + "\n ".join(failures)) + ok("Job-definition backend generator safeguards are present") + + +def check_fastapi_import() -> None: + # Import only. Do not start Uvicorn and do not connect to external services. + sys.path.insert(0, str(ROOT)) + os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") + try: + from app.main import app # noqa: F401 + except Exception as exc: + fail(f"FastAPI application import failed: {type(exc).__name__}: {exc}") + ok("FastAPI application imports") + + +def main() -> None: + print("AssetManager release validation") + check_version() + check_compile() + check_translations() + check_templates() + check_job_definition_frontend() + check_fastapi_import() + print("All release checks passed.") + + +if __name__ == "__main__": + main()