fmeshsync fix with synology,vm's and lxc
This commit is contained in:
+8
-3
@@ -9,7 +9,8 @@ WORKDIR /opt/meshcentral
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client \
|
&& apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& npm install --omit=dev meshcentral
|
&& npm install --omit=dev meshcentral \
|
||||||
|
&& chown -R root:root /opt/meshcentral
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
@@ -21,10 +22,14 @@ RUN pip install --no-cache-dir -r requirements.txt \
|
|||||||
&& rm /tmp/export_dependency_licenses.py
|
&& rm /tmp/export_dependency_licenses.py
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY VERSION APPINFO.json LICENSE.txt THIRD_PARTY_NOTICES.md ./
|
COPY VERSION 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
|
COPY APPINFO.json /app/default-config/APPINFO.json
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/assetmanager-entrypoint
|
||||||
|
RUN chmod +x /usr/local/bin/assetmanager-entrypoint \
|
||||||
|
&& 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 \
|
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 ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/software-callback/health', timeout=3).read()"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/assetmanager-entrypoint"]
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# AssetManager 0.5.5.42
|
# AssetManager 0.5.5.52
|
||||||
|
|
||||||
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
|
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ The project is licensed under the **Apache License 2.0** and may be used, modifi
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
For a detailed first-start, image deployment, authentication, callback, update, and troubleshooting guide, see [`docs/INSTALLATION.md`](docs/INSTALLATION.md).
|
||||||
|
|
||||||
|
|
||||||
Create the local environment file:
|
Create the local environment file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -50,6 +53,20 @@ docker compose up -d --build
|
|||||||
|
|
||||||
By default, AssetManager is exposed on port `8088`.
|
By default, AssetManager is exposed on port `8088`.
|
||||||
|
|
||||||
|
### First start and authentication
|
||||||
|
|
||||||
|
On a fresh image-based installation, `config.json` and `APPINFO.json` are created automatically in `data/config/` if they do not exist. Existing files are never overwritten by the container entrypoint.
|
||||||
|
|
||||||
|
The default configuration starts with authentication disabled so the initial configuration can be completed. A prominent warning banner is displayed in the web interface while this mode is active. Before normal use:
|
||||||
|
|
||||||
|
1. Edit `data/config/config.json`.
|
||||||
|
2. Set `authentication.mode` to `local`.
|
||||||
|
3. Restart the application container with `docker compose restart app`.
|
||||||
|
4. Sign in with the protected local administrator configured through `LOCAL_ADMIN_USERNAME` and `LOCAL_ADMIN_PASSWORD` in `.env`.
|
||||||
|
5. Configure LDAP/Active Directory afterwards if required. The protected local administrator remains available as an emergency account.
|
||||||
|
|
||||||
|
Do not expose a fresh installation to untrusted networks while authentication is disabled.
|
||||||
|
|
||||||
## Persistent data
|
## Persistent data
|
||||||
|
|
||||||
Runtime data is stored in bind mounts below `data/`:
|
Runtime data is stored in bind mounts below `data/`:
|
||||||
|
|||||||
+3
-1
@@ -59,8 +59,10 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||||||
"timeout_seconds": 120,
|
"timeout_seconds": 120,
|
||||||
"presence_enabled": True,
|
"presence_enabled": True,
|
||||||
"presence_interval_seconds": 30,
|
"presence_interval_seconds": 30,
|
||||||
"match_order": ["node_id", "serial_number"],
|
"match_order": ["node_id", "mac_address", "serial_number"],
|
||||||
"serial_match_manufacturer": False,
|
"serial_match_manufacturer": False,
|
||||||
|
"invalid_serial_values": ["unknown", "n/a", "none", "not specified", "to be filled by o.e.m."],
|
||||||
|
"manufacturer_invalid_serial_rules": [{"manufacturer": "Synology", "values": ["123456789", "Unknown"]}],
|
||||||
"create_missing_assets": True,
|
"create_missing_assets": True,
|
||||||
"mark_missing_devices": True,
|
"mark_missing_devices": True,
|
||||||
"field_rules": {},
|
"field_rules": {},
|
||||||
|
|||||||
+20
@@ -7,6 +7,8 @@ BASE_TRANSLATIONS = {
|
|||||||
"app.charts": ("Charts", "Diagramme"), "app.categories": ("Categories", "Kategorien"), "app.status_values": ("Status values", "Statuswerte"),
|
"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.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"),
|
"app.login": ("Sign in", "Anmelden"), "app.logout": ("Sign out", "Ausloggen"), "app.guest": ("Guest", "Gast"),
|
||||||
|
"auth.disabled_banner_title": ("Authentication is disabled", "Authentifizierung ist deaktiviert"),
|
||||||
|
"auth.disabled_banner_text": ("This installation is currently accessible without signing in. For the first setup, edit data/config/config.json, set authentication.mode to local, restart the app container, and then sign in with the protected local administrator configured in .env.", "Diese Installation ist derzeit ohne Anmeldung erreichbar. Für die Ersteinrichtung bearbeite data/config/config.json, setze authentication.mode auf local, starte den App-Container neu und melde dich anschließend mit dem in .env konfigurierten geschützten lokalen Administrator an."),
|
||||||
"common.save": ("Save", "Speichern"), "common.cancel": ("Cancel", "Abbrechen"), "common.delete": ("Delete", "Löschen"),
|
"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.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.import": ("Import", "Importieren"), "common.back": ("Back", "Zurück"), "common.yes": ("Yes", "Ja"), "common.no": ("No", "Nein"),
|
||||||
@@ -74,9 +76,16 @@ BASE_TRANSLATIONS = {
|
|||||||
"mesh.verify_tls": ("Verify TLS", "TLS prüfen"), "mesh.asset_matching": ("Asset matching", "Asset-Zuordnung"),
|
"mesh.verify_tls": ("Verify TLS", "TLS prüfen"), "mesh.asset_matching": ("Asset matching", "Asset-Zuordnung"),
|
||||||
"mesh.asset_matching_help": ("Choose which identifiers are used to match MeshCentral devices to existing assets. Matching is evaluated in the displayed order.", "Wähle, mit welchen Kennungen MeshCentral-Geräte bestehenden Assets zugeordnet werden. Die Zuordnung wird in der angezeigten Reihenfolge geprüft."),
|
"mesh.asset_matching_help": ("Choose which identifiers are used to match MeshCentral devices to existing assets. Matching is evaluated in the displayed order.", "Wähle, mit welchen Kennungen MeshCentral-Geräte bestehenden Assets zugeordnet werden. Die Zuordnung wird in der angezeigten Reihenfolge geprüft."),
|
||||||
"mesh.match_node_id": ("Node ID", "Node-ID"),
|
"mesh.match_node_id": ("Node ID", "Node-ID"),
|
||||||
|
"mesh.match_mac": ("MAC address", "MAC-Adresse"),
|
||||||
"mesh.match_serial": ("Serial number", "Seriennummer"),
|
"mesh.match_serial": ("Serial number", "Seriennummer"),
|
||||||
|
"mesh.match_mac_help": ("MAC matching is used only for an exact, unique MAC address. Node ID remains the primary device identity.", "Der MAC-Abgleich wird nur bei einer exakten, eindeutigen MAC-Adresse verwendet. Die Node-ID bleibt die primäre Geräteidentität."),
|
||||||
"mesh.match_serial_manufacturer": ("For serial-number matches, also compare normalized manufacturer", "Beim Seriennummern-Abgleich zusätzlich den normalisierten Hersteller vergleichen"),
|
"mesh.match_serial_manufacturer": ("For serial-number matches, also compare normalized manufacturer", "Beim Seriennummern-Abgleich zusätzlich den normalisierten Hersteller vergleichen"),
|
||||||
"mesh.match_serial_manufacturer_help": ("When enabled, a serial-number match is accepted only if the normalized manufacturer also matches. Case, punctuation, accents and common legal suffixes such as GmbH, Inc. or Corporation are ignored.", "Wenn aktiviert, wird eine Übereinstimmung über die Seriennummer nur akzeptiert, wenn auch der normalisierte Hersteller übereinstimmt. Groß-/Kleinschreibung, Satzzeichen, Akzente und übliche Rechtsformzusätze wie GmbH, Inc. oder Corporation werden ignoriert."),
|
"mesh.match_serial_manufacturer_help": ("When enabled, a serial-number match is accepted only if the normalized manufacturer also matches. Case, punctuation, accents and common legal suffixes such as GmbH, Inc. or Corporation are ignored.", "Wenn aktiviert, wird eine Übereinstimmung über die Seriennummer nur akzeptiert, wenn auch der normalisierte Hersteller übereinstimmt. Groß-/Kleinschreibung, Satzzeichen, Akzente und übliche Rechtsformzusätze wie GmbH, Inc. oder Corporation werden ignoriert."),
|
||||||
|
"mesh.invalid_serial_values": ("Invalid serial-number placeholders", "Ungültige Seriennummern-Platzhalter"),
|
||||||
|
"mesh.invalid_serial_values_help": ("One value per line. These values are never used for serial-number matching and never overwrite an existing asset serial number.", "Ein Wert pro Zeile. Diese Werte werden niemals für den Seriennummern-Abgleich verwendet und überschreiben keine vorhandene Asset-Seriennummer."),
|
||||||
|
"mesh.manufacturer_invalid_serial_rules": ("Manufacturer-specific serial placeholders", "Herstellerspezifische Seriennummern-Platzhalter"),
|
||||||
|
"mesh.manufacturer_invalid_serial_rules_help": ("One rule per line in the format Manufacturer | Serial number. Example: Synology | 123456789. Manufacturer matching is normalized and ignores case, punctuation and common legal suffixes.", "Eine Regel pro Zeile im Format Hersteller | Seriennummer. Beispiel: Synology | 123456789. Der Herstellervergleich wird normalisiert und ignoriert Groß-/Kleinschreibung, Satzzeichen und übliche Rechtsformzusätze."),
|
||||||
|
"mesh.node_id_identity_help": ("If a reported serial number is recognized as a placeholder, AssetManager ignores it for matching and synchronization. The MeshCentral node ID remains the stable unique device identity. The original reported value is retained in the stored MeshCentral source data when source-data storage is enabled.", "Wird eine gemeldete Seriennummer als Platzhalter erkannt, ignoriert AssetManager sie beim Abgleich und bei der Synchronisierung. Die MeshCentral-Node-ID bleibt die stabile eindeutige Geräteidentität. Der ursprünglich gemeldete Wert bleibt bei aktivierter Quelldatenspeicherung in den gespeicherten MeshCentral-Quelldaten erhalten."),
|
||||||
"mesh.dry_run": ("Dry run", "Dry-Run"),
|
"mesh.dry_run": ("Dry run", "Dry-Run"),
|
||||||
"mesh.fallback_category_id": ("Fallback category ID", "Fallback-Kategorie-ID"), "mesh.device_type_mapping": ("Device type mapping", "Gerätetyp-Mapping"),
|
"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.type": ("Type", "Typ"), "mesh.recent_runs": ("Recent runs", "Letzte Läufe"), "mesh.start_time": ("Start", "Start"),
|
||||||
@@ -87,6 +96,8 @@ BASE_TRANSLATIONS = {
|
|||||||
"mesh.finished_visible": ("Synchronization finished. The log remains visible.", "Synchronisierung beendet. Das Protokoll bleibt sichtbar."),
|
"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": ("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.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.save_mesh_mapping": ("Save category mapping", "Kategoriezuordnung speichern"),
|
||||||
|
"settings.mesh_mapping_saved": ("MeshCentral category mapping saved", "MeshCentral-Kategoriezuordnung gespeichert"),
|
||||||
"settings.fallback_category": ("Fallback category", "Fallback-Kategorie"), "settings.mobile_device": ("Mobile device", "Mobilgerät"),
|
"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.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.mesh_type": ("MeshCentral type", "MeshCentral-Typ"), "settings.use_fallback": ("Use fallback", "Fallback verwenden"),
|
||||||
@@ -146,6 +157,9 @@ BASE_TRANSLATIONS = {
|
|||||||
"lists.filters_active": ("{count} filters active", "{count} 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.filter_status": ("Filtered: {visible} of {total} records", "Gefiltert: {visible} von {total} Datensätzen"),
|
||||||
"lists.clear_filters": ("Clear filters", "Filter zurücksetzen"),
|
"lists.clear_filters": ("Clear filters", "Filter zurücksetzen"),
|
||||||
|
"lists.row_number": ("Row number", "Zeilennummer"),
|
||||||
|
"lists.row_number_resize": ("Drag to change the row-number column width", "Ziehen, um die Breite der Nummernspalte zu ändern"),
|
||||||
|
"lists.column_resize": ("Drag to change the column width", "Ziehen, um die Spaltenbreite zu ändern"),
|
||||||
"appearance.colors_title": ("System colors", "Systemfarben"),
|
"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.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_color": ("Primary color", "Primärfarbe"),
|
||||||
@@ -549,6 +563,12 @@ BASE_TRANSLATIONS.update({
|
|||||||
"duplicates.ignore_case": ("Ignore letter case", "Groß-/Kleinschreibung ignorieren"),
|
"duplicates.ignore_case": ("Ignore letter case", "Groß-/Kleinschreibung ignorieren"),
|
||||||
"duplicates.clear_filter": ("Clear duplicate filter", "Dublettenfilter aufheben"),
|
"duplicates.clear_filter": ("Clear duplicate filter", "Dublettenfilter aufheben"),
|
||||||
"duplicates.show": ("Show duplicates", "Duplikate anzeigen"),
|
"duplicates.show": ("Show duplicates", "Duplikate anzeigen"),
|
||||||
|
"duplicates.active_filter": ("Duplicates: {field}", "Duplikate: {field}"),
|
||||||
|
"duplicates.result_summary": ("{records} records in {groups} duplicate groups found.", "{records} Datensätze in {groups} Dublettengruppen gefunden."),
|
||||||
|
"assets.active_selection": ("Active selection", "Aktive Auswahl"),
|
||||||
|
"assets.category_filter": ("Category: {category}", "Kategorie: {category}"),
|
||||||
|
"assets.clear_category_filter": ("Remove category filter", "Kategoriefilter entfernen"),
|
||||||
|
"duplicates.remove_filter": ("Remove duplicate filter", "Dublettenfilter entfernen"),
|
||||||
"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_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.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": ("View result", "Ergebnis ansehen"),
|
||||||
|
|||||||
+113
-26
@@ -9,6 +9,7 @@ import shutil
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from logging.handlers import WatchedFileHandler
|
||||||
import traceback
|
import traceback
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
@@ -192,7 +193,7 @@ def _software_debug_tail(max_lines: int = 400) -> str:
|
|||||||
logger = logging.getLogger("assetmanager")
|
logger = logging.getLogger("assetmanager")
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
if not logger.handlers:
|
if not logger.handlers:
|
||||||
file_handler = logging.FileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8")
|
file_handler = WatchedFileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8")
|
||||||
file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
|
file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
@@ -200,7 +201,7 @@ ldap_logger = logging.getLogger("assetmanager.ldap")
|
|||||||
ldap_logger.setLevel(logging.DEBUG)
|
ldap_logger.setLevel(logging.DEBUG)
|
||||||
ldap_logger.propagate = False
|
ldap_logger.propagate = False
|
||||||
if not ldap_logger.handlers:
|
if not ldap_logger.handlers:
|
||||||
ldap_file_handler = logging.FileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8")
|
ldap_file_handler = WatchedFileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8")
|
||||||
ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
|
ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
|
||||||
ldap_logger.addHandler(ldap_file_handler)
|
ldap_logger.addHandler(ldap_file_handler)
|
||||||
ldap_console_handler = logging.StreamHandler()
|
ldap_console_handler = logging.StreamHandler()
|
||||||
@@ -6023,6 +6024,39 @@ def _decode_software_callback_body(raw_body: bytes) -> tuple[str, str, list[str]
|
|||||||
return decoded_body, detected_encoding, decode_errors
|
return decoded_body, detected_encoding, decode_errors
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_callback_value(value: Any) -> Any:
|
||||||
|
"""Remove PostgreSQL-incompatible NUL characters from callback values.
|
||||||
|
|
||||||
|
Windows registry and uninstall metadata can occasionally contain embedded
|
||||||
|
NUL characters. PostgreSQL text and JSON values reject those characters,
|
||||||
|
so callback payloads are sanitized before they are written to the database.
|
||||||
|
Other characters and the original data structure are preserved.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.replace("\x00", "")
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_sanitize_callback_value(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(_sanitize_callback_value(item) for item in value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {
|
||||||
|
_sanitize_callback_value(key) if isinstance(key, str) else key: _sanitize_callback_value(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _count_nul_characters(value: Any) -> int:
|
||||||
|
"""Count embedded NUL characters recursively for diagnostics only."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.count("\x00")
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return sum(_count_nul_characters(item) for item in value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return sum(_count_nul_characters(key) + _count_nul_characters(item) for key, item in value.items())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _process_software_job_callback(
|
def _process_software_job_callback(
|
||||||
job_id: int,
|
job_id: int,
|
||||||
token: str,
|
token: str,
|
||||||
@@ -6101,6 +6135,14 @@ def _process_software_job_callback(
|
|||||||
_software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}")
|
_software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}")
|
||||||
raise HTTPException(400, "JSON-Objekt erwartet")
|
raise HTTPException(400, "JSON-Objekt erwartet")
|
||||||
|
|
||||||
|
nul_character_count = _count_nul_characters(payload)
|
||||||
|
if nul_character_count:
|
||||||
|
payload = _sanitize_callback_value(payload)
|
||||||
|
_software_debug_log(
|
||||||
|
f"CALLBACK sanitized PostgreSQL-incompatible NUL characters | "
|
||||||
|
f"job={job_id} | removed={nul_character_count}"
|
||||||
|
)
|
||||||
|
|
||||||
_software_debug_log(
|
_software_debug_log(
|
||||||
f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}"
|
f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}"
|
||||||
)
|
)
|
||||||
@@ -7910,15 +7952,6 @@ async def settings_save(
|
|||||||
success_color: str = Form("#198754"),
|
success_color: str = Form("#198754"),
|
||||||
warning_color: str = Form("#d97706"),
|
warning_color: str = Form("#d97706"),
|
||||||
danger_color: str = Form("#b42318"),
|
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"),
|
auth_mode: str = Form("none"),
|
||||||
ldap_server: str = Form(""),
|
ldap_server: str = Form(""),
|
||||||
ldap_port: int = Form(389),
|
ldap_port: int = Form(389),
|
||||||
@@ -8012,19 +8045,7 @@ async def settings_save(
|
|||||||
"debug_logging": ldap_debug_logging == "on",
|
"debug_logging": ldap_debug_logging == "on",
|
||||||
})
|
})
|
||||||
authentication["ldap"] = ldap
|
authentication["ldap"] = ldap
|
||||||
meshcentral = dict(current.get("meshcentral", {}))
|
save_config({"general": general, "authentication": authentication})
|
||||||
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)
|
return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -8037,11 +8058,13 @@ def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").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()
|
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()
|
mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all()
|
||||||
|
categories = db.query(Category).order_by(Category.name.asc()).all()
|
||||||
return templates.TemplateResponse("meshcentral_sync.html", {
|
return templates.TemplateResponse("meshcentral_sync.html", {
|
||||||
"request": request, "runs": runs, "config": public_config(),
|
"request": request, "runs": runs, "config": public_config(),
|
||||||
"linked": linked, "conflicts": conflicts, "missing": missing,
|
"linked": linked, "conflicts": conflicts, "missing": missing,
|
||||||
"definitions": definitions,
|
"definitions": definitions,
|
||||||
"mappings": mappings,
|
"mappings": mappings,
|
||||||
|
"categories": categories,
|
||||||
"transform_options": [
|
"transform_options": [
|
||||||
"raw", "string", "integer", "decimal", "boolean", "json",
|
"raw", "string", "integer", "decimal", "boolean", "json",
|
||||||
"normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac",
|
"normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac",
|
||||||
@@ -8198,8 +8221,34 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_
|
|||||||
'store_source_json': form.get('store_source_json') == 'on',
|
'store_source_json': form.get('store_source_json') == 'on',
|
||||||
'serial_match_manufacturer': form.get('serial_match_manufacturer') == 'on',
|
'serial_match_manufacturer': form.get('serial_match_manufacturer') == 'on',
|
||||||
})
|
})
|
||||||
submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'serial_number'}]
|
|
||||||
mesh['match_order'] = submitted_match_order or ['node_id', 'serial_number']
|
invalid_serial_values = []
|
||||||
|
for line in str(form.get('invalid_serial_values') or '').splitlines():
|
||||||
|
value = line.strip()
|
||||||
|
if value and value.casefold() not in {item.casefold() for item in invalid_serial_values}:
|
||||||
|
invalid_serial_values.append(value)
|
||||||
|
mesh['invalid_serial_values'] = invalid_serial_values
|
||||||
|
|
||||||
|
manufacturer_rules: dict[str, list[str]] = {}
|
||||||
|
for line in str(form.get('manufacturer_invalid_serial_rules') or '').splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
if '|' not in line:
|
||||||
|
continue
|
||||||
|
manufacturer, serial = (part.strip() for part in line.split('|', 1))
|
||||||
|
if not manufacturer or not serial:
|
||||||
|
continue
|
||||||
|
values = manufacturer_rules.setdefault(manufacturer, [])
|
||||||
|
if serial.casefold() not in {item.casefold() for item in values}:
|
||||||
|
values.append(serial)
|
||||||
|
mesh['manufacturer_invalid_serial_rules'] = [
|
||||||
|
{'manufacturer': manufacturer, 'values': values}
|
||||||
|
for manufacturer, values in manufacturer_rules.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'mac_address', 'serial_number'}]
|
||||||
|
mesh['match_order'] = submitted_match_order or ['node_id', 'mac_address', 'serial_number']
|
||||||
# Do not retain plaintext passwords from older versions.
|
# Do not retain plaintext passwords from older versions.
|
||||||
mesh.pop('password', None)
|
mesh.pop('password', None)
|
||||||
mesh.pop('field_mappings', None)
|
mesh.pop('field_mappings', None)
|
||||||
@@ -8208,6 +8257,44 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_
|
|||||||
return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303)
|
return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post('/sync/meshcentral/categories/save')
|
||||||
|
async def meshcentral_category_mapping_save(request: Request, db: Session = Depends(get_db)):
|
||||||
|
_require_admin(request)
|
||||||
|
form = await request.form()
|
||||||
|
categories = db.query(Category).all()
|
||||||
|
valid_category_ids = {int(category.id) for category in categories}
|
||||||
|
if not valid_category_ids:
|
||||||
|
raise HTTPException(400, 'No asset categories are available.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
fallback_category_id = int(form.get('mesh_fallback_category_id') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
fallback_category_id = 0
|
||||||
|
if fallback_category_id not in valid_category_ids:
|
||||||
|
raise HTTPException(400, 'Invalid fallback category.')
|
||||||
|
|
||||||
|
category_mapping = {}
|
||||||
|
for type_id in range(1, 9):
|
||||||
|
raw_value = str(form.get(f'mesh_category_{type_id}') or '').strip()
|
||||||
|
if not raw_value:
|
||||||
|
category_mapping[str(type_id)] = None
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
category_id = int(raw_value)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f'Invalid category mapping for MeshCentral type {type_id}.')
|
||||||
|
if category_id not in valid_category_ids:
|
||||||
|
raise HTTPException(400, f'Unknown category for MeshCentral type {type_id}.')
|
||||||
|
category_mapping[str(type_id)] = category_id
|
||||||
|
|
||||||
|
current = load_config()
|
||||||
|
mesh = dict(current.get('meshcentral', {}))
|
||||||
|
mesh['fallback_category_id'] = fallback_category_id
|
||||||
|
mesh['category_mapping'] = category_mapping
|
||||||
|
save_config({'meshcentral': mesh})
|
||||||
|
return RedirectResponse('/sync/meshcentral?toast_success=' + quote(_translate_request(request, 'settings.mesh_mapping_saved', 'MeshCentral category mapping saved')), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.post('/sync/meshcentral/mappings/save')
|
@app.post('/sync/meshcentral/mappings/save')
|
||||||
async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)):
|
async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)):
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
|
|||||||
-1351
File diff suppressed because it is too large
Load Diff
+212
-13
@@ -466,29 +466,126 @@ def _path_values(data: Any, path: str) -> list[Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _network_entries(value: Any) -> list[dict[str, Any]]:
|
def _network_entries(value: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Flatten MeshCentral network entries while retaining the interface name.
|
||||||
|
|
||||||
|
``net.netif2`` is keyed by interface name. Keeping that name allows us to
|
||||||
|
prefer real/primary interfaces over loopback, container and tunnel entries.
|
||||||
|
"""
|
||||||
entries: list[dict[str, Any]] = []
|
entries: list[dict[str, Any]] = []
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for group in value.values():
|
for adapter_name, group in value.items():
|
||||||
if isinstance(group, list):
|
if not isinstance(group, list):
|
||||||
entries.extend(item for item in group if isinstance(item, dict))
|
continue
|
||||||
|
for item in group:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
entry = dict(item)
|
||||||
|
entry.setdefault('_adapter_name', str(adapter_name or ''))
|
||||||
|
entries.append(entry)
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
entries.extend(item for item in value if isinstance(item, dict))
|
entries.extend(dict(item) for item in value if isinstance(item, dict))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _network_interface_rank(name: Any) -> int:
|
||||||
|
"""Return a stable preference rank for common physical/virtual NIC names."""
|
||||||
|
value = str(name or '').strip().casefold()
|
||||||
|
if not value:
|
||||||
|
return 80
|
||||||
|
|
||||||
|
# Explicitly de-prioritize interfaces that are usually local/container/tunnel
|
||||||
|
# plumbing and should not become the AssetManager management address.
|
||||||
|
low_priority = (
|
||||||
|
'loopback', 'docker', 'veth', 'virbr', 'tun', 'tap', 'tailscale',
|
||||||
|
'wg', 'zt', 'br-',
|
||||||
|
)
|
||||||
|
if value == 'lo' or value.startswith(low_priority):
|
||||||
|
return 200
|
||||||
|
|
||||||
|
# Common Linux, Windows and hypervisor interface names. Comparison is
|
||||||
|
# intentionally case-insensitive.
|
||||||
|
if value.startswith(('ethernet', 'eth')):
|
||||||
|
return 10
|
||||||
|
if value.startswith(('eno', 'ens', 'enp', 'enx')):
|
||||||
|
return 15
|
||||||
|
if value == 'vmbr0':
|
||||||
|
return 20
|
||||||
|
if value.startswith('vmbr'):
|
||||||
|
return 22
|
||||||
|
if value.startswith(('bond', 'team')):
|
||||||
|
return 25
|
||||||
|
if value.startswith(('wlan', 'wlp', 'wifi', 'wi-fi', 'wireless')):
|
||||||
|
return 30
|
||||||
|
if value.startswith(('vethernet', 'hyper-v', 'vmnet')):
|
||||||
|
return 35
|
||||||
|
if value.startswith('br'):
|
||||||
|
return 40
|
||||||
|
return 60
|
||||||
|
|
||||||
|
|
||||||
|
def _network_entry_active(entry: dict[str, Any]) -> bool:
|
||||||
|
status = str(entry.get('status') or '').strip().casefold()
|
||||||
|
return status not in {'down', 'inactive', 'disabled', 'disconnected', 'notpresent'}
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_ipv4(value: Any) -> bool:
|
||||||
|
try:
|
||||||
|
import ipaddress
|
||||||
|
address = ipaddress.ip_address(str(value or '').strip())
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return address.version == 4 and not address.is_loopback and not address.is_unspecified
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_network_mac(value: Any) -> str | None:
|
||||||
|
mac = _normalize_mac(value)
|
||||||
|
if not mac:
|
||||||
|
return None
|
||||||
|
raw = re.sub(r'[^0-9A-Fa-f]', '', mac)
|
||||||
|
# Reject malformed, all-zero and broadcast placeholders. Locally administered
|
||||||
|
# addresses remain valid because they are common for virtual interfaces.
|
||||||
|
if len(raw) != 12 or set(raw.casefold()) == {'0'} or set(raw.casefold()) == {'f'}:
|
||||||
|
return None
|
||||||
|
return ':'.join(raw[index:index + 2] for index in range(0, 12, 2)).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _preferred_network_entries(value: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Order usable network records by interface suitability and source order."""
|
||||||
|
candidates: list[tuple[int, int, dict[str, Any]]] = []
|
||||||
|
for index, entry in enumerate(_network_entries(value)):
|
||||||
|
if entry.get('type') == 'loopback' or not _network_entry_active(entry):
|
||||||
|
continue
|
||||||
|
rank = _network_interface_rank(entry.get('_adapter_name'))
|
||||||
|
candidates.append((rank, index, entry))
|
||||||
|
candidates.sort(key=lambda item: (item[0], item[1]))
|
||||||
|
return [entry for _, _, entry in candidates]
|
||||||
|
|
||||||
|
|
||||||
def _convert_mapping_value(values: list[Any], transform: str, separator: str) -> Any:
|
def _convert_mapping_value(values: list[Any], transform: str, separator: str) -> Any:
|
||||||
if not values:
|
if not values:
|
||||||
return None
|
return None
|
||||||
source = values[0]
|
source = values[0]
|
||||||
if transform == 'active_ipv4_address':
|
if transform == 'active_ipv4_address':
|
||||||
for entry in _network_entries(source):
|
entries = _preferred_network_entries(source)
|
||||||
if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback':
|
# Prefer a usable IPv4 address on well-known management interfaces.
|
||||||
return entry.get('address')
|
for entry in entries:
|
||||||
|
if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')):
|
||||||
|
return str(entry.get('address')).strip()
|
||||||
return None
|
return None
|
||||||
if transform == 'active_ipv4_mac':
|
if transform == 'active_ipv4_mac':
|
||||||
for entry in _network_entries(source):
|
entries = _preferred_network_entries(source)
|
||||||
if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback':
|
# Prefer the MAC from an interface that also has usable IPv4 data. This
|
||||||
return _normalize_mac(entry.get('mac'))
|
# keeps IP and MAC aligned in the common case and avoids all-zero values.
|
||||||
|
for entry in entries:
|
||||||
|
if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')):
|
||||||
|
mac = _valid_network_mac(entry.get('mac'))
|
||||||
|
if mac:
|
||||||
|
return mac
|
||||||
|
# Some MeshCentral inventories expose MAC data in a separate record.
|
||||||
|
for entry in entries:
|
||||||
|
mac = _valid_network_mac(entry.get('mac'))
|
||||||
|
if mac:
|
||||||
|
return mac
|
||||||
return None
|
return None
|
||||||
if transform == 'normalize_serial':
|
if transform == 'normalize_serial':
|
||||||
return _normalize_serial(source)
|
return _normalize_serial(source)
|
||||||
@@ -683,17 +780,72 @@ def _normalize_manufacturer(value: Any) -> str:
|
|||||||
return "".join(tokens)
|
return "".join(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_serial_value(value: Any) -> str:
|
||||||
|
if value in (None, ""):
|
||||||
|
return ""
|
||||||
|
return str(value).replace("\x00", "").strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid_serial_reason(mapped: dict[str, Any], cfg: dict[str, Any]) -> str | None:
|
||||||
|
"""Return a human-readable reason when a reported serial number is a configured placeholder."""
|
||||||
|
serial = _normalized_serial_value(mapped.get("serial_number"))
|
||||||
|
if not serial:
|
||||||
|
return None
|
||||||
|
|
||||||
|
global_values = {
|
||||||
|
_normalized_serial_value(value)
|
||||||
|
for value in (cfg.get("invalid_serial_values") or [])
|
||||||
|
if _normalized_serial_value(value)
|
||||||
|
}
|
||||||
|
if serial in global_values:
|
||||||
|
return "global configured placeholder"
|
||||||
|
|
||||||
|
manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
|
||||||
|
if manufacturer:
|
||||||
|
for rule in cfg.get("manufacturer_invalid_serial_rules") or []:
|
||||||
|
if not isinstance(rule, dict):
|
||||||
|
continue
|
||||||
|
if _normalize_manufacturer(rule.get("manufacturer")) != manufacturer:
|
||||||
|
continue
|
||||||
|
values = {
|
||||||
|
_normalized_serial_value(value)
|
||||||
|
for value in (rule.get("values") or [])
|
||||||
|
if _normalized_serial_value(value)
|
||||||
|
}
|
||||||
|
if serial in values:
|
||||||
|
return f"configured placeholder for {rule.get('manufacturer') or mapped.get('manufacturer')}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _serial_identity_key(mapped: dict[str, Any], *, include_manufacturer: bool) -> tuple[str, str] | None:
|
||||||
|
serial = _normalized_serial_value(mapped.get("serial_number"))
|
||||||
|
if not serial:
|
||||||
|
return None
|
||||||
|
manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) if include_manufacturer else ""
|
||||||
|
return serial, manufacturer
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_mac_identity(value: Any) -> str:
|
||||||
|
mac = _valid_network_mac(value)
|
||||||
|
return mac.casefold() if mac else ""
|
||||||
|
|
||||||
|
|
||||||
def _find_asset(
|
def _find_asset(
|
||||||
db: Session,
|
db: Session,
|
||||||
mapped: dict[str, Any],
|
mapped: dict[str, Any],
|
||||||
order: list[str],
|
order: list[str],
|
||||||
*,
|
*,
|
||||||
serial_match_manufacturer: bool = False,
|
serial_match_manufacturer: bool = False,
|
||||||
|
serial_is_invalid: bool = False,
|
||||||
|
serial_is_ambiguous: bool = False,
|
||||||
) -> tuple[Asset | None, str | None, str | None]:
|
) -> tuple[Asset | None, str | None, str | None]:
|
||||||
|
incoming_mac = _normalized_mac_identity(mapped.get("mac_address"))
|
||||||
for method in order:
|
for method in order:
|
||||||
if method == "node_id" and mapped.get("mesh_node_id"):
|
if method == "node_id" and mapped.get("mesh_node_id"):
|
||||||
matches = db.query(Asset).filter(Asset.mesh_node_id == mapped["mesh_node_id"]).all()
|
matches = db.query(Asset).filter(Asset.mesh_node_id == mapped["mesh_node_id"]).all()
|
||||||
elif method == "serial_number" and mapped.get("serial_number"):
|
elif method == "mac_address" and incoming_mac:
|
||||||
|
matches = db.query(Asset).filter(func.lower(Asset.mac_address) == str(mapped.get("mac_address")).lower()).all()
|
||||||
|
elif method == "serial_number" and mapped.get("serial_number") and not serial_is_invalid and not serial_is_ambiguous:
|
||||||
matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all()
|
matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all()
|
||||||
if serial_match_manufacturer:
|
if serial_match_manufacturer:
|
||||||
incoming_manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
|
incoming_manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
|
||||||
@@ -703,6 +855,16 @@ def _find_asset(
|
|||||||
asset for asset in matches
|
asset for asset in matches
|
||||||
if _normalize_manufacturer(asset.manufacturer) == incoming_manufacturer
|
if _normalize_manufacturer(asset.manufacturer) == incoming_manufacturer
|
||||||
]
|
]
|
||||||
|
# A shared DMI/mainboard serial is common for containers and VMs.
|
||||||
|
# Never accept such a serial match when both sides have a valid but
|
||||||
|
# different MAC address. This avoids reassigning another guest's
|
||||||
|
# MeshCentral node ID.
|
||||||
|
if incoming_mac:
|
||||||
|
matches = [
|
||||||
|
asset for asset in matches
|
||||||
|
if not _normalized_mac_identity(asset.mac_address)
|
||||||
|
or _normalized_mac_identity(asset.mac_address) == incoming_mac
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
if len(matches) == 1:
|
if len(matches) == 1:
|
||||||
@@ -928,12 +1090,34 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
|
|||||||
log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.")
|
log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.")
|
||||||
run.error_count += 1
|
run.error_count += 1
|
||||||
log("info", "Dry-Run gestartet; es werden keine Datenbankänderungen gespeichert." if dry_run else "Synchronisierung gestartet.")
|
log("info", "Dry-Run gestartet; es werden keine Datenbankänderungen gespeichert." if dry_run else "Synchronisierung gestartet.")
|
||||||
log("info", "Asset-Zuordnung: " + " → ".join(cfg.get("match_order", ["node_id", "serial_number"])))
|
log("info", "Asset-Zuordnung: " + " → ".join(cfg.get("match_order", ["node_id", "mac_address", "serial_number"])))
|
||||||
if cfg.get("serial_match_manufacturer", False):
|
if cfg.get("serial_match_manufacturer", False):
|
||||||
log("info", "Seriennummern-Abgleich erfordert zusätzlich einen normalisierten Herstellervergleich.")
|
log("info", "Seriennummern-Abgleich erfordert zusätzlich einen normalisierten Herstellervergleich.")
|
||||||
|
invalid_global_count = len(cfg.get("invalid_serial_values") or [])
|
||||||
|
invalid_manufacturer_count = len(cfg.get("manufacturer_invalid_serial_rules") or [])
|
||||||
|
if invalid_global_count or invalid_manufacturer_count:
|
||||||
|
log("info", f"Dummy-Seriennummernfilter aktiv: {invalid_global_count} globale Werte, {invalid_manufacturer_count} Herstellerregel(n). Node-ID bleibt die stabile Geräteidentität.")
|
||||||
devices = fetch_devices(log)
|
devices = fetch_devices(log)
|
||||||
run.devices_found = len(devices)
|
run.devices_found = len(devices)
|
||||||
log("info", f"Verarbeite {len(devices)} Gerät(e).")
|
log("info", f"Verarbeite {len(devices)} Gerät(e).")
|
||||||
|
|
||||||
|
# Determine serial numbers that are already ambiguous within the current
|
||||||
|
# MeshCentral inventory. This is essential for containers/VMs where the
|
||||||
|
# host DMI/mainboard serial may be exposed identically to several guests.
|
||||||
|
serial_identity_counts: dict[tuple[str, str], int] = {}
|
||||||
|
for raw_for_identity in devices:
|
||||||
|
mapped_for_identity = map_device(raw_for_identity, field_mappings)
|
||||||
|
if _invalid_serial_reason(mapped_for_identity, cfg):
|
||||||
|
continue
|
||||||
|
identity_key = _serial_identity_key(
|
||||||
|
mapped_for_identity,
|
||||||
|
include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
|
||||||
|
)
|
||||||
|
if identity_key:
|
||||||
|
serial_identity_counts[identity_key] = serial_identity_counts.get(identity_key, 0) + 1
|
||||||
|
ambiguous_serial_keys = {key for key, count in serial_identity_counts.items() if count > 1}
|
||||||
|
if ambiguous_serial_keys:
|
||||||
|
log("warning", f"{len(ambiguous_serial_keys)} Seriennummern-Identität(en) sind im aktuellen MeshCentral-Bestand mehrfach vorhanden und werden nicht zum Matching verwendet.")
|
||||||
fallback_id = int(cfg.get("fallback_category_id") or 1)
|
fallback_id = int(cfg.get("fallback_category_id") or 1)
|
||||||
fallback_category = db.query(Category).filter(Category.id == fallback_id).first()
|
fallback_category = db.query(Category).filter(Category.id == fallback_id).first()
|
||||||
if fallback_category is None and fallback_id != 1:
|
if fallback_category is None and fallback_id != 1:
|
||||||
@@ -954,11 +1138,24 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
|
|||||||
log("error", "Gerät ohne Node-ID wurde übersprungen.")
|
log("error", "Gerät ohne Node-ID wurde übersprungen.")
|
||||||
continue
|
continue
|
||||||
seen_node_ids.add(node_id)
|
seen_node_ids.add(node_id)
|
||||||
|
invalid_serial_reason = _invalid_serial_reason(mapped, cfg)
|
||||||
|
serial_identity_key = _serial_identity_key(
|
||||||
|
mapped,
|
||||||
|
include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
|
||||||
|
)
|
||||||
|
serial_is_ambiguous = bool(serial_identity_key and serial_identity_key in ambiguous_serial_keys)
|
||||||
|
if serial_is_ambiguous:
|
||||||
|
log("warning", f"{mapped.get('name', node_id)}: Seriennummer '{mapped.get('serial_number')}' ist im aktuellen MeshCentral-Bestand nicht eindeutig und wird nicht zum Matching verwendet. Node-ID/MAC haben Vorrang.")
|
||||||
|
if invalid_serial_reason:
|
||||||
|
reported_serial = mapped.get("serial_number")
|
||||||
|
log("warning", f"{mapped.get('name', node_id)}: gemeldete Seriennummer '{reported_serial}' als Dummy erkannt ({invalid_serial_reason}); Seriennummer wird weder zum Matching noch zum Überschreiben verwendet. Node-ID: {node_id}.")
|
||||||
asset, match_method, match_error = _find_asset(
|
asset, match_method, match_error = _find_asset(
|
||||||
db,
|
db,
|
||||||
mapped,
|
mapped,
|
||||||
cfg.get("match_order", ["node_id", "serial_number"]),
|
cfg.get("match_order", ["node_id", "mac_address", "serial_number"]),
|
||||||
serial_match_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
|
serial_match_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
|
||||||
|
serial_is_invalid=bool(invalid_serial_reason),
|
||||||
|
serial_is_ambiguous=serial_is_ambiguous,
|
||||||
)
|
)
|
||||||
if match_error:
|
if match_error:
|
||||||
run.conflict_count += 1
|
run.conflict_count += 1
|
||||||
@@ -997,6 +1194,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
|
|||||||
for target, incoming in mapped.items():
|
for target, incoming in mapped.items():
|
||||||
if target == "mesh_device_type" or incoming in (None, "", [], {}):
|
if target == "mesh_device_type" or incoming in (None, "", [], {}):
|
||||||
continue
|
continue
|
||||||
|
if target == "serial_number" and invalid_serial_reason:
|
||||||
|
continue
|
||||||
|
|
||||||
definition = field_definitions.get(target)
|
definition = field_definitions.get(target)
|
||||||
if definition is None:
|
if definition is None:
|
||||||
|
|||||||
@@ -2851,3 +2851,113 @@ table.server-filter-loading tbody{opacity:.55;transition:opacity .12s ease}
|
|||||||
.privacy-settings-form[data-privacy-readonly="true"] input:disabled,
|
.privacy-settings-form[data-privacy-readonly="true"] input:disabled,
|
||||||
.privacy-settings-form[data-privacy-readonly="true"] textarea:disabled,
|
.privacy-settings-form[data-privacy-readonly="true"] textarea:disabled,
|
||||||
.privacy-settings-form[data-privacy-readonly="true"] select:disabled { opacity: 1; color: inherit; -webkit-text-fill-color: currentColor; cursor: default; }
|
.privacy-settings-form[data-privacy-readonly="true"] select:disabled { opacity: 1; color: inherit; -webkit-text-fill-color: currentColor; cursor: default; }
|
||||||
|
|
||||||
|
/* First-start security notice shown while authentication is disabled. */
|
||||||
|
.authentication-disabled-banner{display:flex;align-items:flex-start;gap:.75rem;padding:.7rem 1.25rem;background:#fff3cd;border-bottom:1px solid #d9b650;color:#4a3a00;font-size:.92rem;line-height:1.35}
|
||||||
|
.authentication-disabled-banner strong{white-space:nowrap}
|
||||||
|
html[data-theme="dark"] .authentication-disabled-banner{background:#3b310d;border-bottom-color:#7f6a1d;color:#fff0ad}
|
||||||
|
@media(max-width:800px){.authentication-disabled-banner{flex-direction:column;gap:.25rem}.authentication-disabled-banner strong{white-space:normal}}
|
||||||
|
|
||||||
|
/* Active category / duplicate filters on the asset list. */
|
||||||
|
.asset-context-filter-details{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||||
|
.asset-filter-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 7px;border:1px solid currentColor;border-radius:999px;background:rgba(255,255,255,.45)}
|
||||||
|
.asset-filter-chip-remove{display:inline-grid;place-items:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;font:inherit;font-size:17px;line-height:1;cursor:pointer}
|
||||||
|
.asset-filter-chip-remove:hover,.asset-filter-chip-remove:focus-visible{background:rgba(0,0,0,.10);outline:none}
|
||||||
|
html[data-theme="dark"] .asset-filter-chip{background:rgba(0,0,0,.16)}
|
||||||
|
html[data-theme="dark"] .asset-filter-chip-remove:hover,html[data-theme="dark"] .asset-filter-chip-remove:focus-visible{background:rgba(255,255,255,.12)}
|
||||||
|
|
||||||
|
/* v0.5.5.41: generated visual row number column for data tables. */
|
||||||
|
.data-table .table-row-number-header,
|
||||||
|
.data-table .table-row-number-cell{
|
||||||
|
width:1%;
|
||||||
|
min-width:3.25rem;
|
||||||
|
white-space:nowrap;
|
||||||
|
text-align:right;
|
||||||
|
font-variant-numeric:tabular-nums;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-header{
|
||||||
|
cursor:default;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-cell{
|
||||||
|
color:var(--am-muted,#64748b);
|
||||||
|
user-select:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Visual row-number column: readable by default and user-resizable per table. */
|
||||||
|
.data-table .table-row-number-header,
|
||||||
|
.data-table .table-row-number-filter,
|
||||||
|
.data-table .table-row-number-cell {
|
||||||
|
width: var(--table-row-number-width, 64px);
|
||||||
|
min-width: var(--table-row-number-width, 64px);
|
||||||
|
max-width: var(--table-row-number-width, 64px);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-header {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 14px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-cell {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -4px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 9px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 3;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-resizer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 20%;
|
||||||
|
bottom: 20%;
|
||||||
|
left: 4px;
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.data-table .table-row-number-resizer:hover::after,
|
||||||
|
.data-table .table-row-number-resizer:focus-visible::after {
|
||||||
|
border-left-color: currentColor;
|
||||||
|
}
|
||||||
|
html.table-column-resizing,
|
||||||
|
html.table-column-resizing * {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* User-resizable widths for every data-table column. */
|
||||||
|
.data-table th.table-resizable-column-header {
|
||||||
|
position: relative;
|
||||||
|
padding-right: 14px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.data-table .table-column-resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -4px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 9px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 4;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.data-table .table-column-resizer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 20%;
|
||||||
|
bottom: 20%;
|
||||||
|
left: 4px;
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.data-table .table-column-resizer:hover::after,
|
||||||
|
.data-table .table-column-resizer:focus-visible::after {
|
||||||
|
border-left-color: currentColor;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
const ignoreEmpty = document.getElementById('duplicate-ignore-empty');
|
const ignoreEmpty = document.getElementById('duplicate-ignore-empty');
|
||||||
const caseInsensitive = document.getElementById('duplicate-case-insensitive');
|
const caseInsensitive = document.getElementById('duplicate-case-insensitive');
|
||||||
const summary = document.getElementById('duplicate-search-summary');
|
const summary = document.getElementById('duplicate-search-summary');
|
||||||
|
const contextStatus = document.getElementById('asset-context-filter-status');
|
||||||
|
const duplicateChip = document.getElementById('asset-duplicate-filter-chip');
|
||||||
|
const duplicateLabel = document.getElementById('asset-duplicate-filter-label');
|
||||||
|
const duplicateRemove = document.getElementById('asset-duplicate-filter-remove');
|
||||||
|
const categoryRemove = document.getElementById('asset-category-filter-remove');
|
||||||
if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return;
|
if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return;
|
||||||
table.dataset.duplicateSearchReady = '1';
|
table.dataset.duplicateSearchReady = '1';
|
||||||
|
|
||||||
@@ -26,6 +31,17 @@
|
|||||||
label: th.dataset.label || th.textContent.trim()
|
label: th.dataset.label || th.textContent.trim()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function refreshContextStatus() {
|
||||||
|
const categoryChip = document.getElementById('asset-category-filter-chip');
|
||||||
|
const duplicateActive = table.dataset.duplicateFilterActive === '1';
|
||||||
|
if (duplicateChip) duplicateChip.hidden = !duplicateActive;
|
||||||
|
if (contextStatus) {
|
||||||
|
const hasActive = Boolean(categoryChip) || duplicateActive;
|
||||||
|
contextStatus.hidden = !hasActive;
|
||||||
|
contextStatus.classList.toggle('is-active', hasActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fillSelect() {
|
function fillSelect() {
|
||||||
const previous = select.value;
|
const previous = select.value;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
@@ -42,8 +58,13 @@
|
|||||||
|
|
||||||
function clearDuplicateFilter() {
|
function clearDuplicateFilter() {
|
||||||
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
|
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
|
||||||
summary.hidden = true;
|
delete table.dataset.duplicateFilterActive;
|
||||||
window.dispatchEvent(new Event('asset-table-filter-changed'));
|
delete table.dataset.duplicateFilterTotal;
|
||||||
|
delete table.dataset.duplicateFilterField;
|
||||||
|
if (summary) summary.hidden = true;
|
||||||
|
if (duplicateLabel) duplicateLabel.textContent = '';
|
||||||
|
refreshContextStatus();
|
||||||
|
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {detail: {table}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(value) {
|
function normalize(value) {
|
||||||
@@ -93,9 +114,24 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
summary.textContent = `${duplicateRows.size} Datensätze in ${duplicateGroups} Dublettengruppen gefunden.`;
|
table.dataset.duplicateFilterActive = '1';
|
||||||
summary.hidden = false;
|
table.dataset.duplicateFilterTotal = String(duplicateRows.size);
|
||||||
window.dispatchEvent(new Event('asset-table-filter-changed'));
|
table.dataset.duplicateFilterField = field;
|
||||||
|
|
||||||
|
const selectedOption = select.options[select.selectedIndex];
|
||||||
|
const fieldLabel = selectedOption?.textContent?.trim() || field;
|
||||||
|
const duplicateTemplate = contextStatus?.dataset.duplicateTemplate || 'Duplikate: {field}';
|
||||||
|
if (duplicateLabel) duplicateLabel.textContent = duplicateTemplate.replace('{field}', fieldLabel);
|
||||||
|
refreshContextStatus();
|
||||||
|
|
||||||
|
const summaryTemplate = dialog.dataset.resultSummaryTemplate || '{records} Datensätze in {groups} Dublettengruppen gefunden.';
|
||||||
|
if (summary) {
|
||||||
|
summary.textContent = summaryTemplate
|
||||||
|
.replace('{records}', String(duplicateRows.size))
|
||||||
|
.replace('{groups}', String(duplicateGroups));
|
||||||
|
summary.hidden = false;
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {detail: {table}}));
|
||||||
dialog.close();
|
dialog.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +144,13 @@
|
|||||||
});
|
});
|
||||||
applyButton.addEventListener('click', applyDuplicateFilter);
|
applyButton.addEventListener('click', applyDuplicateFilter);
|
||||||
clearButton.addEventListener('click', clearDuplicateFilter);
|
clearButton.addEventListener('click', clearDuplicateFilter);
|
||||||
|
duplicateRemove?.addEventListener('click', clearDuplicateFilter);
|
||||||
|
categoryRemove?.addEventListener('click', () => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('category_id');
|
||||||
|
window.location.assign(url.toString());
|
||||||
|
});
|
||||||
|
refreshContextStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
@@ -35,15 +35,35 @@
|
|||||||
return control.name || control.dataset.field || cell?.dataset.field || `filter-${index}`;
|
return control.name || control.dataset.field || cell?.dataset.field || `filter-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreSort(table, index, force = false) {
|
||||||
|
if (!force && table.dataset.browserSortRestored === '1') return;
|
||||||
|
if (typeof table.assetApplySort !== 'function') return;
|
||||||
|
|
||||||
|
const key = `${tableKey(table, index)}:sort`;
|
||||||
|
let state = null;
|
||||||
|
try { state = JSON.parse(api.get(key) || 'null'); } catch (_) {}
|
||||||
|
if (state?.field && (state.direction === 'asc' || state.direction === 'desc')) {
|
||||||
|
table.assetApplySort(state.field, state.direction, {silent: true});
|
||||||
|
} else {
|
||||||
|
table.assetUpdateRowNumbers?.();
|
||||||
|
}
|
||||||
|
table.dataset.browserSortRestored = '1';
|
||||||
|
}
|
||||||
|
|
||||||
function restoreTable(table, index) {
|
function restoreTable(table, index) {
|
||||||
if (table.dataset.browserStateReady === '1') return;
|
if (table.dataset.browserStateReady === '1') return;
|
||||||
if (table.dataset.serverFilters === '1') {
|
if (table.dataset.serverFilters === '1') {
|
||||||
table.dataset.browserStateReady = '1';
|
table.dataset.browserStateReady = '1';
|
||||||
|
restoreSort(table, index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const restoreStarted = performance.now();
|
const restoreStarted = performance.now();
|
||||||
const controls = filterControls(table);
|
const controls = filterControls(table);
|
||||||
if (!controls.length) return;
|
if (!controls.length) {
|
||||||
|
table.dataset.browserStateReady = '1';
|
||||||
|
restoreSort(table, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
table.dataset.browserStateReady = '1';
|
table.dataset.browserStateReady = '1';
|
||||||
const key = `${tableKey(table, index)}:filters`;
|
const key = `${tableKey(table, index)}:filters`;
|
||||||
@@ -71,6 +91,7 @@
|
|||||||
if (typeof table.assetApplyFilters === 'function') {
|
if (typeof table.assetApplyFilters === 'function') {
|
||||||
table.assetApplyFilters();
|
table.assetApplyFilters();
|
||||||
}
|
}
|
||||||
|
restoreSort(table, index);
|
||||||
table.dataset.browserStateRestored = '1';
|
table.dataset.browserStateRestored = '1';
|
||||||
window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } }));
|
window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } }));
|
||||||
const duration = performance.now() - restoreStarted;
|
const duration = performance.now() - restoreStarted;
|
||||||
@@ -109,5 +130,25 @@
|
|||||||
api.remove(key);
|
api.remove(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('asset-table-sort-changed', 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)}:sort`;
|
||||||
|
api.set(key, JSON.stringify({
|
||||||
|
field: event.detail?.field || '',
|
||||||
|
direction: event.detail?.direction || 'asc'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('asset-table-content-reloaded', event => {
|
||||||
|
const table = event.detail?.table;
|
||||||
|
if (!(table instanceof HTMLTableElement)) return;
|
||||||
|
const tables = [...document.querySelectorAll('table')];
|
||||||
|
const index = tables.indexOf(table);
|
||||||
|
restoreSort(table, index >= 0 ? index : 0, true);
|
||||||
|
});
|
||||||
|
|
||||||
perf?.end('browser-state:gesamtes Modul');
|
perf?.end('browser-state:gesamtes Modul');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isVisible(row) {
|
function isVisible(row) {
|
||||||
if (row.hidden || row.classList.contains('hidden')) return false;
|
if (row.hidden || row.classList.contains('hidden') || row.classList.contains('duplicate-hidden')) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,23 +40,27 @@
|
|||||||
const updateStarted = performance.now();
|
const updateStarted = performance.now();
|
||||||
const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow);
|
const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow);
|
||||||
const visible = rows.filter(isVisible).length;
|
const visible = rows.filter(isVisible).length;
|
||||||
|
const duplicateTotal = Number.parseInt(table.dataset.duplicateFilterTotal || '', 10);
|
||||||
|
const total = table.dataset.duplicateFilterActive === '1' && Number.isFinite(duplicateTotal)
|
||||||
|
? duplicateTotal
|
||||||
|
: rows.length;
|
||||||
const target = countTarget(table, index);
|
const target = countTarget(table, index);
|
||||||
if (target) {
|
if (target) {
|
||||||
const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen';
|
const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen';
|
||||||
target.textContent = template
|
target.textContent = template
|
||||||
.replace('{visible}', String(visible))
|
.replace('{visible}', String(visible))
|
||||||
.replace('{total}', String(rows.length));
|
.replace('{total}', String(total));
|
||||||
target.title = document.body.dataset.recordCountTitle || '';
|
target.title = document.body.dataset.recordCountTitle || '';
|
||||||
}
|
}
|
||||||
window.dispatchEvent(new CustomEvent('asset-list-count-updated', {
|
window.dispatchEvent(new CustomEvent('asset-list-count-updated', {
|
||||||
detail: { table, visible, total: rows.length }
|
detail: { table, visible, total }
|
||||||
}));
|
}));
|
||||||
const duration = performance.now() - updateStarted;
|
const duration = performance.now() - updateStarted;
|
||||||
const logger = duration >= 50 ? console.warn : console.debug;
|
const logger = duration >= 50 ? console.warn : console.debug;
|
||||||
logger('[AssetManager][Performance]', 'list-tools:update', {
|
logger('[AssetManager][Performance]', 'list-tools:update', {
|
||||||
dauer_ms: Math.round(duration * 10) / 10,
|
dauer_ms: Math.round(duration * 10) / 10,
|
||||||
sichtbar: visible,
|
sichtbar: visible,
|
||||||
gesamt: rows.length
|
gesamt: total
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+294
-27
@@ -163,6 +163,221 @@
|
|||||||
|
|
||||||
const headerRow = thead.rows[0];
|
const headerRow = thead.rows[0];
|
||||||
|
|
||||||
|
// Add a purely visual row-number column before every real table column.
|
||||||
|
// It is recalculated after filtering, sorting and partial server reloads and
|
||||||
|
// therefore never represents a database identifier.
|
||||||
|
if (!headerRow.querySelector('th[data-row-number-column="1"]')) {
|
||||||
|
const numberHeader = document.createElement('th');
|
||||||
|
numberHeader.className = 'table-row-number-header';
|
||||||
|
numberHeader.dataset.rowNumberColumn = '1';
|
||||||
|
numberHeader.dataset.noSort = '1';
|
||||||
|
numberHeader.dataset.noFilter = '1';
|
||||||
|
numberHeader.textContent = '#';
|
||||||
|
numberHeader.title = document.body.dataset.rowNumberLabel || 'Row number';
|
||||||
|
headerRow.insertBefore(numberHeader, headerRow.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]');
|
||||||
|
const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname;
|
||||||
|
const rowNumberWidthKey = `assetmanager:table:${rowNumberStoragePart}:row-number-width`;
|
||||||
|
const defaultRowNumberWidth = 64;
|
||||||
|
const minimumRowNumberWidth = 48;
|
||||||
|
const maximumRowNumberWidth = 180;
|
||||||
|
|
||||||
|
function readStoredRowNumberWidth() {
|
||||||
|
try {
|
||||||
|
const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10);
|
||||||
|
if (Number.isFinite(stored)) {
|
||||||
|
return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return defaultRowNumberWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRowNumberWidth(width) {
|
||||||
|
const normalized = Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, Math.round(width)));
|
||||||
|
table.style.setProperty('--table-row-number-width', `${normalized}px`);
|
||||||
|
table.dataset.rowNumberWidth = String(normalized);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installRowNumberResizer() {
|
||||||
|
if (!numberHeader || numberHeader.querySelector('.table-row-number-resizer')) return;
|
||||||
|
const handle = document.createElement('span');
|
||||||
|
handle.className = 'table-row-number-resizer';
|
||||||
|
handle.setAttribute('role', 'separator');
|
||||||
|
handle.setAttribute('aria-orientation', 'vertical');
|
||||||
|
handle.tabIndex = 0;
|
||||||
|
handle.title = document.body.dataset.rowNumberResizeLabel || 'Drag to change column width';
|
||||||
|
numberHeader.appendChild(handle);
|
||||||
|
|
||||||
|
const saveWidth = width => {
|
||||||
|
const normalized = applyRowNumberWidth(width);
|
||||||
|
try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth;
|
||||||
|
handle.setPointerCapture?.(event.pointerId);
|
||||||
|
document.documentElement.classList.add('table-column-resizing');
|
||||||
|
|
||||||
|
const move = moveEvent => applyRowNumberWidth(startWidth + moveEvent.clientX - startX);
|
||||||
|
const finish = finishEvent => {
|
||||||
|
saveWidth(Number.parseInt(table.dataset.rowNumberWidth || '', 10) || startWidth);
|
||||||
|
handle.releasePointerCapture?.(finishEvent.pointerId);
|
||||||
|
document.documentElement.classList.remove('table-column-resizing');
|
||||||
|
handle.removeEventListener('pointermove', move);
|
||||||
|
handle.removeEventListener('pointerup', finish);
|
||||||
|
handle.removeEventListener('pointercancel', finish);
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', move);
|
||||||
|
handle.addEventListener('pointerup', finish);
|
||||||
|
handle.addEventListener('pointercancel', finish);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||||
|
event.preventDefault();
|
||||||
|
const current = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth;
|
||||||
|
saveWidth(current + (event.key === 'ArrowRight' ? 8 : -8));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyRowNumberWidth(readStoredRowNumberWidth());
|
||||||
|
installRowNumberResizer();
|
||||||
|
|
||||||
|
|
||||||
|
// Every visible data column can be resized independently. Widths are
|
||||||
|
// stored per table and per field/index so they survive navigation,
|
||||||
|
// reloads and container updates. The visual row-number column keeps its
|
||||||
|
// dedicated implementation above.
|
||||||
|
const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`;
|
||||||
|
const minimumColumnWidth = 56;
|
||||||
|
const maximumColumnWidth = 1200;
|
||||||
|
|
||||||
|
function columnStorageId(header, columnIndex) {
|
||||||
|
const field = header?.dataset?.field;
|
||||||
|
if (field) return field;
|
||||||
|
return `index-${columnIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColumnWidth(width) {
|
||||||
|
return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColumnWidth(columnIndex, width) {
|
||||||
|
const normalized = normalizeColumnWidth(width);
|
||||||
|
const rows = [
|
||||||
|
...thead.rows,
|
||||||
|
...tbody.rows
|
||||||
|
];
|
||||||
|
rows.forEach(row => {
|
||||||
|
const cell = row.cells?.[columnIndex];
|
||||||
|
if (!cell || cell.dataset.rowNumberColumn === '1') return;
|
||||||
|
cell.style.width = `${normalized}px`;
|
||||||
|
cell.style.minWidth = `${normalized}px`;
|
||||||
|
cell.style.maxWidth = `${normalized}px`;
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredColumnWidth(header, columnIndex) {
|
||||||
|
try {
|
||||||
|
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
|
||||||
|
const stored = Number.parseInt(window.localStorage.getItem(key) || '', 10);
|
||||||
|
if (Number.isFinite(stored)) return normalizeColumnWidth(stored);
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveColumnWidth(header, columnIndex, width) {
|
||||||
|
const normalized = applyColumnWidth(columnIndex, width);
|
||||||
|
try {
|
||||||
|
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
|
||||||
|
window.localStorage.setItem(key, String(normalized));
|
||||||
|
} catch (_) {}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStoredColumnWidths() {
|
||||||
|
[...headerRow.cells].forEach((header, columnIndex) => {
|
||||||
|
if (header.dataset.rowNumberColumn === '1') return;
|
||||||
|
const stored = readStoredColumnWidth(header, columnIndex);
|
||||||
|
if (stored !== null) applyColumnWidth(columnIndex, stored);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function installColumnResizers() {
|
||||||
|
[...headerRow.cells].forEach((header, columnIndex) => {
|
||||||
|
if (header.dataset.rowNumberColumn === '1') return;
|
||||||
|
if (header.dataset.noResize === '1') return;
|
||||||
|
if (header.querySelector(':scope > .table-column-resizer')) return;
|
||||||
|
|
||||||
|
header.classList.add('table-resizable-column-header');
|
||||||
|
const handle = document.createElement('span');
|
||||||
|
handle.className = 'table-column-resizer';
|
||||||
|
handle.setAttribute('role', 'separator');
|
||||||
|
handle.setAttribute('aria-orientation', 'vertical');
|
||||||
|
handle.tabIndex = 0;
|
||||||
|
handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width';
|
||||||
|
header.appendChild(handle);
|
||||||
|
|
||||||
|
handle.addEventListener('click', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const measured = header.getBoundingClientRect().width;
|
||||||
|
const startWidth = measured > 0 ? measured : minimumColumnWidth;
|
||||||
|
handle.setPointerCapture?.(event.pointerId);
|
||||||
|
document.documentElement.classList.add('table-column-resizing');
|
||||||
|
|
||||||
|
const move = moveEvent => {
|
||||||
|
applyColumnWidth(columnIndex, startWidth + moveEvent.clientX - startX);
|
||||||
|
};
|
||||||
|
const finish = finishEvent => {
|
||||||
|
const current = header.getBoundingClientRect().width || startWidth;
|
||||||
|
saveColumnWidth(header, columnIndex, current);
|
||||||
|
handle.releasePointerCapture?.(finishEvent.pointerId);
|
||||||
|
document.documentElement.classList.remove('table-column-resizing');
|
||||||
|
handle.removeEventListener('pointermove', move);
|
||||||
|
handle.removeEventListener('pointerup', finish);
|
||||||
|
handle.removeEventListener('pointercancel', finish);
|
||||||
|
};
|
||||||
|
handle.addEventListener('pointermove', move);
|
||||||
|
handle.addEventListener('pointerup', finish);
|
||||||
|
handle.addEventListener('pointercancel', finish);
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.addEventListener('keydown', event => {
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const current = header.getBoundingClientRect().width || minimumColumnWidth;
|
||||||
|
saveColumnWidth(header, columnIndex, current + (event.key === 'ArrowRight' ? 12 : -12));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRowNumberCells() {
|
||||||
|
[...tbody.rows].forEach(row => {
|
||||||
|
if (row.querySelector('td[data-row-number-column="1"]')) return;
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
cell.className = 'table-row-number-cell';
|
||||||
|
cell.dataset.rowNumberColumn = '1';
|
||||||
|
cell.setAttribute('aria-hidden', 'true');
|
||||||
|
row.insertBefore(cell, row.firstChild);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ensureRowNumberCells();
|
||||||
|
|
||||||
const isAssetTable = table.id === 'asset-table';
|
const isAssetTable = table.id === 'asset-table';
|
||||||
const usesServerFilters = table.dataset.serverFilters === '1';
|
const usesServerFilters = table.dataset.serverFilters === '1';
|
||||||
let serverFilterTimer = null;
|
let serverFilterTimer = null;
|
||||||
@@ -173,15 +388,19 @@
|
|||||||
// real data-field keys on its movable columns; selection and action
|
// real data-field keys on its movable columns; selection and action
|
||||||
// columns must remain without a field key.
|
// columns must remain without a field key.
|
||||||
if (!isAssetTable) {
|
if (!isAssetTable) {
|
||||||
[...headerRow.cells].forEach((header, index) => {
|
let syntheticIndex = 0;
|
||||||
|
[...headerRow.cells].forEach(header => {
|
||||||
|
if (header.dataset.rowNumberColumn === '1') return;
|
||||||
if (!header.dataset.field) {
|
if (!header.dataset.field) {
|
||||||
header.dataset.field = `__col_${index}`;
|
header.dataset.field = `__col_${syntheticIndex}`;
|
||||||
header.dataset.syntheticField = '1';
|
header.dataset.syntheticField = '1';
|
||||||
}
|
}
|
||||||
|
syntheticIndex += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
[...tbody.rows].forEach(row => {
|
[...tbody.rows].forEach(row => {
|
||||||
[...row.cells].forEach((cell, index) => {
|
[...row.cells].forEach((cell, index) => {
|
||||||
|
if (cell.dataset.rowNumberColumn === '1') return;
|
||||||
const header = headerRow.cells[index];
|
const header = headerRow.cells[index];
|
||||||
if (header && !cell.dataset.field) {
|
if (header && !cell.dataset.field) {
|
||||||
cell.dataset.field = header.dataset.field;
|
cell.dataset.field = header.dataset.field;
|
||||||
@@ -291,10 +510,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rebuildMissingCellKeys() {
|
function rebuildMissingCellKeys() {
|
||||||
|
ensureRowNumberCells();
|
||||||
if (isAssetTable) return;
|
if (isAssetTable) return;
|
||||||
|
|
||||||
[...tbody.rows].forEach(row => {
|
[...tbody.rows].forEach(row => {
|
||||||
[...row.cells].forEach((cell, index) => {
|
[...row.cells].forEach((cell, index) => {
|
||||||
|
if (cell.dataset.rowNumberColumn === '1') return;
|
||||||
const header = headerRow.cells[index];
|
const header = headerRow.cells[index];
|
||||||
if (header && !cell.dataset.field) {
|
if (header && !cell.dataset.field) {
|
||||||
cell.dataset.field = header.dataset.field;
|
cell.dataset.field = header.dataset.field;
|
||||||
@@ -405,6 +626,20 @@
|
|||||||
else serverFilterTimer = window.setTimeout(navigate, 700);
|
else serverFilterTimer = window.setTimeout(navigate, 700);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRowNumbers() {
|
||||||
|
let visibleIndex = 0;
|
||||||
|
[...tbody.rows].forEach(row => {
|
||||||
|
const cell = row.querySelector('td[data-row-number-column="1"]');
|
||||||
|
if (!cell) return;
|
||||||
|
if (row.hidden) {
|
||||||
|
cell.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visibleIndex += 1;
|
||||||
|
cell.textContent = String(visibleIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
rebuildMissingCellKeys();
|
rebuildMissingCellKeys();
|
||||||
|
|
||||||
@@ -419,6 +654,7 @@
|
|||||||
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
|
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
|
||||||
const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length;
|
const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length;
|
||||||
updateFilterPresentation(active, tbody.rows.length, total);
|
updateFilterPresentation(active, tbody.rows.length, total);
|
||||||
|
updateRowNumbers();
|
||||||
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
||||||
detail: {
|
detail: {
|
||||||
table,
|
table,
|
||||||
@@ -447,6 +683,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateFilterPresentation(active, visibleRows, tbody.rows.length);
|
updateFilterPresentation(active, visibleRows, tbody.rows.length);
|
||||||
|
updateRowNumbers();
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -458,6 +695,43 @@
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySort(field, direction, options = {}) {
|
||||||
|
const header = [...headerRow.cells].find(item => item.dataset.field === field);
|
||||||
|
if (!header || header.dataset.noSort) return false;
|
||||||
|
|
||||||
|
const columnIndex = header.cellIndex;
|
||||||
|
[...headerRow.cells].forEach(item => {
|
||||||
|
delete item.dataset.sortDirection;
|
||||||
|
item.classList.remove('sort-asc', 'sort-desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedDirection = direction === 'desc' ? 'desc' : 'asc';
|
||||||
|
header.dataset.sortDirection = normalizedDirection;
|
||||||
|
header.classList.add(normalizedDirection === '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 normalizedDirection === 'asc' ? result : -result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
rows.forEach(row => fragment.appendChild(row));
|
||||||
|
tbody.appendChild(fragment);
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
if (!options.silent) {
|
||||||
|
window.dispatchEvent(new CustomEvent('asset-table-sort-changed', {
|
||||||
|
detail: { table, field, direction: normalizedDirection }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
[...headerRow.cells].forEach((header, columnIndex) => {
|
[...headerRow.cells].forEach((header, columnIndex) => {
|
||||||
const field = header.dataset.field || '';
|
const field = header.dataset.field || '';
|
||||||
const serverName = header.dataset.serverFilterName || '';
|
const serverName = header.dataset.serverFilterName || '';
|
||||||
@@ -468,37 +742,18 @@
|
|||||||
|
|
||||||
header.addEventListener('click', event => {
|
header.addEventListener('click', event => {
|
||||||
if (event.target.closest('input,button,a,select,textarea,label')) return;
|
if (event.target.closest('input,button,a,select,textarea,label')) return;
|
||||||
|
|
||||||
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
|
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
applySort(field, direction);
|
||||||
[...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');
|
const filterCell = document.createElement('th');
|
||||||
if (field) filterCell.dataset.field = field;
|
if (field) filterCell.dataset.field = field;
|
||||||
filterCell.dataset.columnIndex = String(columnIndex);
|
filterCell.dataset.columnIndex = String(columnIndex);
|
||||||
|
if (header.dataset.rowNumberColumn === '1') {
|
||||||
|
filterCell.dataset.rowNumberColumn = '1';
|
||||||
|
filterCell.className = 'table-row-number-filter';
|
||||||
|
}
|
||||||
|
|
||||||
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
|
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -541,6 +796,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
thead.appendChild(filterRow);
|
thead.appendChild(filterRow);
|
||||||
|
installColumnResizers();
|
||||||
|
applyStoredColumnWidths();
|
||||||
|
|
||||||
// Editable cells are always read live, so changed values immediately
|
// Editable cells are always read live, so changed values immediately
|
||||||
// participate in filtering without a stale cache.
|
// participate in filtering without a stale cache.
|
||||||
@@ -553,7 +810,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
table.assetApplyFilters = applyFilters;
|
table.assetApplyFilters = applyFilters;
|
||||||
|
table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options);
|
||||||
|
table.assetUpdateRowNumbers = updateRowNumbers;
|
||||||
table.assetRebuildFilterCache = rebuildMissingCellKeys;
|
table.assetRebuildFilterCache = rebuildMissingCellKeys;
|
||||||
|
table.assetApplyStoredColumnWidths = applyStoredColumnWidths;
|
||||||
table.dataset.tableToolsReady = '1';
|
table.dataset.tableToolsReady = '1';
|
||||||
ensureFilterStatus();
|
ensureFilterStatus();
|
||||||
|
|
||||||
@@ -586,7 +846,14 @@
|
|||||||
const table = event.detail?.table;
|
const table = event.detail?.table;
|
||||||
if (table?.assetRebuildFilterCache) {
|
if (table?.assetRebuildFilterCache) {
|
||||||
table.assetRebuildFilterCache();
|
table.assetRebuildFilterCache();
|
||||||
table.assetApplyFilters?.();
|
const sortedHeader = table.tHead?.rows?.[0]?.querySelector('[data-sort-direction]');
|
||||||
|
if (sortedHeader?.dataset.field && table.assetApplySort) {
|
||||||
|
table.assetApplySort(sortedHeader.dataset.field, sortedHeader.dataset.sortDirection, {silent: true});
|
||||||
|
} else {
|
||||||
|
table.assetApplyFilters?.();
|
||||||
|
}
|
||||||
|
table.assetUpdateRowNumbers?.();
|
||||||
|
table.assetApplyStoredColumnWidths?.();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -36,6 +36,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters"><a href="/assets">{{ t('common.all', 'Alle') }}</a>{% for c in categories %}<a href="/assets?category_id={{ c.id }}">{{ c.name }}</a>{% endfor %}</div>
|
<div class="filters"><a href="/assets">{{ t('common.all', 'Alle') }}</a>{% for c in categories %}<a href="/assets?category_id={{ c.id }}">{{ c.name }}</a>{% endfor %}</div>
|
||||||
|
<div id="asset-context-filter-status" class="table-filter-status asset-context-filter-status{% if selected %} is-active{% endif %}" {% if not selected %}hidden{% endif %} role="status" aria-live="polite"
|
||||||
|
data-active-label="{{ t('assets.active_selection', 'Aktive Auswahl')|e }}"
|
||||||
|
data-duplicate-template="{{ t('duplicates.active_filter', 'Duplikate: {field}')|e }}">
|
||||||
|
<span class="table-filter-status-icon" aria-hidden="true">⌕</span>
|
||||||
|
<strong class="table-filter-status-label">{{ t('assets.active_selection', 'Aktive Auswahl') }}</strong>
|
||||||
|
<span class="table-filter-status-details asset-context-filter-details">
|
||||||
|
{% if selected %}
|
||||||
|
<span class="asset-filter-chip" id="asset-category-filter-chip">
|
||||||
|
{{ t('assets.category_filter', 'Kategorie: {category}').format(category=selected.name) }}
|
||||||
|
<button type="button" class="asset-filter-chip-remove" id="asset-category-filter-remove" title="{{ t('assets.clear_category_filter', 'Kategoriefilter entfernen')|e }}" aria-label="{{ t('assets.clear_category_filter', 'Kategoriefilter entfernen')|e }}">×</button>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="asset-filter-chip" id="asset-duplicate-filter-chip" hidden>
|
||||||
|
<span id="asset-duplicate-filter-label"></span>
|
||||||
|
<button type="button" class="asset-filter-chip-remove" id="asset-duplicate-filter-remove" title="{{ t('duplicates.remove_filter', 'Dublettenfilter entfernen')|e }}" aria-label="{{ t('duplicates.remove_filter', 'Dublettenfilter entfernen')|e }}">×</button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{% if job_filter %}
|
{% if job_filter %}
|
||||||
<div class="notice job-filter-summary">
|
<div class="notice job-filter-summary">
|
||||||
<strong>{{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}:</strong>
|
<strong>{{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}:</strong>
|
||||||
@@ -193,7 +211,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<dialog id="duplicate-search-dialog" class="duplicate-search-dialog">
|
<dialog id="duplicate-search-dialog" class="duplicate-search-dialog" data-result-summary-template="{{ t('duplicates.result_summary', '{records} Datensätze in {groups} Dublettengruppen gefunden.')|e }}">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<div class="dialog-heading">
|
<div class="dialog-heading">
|
||||||
<h2>{{ t('duplicates.search_title') }}</h2>
|
<h2>{{ t('duplicates.search_title') }}</h2>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
--am-warning:{{ colors.get('warning_color', '#d97706') }};
|
--am-warning:{{ colors.get('warning_color', '#d97706') }};
|
||||||
--am-danger:{{ colors.get('danger_color', '#b42318') }};
|
--am-danger:{{ colors.get('danger_color', '#b42318') }};
|
||||||
}
|
}
|
||||||
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
|
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-row-number-label="{{ t('lists.row_number', 'Zeilennummer')|e }}" data-row-number-resize-label="{{ t('lists.row_number_resize', 'Ziehen, um die Breite der Nummernspalte zu ändern')|e }}" data-column-resize-label="{{ t('lists.column_resize', 'Ziehen, um die Spaltenbreite zu ändern')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company">– {{ general.get('company_name') }}</span>{% endif %}</a>
|
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company">– {{ general.get('company_name') }}</span>{% endif %}</a>
|
||||||
|
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
|
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
|
||||||
<details class="user-menu"><summary>👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾</summary><div class="user-menu-panel">{% if session_user %}<a href="/profile">{{ t('app.profile') }}</a><a href="/logout">{{ t('app.logout') }}</a>{% else %}{% if auth.get('mode', 'none') == 'none' %}<span class="muted">Authentication disabled</span>{% endif %}<a href="/login">{{ t('app.login') }}</a>{% endif %}</div></details></div>
|
<details class="user-menu"><summary>👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾</summary><div class="user-menu-panel">{% if session_user %}<a href="/profile">{{ t('app.profile') }}</a><a href="/logout">{{ t('app.logout') }}</a>{% else %}{% if auth.get('mode', 'none') == 'none' %}<span class="muted">Authentication disabled</span>{% endif %}<a href="/login">{{ t('app.login') }}</a>{% endif %}</div></details></div>
|
||||||
</header>
|
</header>
|
||||||
|
{% if auth.get('mode', 'none') == 'none' %}
|
||||||
|
<div class="authentication-disabled-banner" role="status">
|
||||||
|
<strong>⚠ {{ t('auth.disabled_banner_title') }}</strong>
|
||||||
|
<span>{{ t('auth.disabled_banner_text') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<dialog id="about-dialog" class="about-dialog">
|
<dialog id="about-dialog" class="about-dialog">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<div class="dialog-heading">
|
<div class="dialog-heading">
|
||||||
@@ -99,7 +105,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<main class="app-content">{% block content %}{% endblock %}</main>
|
<main class="app-content">{% block content %}{% endblock %}</main>
|
||||||
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js"></script><script src="/static/js/asset-columns.js"></script><script src="/static/js/asset-duplicates.js"></script><script src="/static/js/toasts.js"></script><script>
|
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js?v={{ app_version }}"></script><script src="/static/js/asset-columns.js?v={{ app_version }}"></script><script src="/static/js/asset-duplicates.js?v={{ app_version }}"></script><script src="/static/js/toasts.js"></script><script>
|
||||||
(() => {
|
(() => {
|
||||||
document.addEventListener('keydown', event => {
|
document.addEventListener('keydown', event => {
|
||||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
|
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
|
||||||
|
|||||||
@@ -28,16 +28,67 @@
|
|||||||
<p class="muted">{{ t('mesh.asset_matching_help') }}</p>
|
<p class="muted">{{ t('mesh.asset_matching_help') }}</p>
|
||||||
<div class="checkbox-row">
|
<div class="checkbox-row">
|
||||||
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="node_id" {% if 'node_id' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_node_id') }}</label>
|
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="node_id" {% if 'node_id' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_node_id') }}</label>
|
||||||
|
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="mac_address" {% if 'mac_address' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_mac') }}</label>
|
||||||
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="serial_number" {% if 'serial_number' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_serial') }}</label>
|
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="serial_number" {% if 'serial_number' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_serial') }}</label>
|
||||||
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="serial_match_manufacturer" {% if config.meshcentral.get('serial_match_manufacturer', false) %}checked{% endif %}> {{ t('mesh.match_serial_manufacturer') }}</label>
|
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="serial_match_manufacturer" {% if config.meshcentral.get('serial_match_manufacturer', false) %}checked{% endif %}> {{ t('mesh.match_serial_manufacturer') }}</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ t('mesh.match_serial_manufacturer_help') }}</p>
|
<p class="muted">{{ t('mesh.match_mac_help') }} {{ t('mesh.match_serial_manufacturer_help') }}</p>
|
||||||
|
<div class="form-grid mesh-identity-settings">
|
||||||
|
<label>{{ t('mesh.invalid_serial_values') }}
|
||||||
|
<textarea name="invalid_serial_values" rows="6" placeholder="Unknown N/A To be filled by O.E.M.">{{ (config.meshcentral.get('invalid_serial_values', []) or []) | join('\n') }}</textarea>
|
||||||
|
<small class="muted">{{ t('mesh.invalid_serial_values_help') }}</small>
|
||||||
|
</label>
|
||||||
|
<label>{{ t('mesh.manufacturer_invalid_serial_rules') }}
|
||||||
|
<textarea name="manufacturer_invalid_serial_rules" rows="6" placeholder="Synology | 123456789 Synology | Unknown">{% for rule in config.meshcentral.get('manufacturer_invalid_serial_rules', []) or [] %}{% for value in rule.get('values', []) or [] %}{{ rule.get('manufacturer', '') }} | {{ value }}{{ '\n' }}{% endfor %}{% endfor %}</textarea>
|
||||||
|
<small class="muted">{{ t('mesh.manufacturer_invalid_serial_rules_help') }}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="notice notice-info">{{ t('mesh.node_id_identity_help') }}</div>
|
||||||
<button class="button button-save">💾 {{ t('mesh.save_connection') }}</button>
|
<button class="button button-save">💾 {{ t('mesh.save_connection') }}</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>2. {{ t('mesh.field_mappings') }}</h2>
|
<h2>2. {{ t('settings.mesh_mapping') }}</h2>
|
||||||
|
{% set mesh = config.meshcentral %}
|
||||||
|
{% set category_mapping = mesh.get('category_mapping', {}) %}
|
||||||
|
<p class="muted">{{ t('settings.mesh_mapping_help') }}</p>
|
||||||
|
<form method="post" action="/sync/meshcentral/categories/save" class="compact-form">
|
||||||
|
<label>{{ t('settings.fallback_category') }}
|
||||||
|
<select name="mesh_fallback_category_id" required>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if category.id == (mesh.get('fallback_category_id', 1)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="form-grid">
|
||||||
|
{% 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 %}
|
||||||
|
<label>{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})
|
||||||
|
<select name="mesh_category_{{ type_id }}">
|
||||||
|
<option value="">{{ t('settings.use_fallback') }}</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if category_mapping.get(type_id) is not none and category.id == (category_mapping.get(type_id)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="button button-save">💾 {{ t('settings.save_mesh_mapping') }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>3. {{ t('mesh.field_mappings') }}</h2>
|
||||||
<p class="muted">{{ t('mesh.mappings_help') }} {{ t('mesh.mappings_examples') }}: <code>node.osdesc</code>, <code>sys.hardware.windows.cpu[].Name</code>, <code>node.name|name</code>.</p>
|
<p class="muted">{{ t('mesh.mappings_help') }} {{ t('mesh.mappings_examples') }}: <code>node.osdesc</code>, <code>sys.hardware.windows.cpu[].Name</code>, <code>node.name|name</code>.</p>
|
||||||
<form method="post" action="/sync/meshcentral/mappings/save">
|
<form method="post" action="/sync/meshcentral/mappings/save">
|
||||||
<div class="table-scroll management-table-scroll"><table class="data-table mesh-mapping-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('mesh.priority') }}</th><th>{{ t('mesh.target_field') }}</th><th>Source Path</th><th>{{ t('mesh.transformation') }}</th><th>{{ t('mesh.multi_values') }}</th><th>{{ t('mesh.separator') }}</th><th>{{ t('mesh.update_rule') }}</th><th>{{ t('common.description') }}</th><th></th></tr></thead><tbody>
|
<div class="table-scroll management-table-scroll"><table class="data-table mesh-mapping-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('mesh.priority') }}</th><th>{{ t('mesh.target_field') }}</th><th>Source Path</th><th>{{ t('mesh.transformation') }}</th><th>{{ t('mesh.multi_values') }}</th><th>{{ t('mesh.separator') }}</th><th>{{ t('mesh.update_rule') }}</th><th>{{ t('common.description') }}</th><th></th></tr></thead><tbody>
|
||||||
@@ -61,7 +112,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>3. {{ t('mesh.new_mapping') }}</h2>
|
<h2>4. {{ t('mesh.new_mapping') }}</h2>
|
||||||
<p class="muted">{{ t('mesh.new_mapping_help') }}</p>
|
<p class="muted">{{ t('mesh.new_mapping_help') }}</p>
|
||||||
<form method="post" action="/sync/meshcentral/mappings/new" class="compact-form">
|
<form method="post" action="/sync/meshcentral/mappings/new" class="compact-form">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
|||||||
@@ -147,41 +147,6 @@
|
|||||||
.inventory-tab { color: #000; }">{{ settings.general.get('custom_css', '') }}</textarea>
|
.inventory-tab { color: #000; }">{{ settings.general.get('custom_css', '') }}</textarea>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
<section class="settings-section" data-settings-section="meshcentral">
|
|
||||||
<h2>{{ t('settings.mesh_mapping') }}</h2>
|
|
||||||
{% set mesh = settings.meshcentral %}
|
|
||||||
{% set mapping = mesh.get('category_mapping', {}) %}
|
|
||||||
<p class="muted">{{ t('settings.mesh_mapping_help') }}</p>
|
|
||||||
<label>{{ t('settings.fallback_category') }}
|
|
||||||
<select name="mesh_fallback_category_id" required>
|
|
||||||
{% for category in categories %}
|
|
||||||
<option value="{{ category.id }}" {% if category.id == (mesh.get('fallback_category_id', 1)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div class="form-grid">
|
|
||||||
{% 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 %}
|
|
||||||
<label>{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})
|
|
||||||
<select name="mesh_category_{{ type_id }}">
|
|
||||||
<option value="">{{ t('settings.use_fallback') }}</option>
|
|
||||||
{% for category in categories %}
|
|
||||||
<option value="{{ category.id }}" {% if mapping.get(type_id) is not none and category.id == (mapping.get(type_id)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="settings-section" data-settings-section="authentication">
|
<section class="settings-section" data-settings-section="authentication">
|
||||||
<h2>{{ t('settings.authentication') }}</h2>
|
<h2>{{ t('settings.authentication') }}</h2>
|
||||||
<p class="muted">{{ t('settings.auth_help') }}</p>
|
<p class="muted">{{ t('settings.auth_help') }}</p>
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = "0.5.5.42"
|
APP_VERSION = "0.5.5.52"
|
||||||
__version__ = APP_VERSION
|
__version__ = APP_VERSION
|
||||||
|
|||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
CONFIG_PATH="${APP_CONFIG:-/app/config/config.json}"
|
||||||
|
APPINFO_PATH="${APPINFO_PATH:-/app/config/APPINFO.json}"
|
||||||
|
DEFAULT_APPINFO="/app/default-config/APPINFO.json"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$CONFIG_PATH")" "$(dirname "$APPINFO_PATH")"
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG_PATH" ]; then
|
||||||
|
echo "Initializing AssetManager configuration: $CONFIG_PATH"
|
||||||
|
python - "$CONFIG_PATH" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from app.config import DEFAULT_CONFIG
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("x", encoding="utf-8") as handle:
|
||||||
|
json.dump(DEFAULT_CONFIG, handle, ensure_ascii=False, indent=2)
|
||||||
|
handle.write("\n")
|
||||||
|
PY
|
||||||
|
else
|
||||||
|
echo "Keeping existing AssetManager configuration: $CONFIG_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$APPINFO_PATH" ]; then
|
||||||
|
echo "Initializing AssetManager APPINFO: $APPINFO_PATH"
|
||||||
|
cp "$DEFAULT_APPINFO" "$APPINFO_PATH"
|
||||||
|
else
|
||||||
|
echo "Keeping existing AssetManager APPINFO: $APPINFO_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# AssetManager installation guide
|
||||||
|
|
||||||
|
This guide describes a Docker image based installation and the first-start behavior of AssetManager.
|
||||||
|
|
||||||
|
## 1. Prepare the installation directory
|
||||||
|
|
||||||
|
Create a dedicated directory and the persistent data directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /srv/docker/assetmanager
|
||||||
|
cd /srv/docker/assetmanager
|
||||||
|
mkdir -p data/config data/uploads data/logs data/backups data/scripts data/postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a `.env` file. Use strong, unique values for all secrets:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ASSETMANAGER_VERSION=0.5.5.47
|
||||||
|
APP_PORT=8088
|
||||||
|
POSTGRES_DB=assetmanager
|
||||||
|
POSTGRES_USER=assetmanager
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
SESSION_SECRET=CHANGE_ME_LONG_RANDOM
|
||||||
|
LOCAL_ADMIN_USERNAME=emergency-admin
|
||||||
|
LOCAL_ADMIN_PASSWORD=CHANGE_ME_MIN_12_CHARS
|
||||||
|
MESHCENTRAL_PASSWORD=
|
||||||
|
LDAP_BIND_PASSWORD=
|
||||||
|
BACKUP_INTERVAL_HOURS=8
|
||||||
|
BACKUP_RETENTION_DAYS=3
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `.env` private. Do not commit it to a public repository.
|
||||||
|
|
||||||
|
## 2. Docker Compose file for a registry image
|
||||||
|
|
||||||
|
Use the published image instead of building the application locally:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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:
|
||||||
|
image: git.jusaro.de/roland/assetmanager:${ASSETMANAGER_VERSION:-0.5.5.47}
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
If the registry is private, sign in once on the Docker host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login git.jusaro.de
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. First start
|
||||||
|
|
||||||
|
Pull and start the containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first start, the container creates the following files only when they do not already exist:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/config/config.json
|
||||||
|
data/config/APPINFO.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing files are preserved during subsequent container starts and updates.
|
||||||
|
|
||||||
|
## 4. Important: authentication is initially disabled
|
||||||
|
|
||||||
|
A fresh default configuration starts with authentication disabled. This is intentional so the initial configuration can be completed, but the installation must not be exposed to an untrusted network in this state.
|
||||||
|
|
||||||
|
The web interface displays a warning banner while authentication is disabled. To enable local authentication:
|
||||||
|
|
||||||
|
1. Open `data/config/config.json`.
|
||||||
|
2. Locate the `authentication` section.
|
||||||
|
3. Set `mode` to `local`.
|
||||||
|
4. Restart only the application container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Sign in using `LOCAL_ADMIN_USERNAME` and `LOCAL_ADMIN_PASSWORD` from `.env`.
|
||||||
|
|
||||||
|
The local administrator is the protected emergency account and should remain available even when LDAP/Active Directory is configured later.
|
||||||
|
|
||||||
|
## 5. Configure the callback base address
|
||||||
|
|
||||||
|
Remote jobs call back to AssetManager after execution. In **Software settings**, configure the callback base address when clients must reach AssetManager through DNS, a reverse proxy, VPN, or the Internet.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://assetmanager.example.org
|
||||||
|
https://assetmanager.example.org:8443
|
||||||
|
http://192.168.1.40:8088
|
||||||
|
```
|
||||||
|
|
||||||
|
For Internet-facing use, HTTPS through a properly configured reverse proxy is strongly recommended. The callback endpoint must be reachable from the managed client.
|
||||||
|
|
||||||
|
If no callback base address is configured, AssetManager derives the address from the current request.
|
||||||
|
|
||||||
|
## 6. Verify the installation
|
||||||
|
|
||||||
|
Check the application status and logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=100 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
After signing in, verify at least:
|
||||||
|
|
||||||
|
- application version
|
||||||
|
- database connection
|
||||||
|
- local emergency administrator login
|
||||||
|
- MeshCentral connectivity, if used
|
||||||
|
- one test inventory or remote job
|
||||||
|
- successful callback processing
|
||||||
|
|
||||||
|
## 7. Persistent data and backups
|
||||||
|
|
||||||
|
The following paths contain persistent runtime data and must survive image replacement:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/config/
|
||||||
|
data/postgres/
|
||||||
|
data/uploads/
|
||||||
|
data/logs/
|
||||||
|
data/backups/
|
||||||
|
data/scripts/
|
||||||
|
.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Back up `.env` and the complete `data/` directory before significant updates.
|
||||||
|
|
||||||
|
## 8. Updating an image-based installation
|
||||||
|
|
||||||
|
Set the desired fixed image version in `.env`, for example:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ASSETMANAGER_VERSION=0.5.5.47
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs --tail=100 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Using a fixed version tag is recommended for controlled production updates.
|
||||||
|
|
||||||
|
## 9. Log files
|
||||||
|
|
||||||
|
Application logs are stored below `data/logs/`. The main files include `errors.log` and, when LDAP is used, `ldap.log`.
|
||||||
|
|
||||||
|
Starting with version 0.5.5.45, these file loggers detect if their active log file was deleted or replaced externally. On the next log event, the file is reopened and created again automatically; an application container restart is no longer required.
|
||||||
|
|
||||||
|
For normal log maintenance, rotation/truncation mechanisms are preferable to manually deleting active log files. Docker container output remains available through:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=100 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Troubleshooting
|
||||||
|
|
||||||
|
### The web interface logs in as guest
|
||||||
|
|
||||||
|
The default configuration still has authentication disabled. Set `authentication.mode` to `local` in `data/config/config.json` and restart the app container.
|
||||||
|
|
||||||
|
### A remote job remains at “callback pending”
|
||||||
|
|
||||||
|
Check:
|
||||||
|
|
||||||
|
- whether the callback base address is reachable from the client
|
||||||
|
- reverse proxy/firewall rules
|
||||||
|
- application logs under `data/logs/`
|
||||||
|
- `docker compose logs --tail=100 -f`
|
||||||
|
|
||||||
|
### A configuration file is missing
|
||||||
|
|
||||||
|
If `config.json` or `APPINFO.json` is absent, restart the application container. The entrypoint creates a missing default file without overwriting an existing one.
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# Proposal: Client communication and future software deployment
|
|
||||||
|
|
||||||
## Target architecture
|
|
||||||
|
|
||||||
A small Windows client ("AssetManager Agent") runs on every managed computer. A Netlogon or GPO startup script only installs or updates this agent. Inventory and software jobs are then controlled through a secured HTTPS API.
|
|
||||||
|
|
||||||
## Why not execute every command directly in a Netlogon script?
|
|
||||||
|
|
||||||
A startup script is useful for bootstrap and repair, but unsuitable for permanent job control:
|
|
||||||
|
|
||||||
- User and computer startup is delayed.
|
|
||||||
- Results and retries are difficult to track.
|
|
||||||
- A network share or domain controller may not yet be reachable during startup.
|
|
||||||
- Software installations require status, timeout, exit-code, and restart handling.
|
|
||||||
- Passwords or global API keys must not be stored in the script.
|
|
||||||
|
|
||||||
## Recommended components
|
|
||||||
|
|
||||||
### 1. Bootstrap through GPO or Netlogon
|
|
||||||
|
|
||||||
The startup script:
|
|
||||||
|
|
||||||
1. creates `C:\ProgramData\AssetManager\agent`,
|
|
||||||
2. downloads a signed agent version from an internal HTTPS address,
|
|
||||||
3. installs a Windows service,
|
|
||||||
4. stores only the server URL and a one-time registration identifier,
|
|
||||||
5. starts the service.
|
|
||||||
|
|
||||||
### 2. Device identity
|
|
||||||
|
|
||||||
During initial registration, the agent reports:
|
|
||||||
|
|
||||||
- computer name,
|
|
||||||
- AD domain,
|
|
||||||
- BIOS serial number,
|
|
||||||
- Windows MachineGuid,
|
|
||||||
- optionally, the MeshCentral node ID.
|
|
||||||
|
|
||||||
The server then creates a device-specific token. Windows DPAPI protects the token locally. Do not use a shared API key for all clients.
|
|
||||||
|
|
||||||
### 3. Agent polling
|
|
||||||
|
|
||||||
For example, the client requests a job every five minutes over HTTPS:
|
|
||||||
|
|
||||||
`GET /api/agent/v1/jobs/next`
|
|
||||||
|
|
||||||
Response when no job is available:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"job": null, "next_poll_seconds": 300}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response with a job:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"job": {
|
|
||||||
"id": 4711,
|
|
||||||
"type": "software_inventory",
|
|
||||||
"expires_at": "2026-07-22T23:00:00Z",
|
|
||||||
"payload": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The client first acknowledges receipt and later reports the result, exit code, log excerpt, and timestamps.
|
|
||||||
|
|
||||||
### 4. Software inventory
|
|
||||||
|
|
||||||
The first agent function should:
|
|
||||||
|
|
||||||
- read installed MSI applications from the 32-bit and 64-bit registry,
|
|
||||||
- optionally include AppX packages,
|
|
||||||
- collect name, version, publisher, installation date, and uninstall string,
|
|
||||||
- send the compressed result to AssetManager,
|
|
||||||
- store inventory runs per asset on the server.
|
|
||||||
|
|
||||||
Do not use `Win32_Product`, because that WMI class may trigger MSI repairs and can be very slow.
|
|
||||||
|
|
||||||
### 5. Software catalog for future deployment
|
|
||||||
|
|
||||||
Server-side tables:
|
|
||||||
|
|
||||||
- `software_packages`
|
|
||||||
- `software_package_versions`
|
|
||||||
- `software_jobs`
|
|
||||||
- `software_job_results`
|
|
||||||
- `agent_devices`
|
|
||||||
- `agent_tokens`
|
|
||||||
- `agent_inventory_runs`
|
|
||||||
|
|
||||||
A software package contains:
|
|
||||||
|
|
||||||
- name and version,
|
|
||||||
- installation source,
|
|
||||||
- SHA-256 hash,
|
|
||||||
- installation command,
|
|
||||||
- uninstall command,
|
|
||||||
- detection rule,
|
|
||||||
- accepted exit codes,
|
|
||||||
- restart behavior,
|
|
||||||
- timeout,
|
|
||||||
- target architecture.
|
|
||||||
|
|
||||||
### 6. Installation source
|
|
||||||
|
|
||||||
Packages may initially remain on a file server, for example:
|
|
||||||
|
|
||||||
`\\fileserver\software\packages\ExampleApp\1.0\`
|
|
||||||
|
|
||||||
A job must not contain arbitrary PowerShell code. It refers to an approved package definition. The agent verifies that:
|
|
||||||
|
|
||||||
1. the package is approved on the server,
|
|
||||||
2. the source is below an allowed UNC base path,
|
|
||||||
3. the file hash matches,
|
|
||||||
4. the installation command matches the package definition.
|
|
||||||
|
|
||||||
An internal HTTPS package download is more robust in the long term because it does not require machine access to a share and is easier to audit.
|
|
||||||
|
|
||||||
### 7. Security rules
|
|
||||||
|
|
||||||
- use HTTPS only, with an internally trusted certificate,
|
|
||||||
- use a separate revocable token per device,
|
|
||||||
- sign jobs on the server or deliver them through an authenticated TLS connection,
|
|
||||||
- do not provide unrestricted shell or PowerShell input in the web interface,
|
|
||||||
- execute commands only through defined job types,
|
|
||||||
- verify package files with SHA-256,
|
|
||||||
- keep a complete audit log,
|
|
||||||
- use roles and permissions for approval and execution,
|
|
||||||
- enforce maximum runtime and retry limits,
|
|
||||||
- run the agent as SYSTEM only when a specific job requires it.
|
|
||||||
|
|
||||||
## Suggested implementation phases
|
|
||||||
|
|
||||||
### Phase 1 – Inventory
|
|
||||||
|
|
||||||
- agent registration
|
|
||||||
- heartbeat and last-contact tracking
|
|
||||||
- operating-system and software inventory
|
|
||||||
- display on the asset detail page
|
|
||||||
- manual inventory job
|
|
||||||
|
|
||||||
### Phase 2 – Job model
|
|
||||||
|
|
||||||
- generic job queue
|
|
||||||
- statuses: pending, claimed, running, success, failed, expired
|
|
||||||
- exit codes, logs, timeout, and retry
|
|
||||||
- live status in AssetManager
|
|
||||||
|
|
||||||
### Phase 3 – Software catalog
|
|
||||||
|
|
||||||
- package definitions
|
|
||||||
- detection rules
|
|
||||||
- installation and uninstallation
|
|
||||||
- single devices and device groups
|
|
||||||
- maintenance windows
|
|
||||||
|
|
||||||
### Phase 4 – Rollout safety
|
|
||||||
|
|
||||||
- pilot groups
|
|
||||||
- approval workflow
|
|
||||||
- staged deployment
|
|
||||||
- abort on excessive failure rate
|
|
||||||
- restart coordination
|
|
||||||
|
|
||||||
## Recommendation for the next development step
|
|
||||||
|
|
||||||
Implement Phase 1 first. The agent should not yet execute arbitrary commands. Once registration, heartbeat, and software inventory are stable, the same secured communication can be used for the job queue and later software deployment.
|
|
||||||
@@ -4,12 +4,29 @@
|
|||||||
|
|
||||||
Release notes are stored outside the project root to keep the repository overview compact.
|
Release notes are stored outside the project root to keep the repository overview compact.
|
||||||
|
|
||||||
The current release is **0.5.5.30**.
|
The current release is **0.5.5.47**.
|
||||||
|
|
||||||
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [0.5.5.47](UPDATE-0.5.5.47.md)
|
||||||
|
- [0.5.5.46](UPDATE-0.5.5.46.md)
|
||||||
|
- [0.5.5.45](UPDATE-0.5.5.45.md)
|
||||||
|
- [0.5.5.44](UPDATE-0.5.5.44.md)
|
||||||
|
- [0.5.5.43](UPDATE-0.5.5.43.md)
|
||||||
|
- [0.5.5.42](UPDATE-0.5.5.42.md)
|
||||||
|
- [0.5.5.41](UPDATE-0.5.5.41.md)
|
||||||
|
- [0.5.5.40](UPDATE-0.5.5.40.md)
|
||||||
|
- [0.5.5.39](UPDATE-0.5.5.39.md)
|
||||||
|
- [0.5.5.38](UPDATE-0.5.5.38.md)
|
||||||
|
- [0.5.5.37](UPDATE-0.5.5.37.md)
|
||||||
|
- [0.5.5.36](UPDATE-0.5.5.36.md)
|
||||||
|
- [0.5.5.35](UPDATE-0.5.5.35.md)
|
||||||
|
- [0.5.5.34](UPDATE-0.5.5.34.md)
|
||||||
|
- [0.5.5.33](UPDATE-0.5.5.33.md)
|
||||||
|
- [0.5.5.32](UPDATE-0.5.5.32.md)
|
||||||
|
- [0.5.5.31](UPDATE-0.5.5.31.md)
|
||||||
- [0.5.5.30](UPDATE-0.5.5.30.md)
|
- [0.5.5.30](UPDATE-0.5.5.30.md)
|
||||||
- [0.5.5.29](UPDATE-0.5.5.29.md)
|
- [0.5.5.29](UPDATE-0.5.5.29.md)
|
||||||
- [0.5.5.28](UPDATE-0.5.5.28.md)
|
- [0.5.5.28](UPDATE-0.5.5.28.md)
|
||||||
@@ -165,3 +182,6 @@ See [UPDATE-NOTE.md](UPDATE-NOTE.md).
|
|||||||
- [0.5.5.41](UPDATE-0.5.5.41.md) – Configurable MeshCentral matching and synchronization dry run.
|
- [0.5.5.41](UPDATE-0.5.5.41.md) – Configurable MeshCentral matching and synchronization dry run.
|
||||||
|
|
||||||
- [0.5.5.42](UPDATE-0.5.5.42.md) — public repository documentation cleanup
|
- [0.5.5.42](UPDATE-0.5.5.42.md) — public repository documentation cleanup
|
||||||
|
|
||||||
|
|
||||||
|
- [0.5.5.50](UPDATE-0.5.5.50.md) - safer MeshCentral identity matching for containers and virtual machines
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Version 0.5.5.43
|
# AssetManager 0.5.5.43
|
||||||
|
|
||||||
- Added drag handles to resize every standard table column.
|
## Changes
|
||||||
- Stored column widths independently for each table and column.
|
|
||||||
- Kept the asset list's existing horizontal-scroll width calculation in sync.
|
- Added first-start initialization for persistent `config.json` and `APPINFO.json` when using the Docker image.
|
||||||
- Added keyboard resizing with the left and right arrow keys.
|
- Existing persistent configuration files are never overwritten by the container entrypoint.
|
||||||
- Added double-click on a separator to reset a column to its default width.
|
- Added an image ownership cleanup for the bundled MeshCentral Node.js dependencies to avoid invalid UID/GID extraction errors on other Docker hosts.
|
||||||
|
- Prepared the Docker image for registry-based installations and updates with persistent configuration volumes.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# AssetManager 0.5.5.44
|
||||||
|
|
||||||
|
- Sanitize embedded NUL characters in software inventory callback data before PostgreSQL persistence.
|
||||||
|
- Add a visible first-start warning banner while authentication is disabled.
|
||||||
|
- Document the first-start authentication procedure for image-based installations.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# AssetManager 0.5.5.45
|
||||||
|
|
||||||
|
- Added a detailed Docker image installation and first-start guide.
|
||||||
|
- Documented automatic first-start creation of `config.json` and `APPINFO.json`.
|
||||||
|
- Documented the initial authentication-disabled state, warning banner, local emergency administrator activation, callback base address, image updates, and troubleshooting.
|
||||||
|
- Changed application and LDAP file logging to watched file handlers so externally deleted or replaced active log files are recreated automatically on the next log event without restarting the application container.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# AssetManager 0.5.5.46
|
||||||
|
|
||||||
|
## MeshCentral device identity and placeholder serial numbers
|
||||||
|
|
||||||
|
- Added configurable global serial-number placeholder values.
|
||||||
|
- Added manufacturer-specific placeholder rules using `Manufacturer | Serial number`.
|
||||||
|
- Placeholder serial numbers are not used for asset matching.
|
||||||
|
- Placeholder serial numbers do not overwrite an existing asset serial number during synchronization.
|
||||||
|
- The MeshCentral node ID remains the stable unique device identity and is used before serial-number matching by default.
|
||||||
|
- The original MeshCentral source value remains available in stored source data when source-data storage is enabled.
|
||||||
|
- Dry-run and live synchronization use the same placeholder detection logic.
|
||||||
|
|
||||||
|
The default configuration includes common textual placeholders and a Synology-specific rule for `123456789` and `Unknown`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# AssetManager 0.5.5.47
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- MeshCentral network mapping now selects a plausible primary interface instead of relying on the first network record.
|
||||||
|
- Common Ethernet, Linux predictable-interface, bridge, bond, wireless and virtualization interface names are ranked case-insensitively.
|
||||||
|
- Loopback, container and tunnel interfaces are de-prioritized.
|
||||||
|
- Invalid all-zero or malformed MAC addresses are ignored.
|
||||||
|
- IP and MAC mapping prefer the same active interface with usable IPv4 data whenever possible.
|
||||||
|
- The asset list now visibly shows active category and duplicate-selection filters with removable filter chips.
|
||||||
|
- Duplicate-filter record counts now reflect the duplicate result set, including `0 of 0` when no duplicates are found.
|
||||||
|
- Duplicate-search result text is fully localized.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# AssetManager 0.5.5.48
|
||||||
|
|
||||||
|
## Restored table state and row-number resizing
|
||||||
|
|
||||||
|
- Restores persistent client-side table sorting across page reloads and partial table reloads.
|
||||||
|
- Restores the visual row-number column on data tables.
|
||||||
|
- Restores resizing of the row-number column and persists its width per table in browser local storage.
|
||||||
|
- Keeps the current 0.5.5.47 duplicate-filter, network-interface, authentication-banner, callback, logging, and MeshCentral identity changes intact.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# AssetManager 0.5.5.49
|
||||||
|
|
||||||
|
## Persistent resizing for all table columns
|
||||||
|
|
||||||
|
- Extends table resizing from the visual row-number column to every visible data-table column.
|
||||||
|
- Stores widths per table and column in browser local storage.
|
||||||
|
- Restores saved widths after page reloads and partial/AJAX table reloads.
|
||||||
|
- Keeps persistent sorting, filters, row numbering and newer 0.5.5.44-0.5.5.48 fixes intact.
|
||||||
|
- Adds translated resize hints for German and English.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# AssetManager 0.5.5.50
|
||||||
|
|
||||||
|
## Safer MeshCentral identity matching for containers and virtual machines
|
||||||
|
|
||||||
|
- Keeps MeshCentral node ID as the primary device identity.
|
||||||
|
- Adds optional exact MAC-address matching between node ID and serial-number matching.
|
||||||
|
- Detects serial-number identities that occur more than once in the current MeshCentral inventory and excludes them from serial-number matching.
|
||||||
|
- Prevents a serial-number match when both the incoming device and the existing asset have valid but different MAC addresses.
|
||||||
|
- This avoids false matches for LXC/VM guests that inherit the same host DMI/mainboard serial number.
|
||||||
|
- Existing configured dummy-serial rules remain unchanged.
|
||||||
|
- Dry-run and real synchronization use the same matching logic.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# AssetManager 0.5.5.51
|
||||||
|
|
||||||
|
## MeshCentral category mapping UI
|
||||||
|
|
||||||
|
- Moves the existing MeshCentral device-type to asset-category mapping from the general settings template to the MeshCentral synchronization page.
|
||||||
|
- Adds a dedicated save action for the fallback category and MeshCentral types 1 through 8.
|
||||||
|
- Keeps the existing configuration keys (`meshcentral.fallback_category_id` and `meshcentral.category_mapping`) and synchronization behavior unchanged.
|
||||||
|
- Validates submitted category IDs server-side.
|
||||||
|
- Removes the unreachable hidden MeshCentral category-mapping block from the general settings page.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# AssetManager 0.5.5.52
|
||||||
|
|
||||||
|
## MeshCentral category page fix
|
||||||
|
|
||||||
|
- Fixes a crash when opening the MeshCentral synchronization page after the category-mapping UI was moved there.
|
||||||
|
- Uses the existing category name ordering because the `Category` model has no `sort_order` column.
|
||||||
|
- Keeps the category-mapping storage and synchronization logic unchanged.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/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 contains --powershell" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "files auf dem Host are korrekt."
|
|
||||||
Reference in New Issue
Block a user