fmeshsync fix with synology,vm's and lxc

This commit is contained in:
root
2026-08-08 19:44:27 +02:00
parent 2786f12ab6
commit 5e32be7d0c
41 changed files with 1335 additions and 1674 deletions
+8 -3
View File
@@ -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"]
-12
View File
@@ -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."
+18 -1
View File
@@ -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/`:
+1 -1
View File
@@ -1 +1 @@
0.5.5.42 0.5.5.52
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+212 -13
View File
@@ -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:
+110
View File
@@ -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;
}
+48 -5
View File
@@ -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') {
+42 -1
View File
@@ -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');
})(); })();
+8 -4
View File
@@ -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
View File
@@ -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?.();
} }
}); });
})(); })();
+19 -1
View File
@@ -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>
+8 -2
View File
@@ -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;
+54 -3
View File
@@ -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&#10;N/A&#10;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&#10;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">
-35
View File
@@ -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
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.42" APP_VERSION = "0.5.5.52"
__version__ = APP_VERSION __version__ = APP_VERSION
View File
View File
View File
View File
View File
View File
View File
+35
View File
@@ -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 "$@"
+233
View File
@@ -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.
+21 -1
View File
@@ -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
+7 -6
View File
@@ -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.
+5
View File
@@ -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.
+6
View File
@@ -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.
+13
View File
@@ -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`.
+12
View File
@@ -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.
+8
View File
@@ -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.
+9
View File
@@ -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.
+11
View File
@@ -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.
+9
View File
@@ -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.
+7
View File
@@ -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.
-12
View File
@@ -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."