From daeb08c054997e93e451436b23a23e0a32db98cb Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 8 Aug 2026 10:16:36 +0000 Subject: [PATCH] Privacy Policy DSGVO with sheets for categories, export-, check- and first erase-functions --- README.md | 2 +- VERSION | 2 +- app/config.py | 1 + app/i18n.py | 8 +- app/main.py | 26 +- app/meshcentral.py | 86 ++++-- app/static/css/app.css | 107 ------- app/static/js/asset-columns.js | 6 - app/static/js/browser-state.js | 43 +-- app/static/js/table-tools.js | 362 ++---------------------- app/templates/base.html | 2 +- app/templates/meshcentral_sync.html | 27 +- app/version.py | 3 +- docs/version-history/README.md | 2 +- docs/version-history/UPDATE-0.5.5.41.md | 11 +- 15 files changed, 162 insertions(+), 526 deletions(-) diff --git a/README.md b/README.md index d43ac07..7e01432 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.43 +# AssetManager 0.5.5.41 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes. diff --git a/VERSION b/VERSION index 0fd87b1..3eb770a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.43 +0.5.5.41 diff --git a/app/config.py b/app/config.py index 9861a8c..f07019c 100644 --- a/app/config.py +++ b/app/config.py @@ -60,6 +60,7 @@ DEFAULT_CONFIG: dict[str, Any] = { "presence_enabled": True, "presence_interval_seconds": 30, "match_order": ["node_id", "serial_number"], + "serial_match_manufacturer": False, "create_missing_assets": True, "mark_missing_devices": True, "field_rules": {}, diff --git a/app/i18n.py b/app/i18n.py index dbe3c84..b8c5334 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -72,6 +72,12 @@ BASE_TRANSLATIONS = { "mesh.live_log": ("Live log", "Live-Protokoll"), "mesh.ready": ("ready", "bereit"), "mesh.ready_message": ("Ready for synchronization.", "Bereit für die Synchronisierung."), "mesh.configuration": ("Configuration", "Konfiguration"), "mesh.file": ("File", "Datei"), "mesh.user": ("User", "Benutzer"), "mesh.verify_tls": ("Verify TLS", "TLS prüfen"), "mesh.asset_matching": ("Asset matching", "Asset-Zuordnung"), +"mesh.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_serial": ("Serial number", "Seriennummer"), +"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.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.type": ("Type", "Typ"), "mesh.recent_runs": ("Recent runs", "Letzte Läufe"), "mesh.start_time": ("Start", "Start"), "mesh.found": ("Found", "Gefunden"), "mesh.created": ("Created", "Neu"), "mesh.changed": ("Changed", "Geändert"), @@ -140,8 +146,6 @@ BASE_TRANSLATIONS = { "lists.filters_active": ("{count} filters active", "{count} Filter aktiv"), "lists.filter_status": ("Filtered: {visible} of {total} records", "Gefiltert: {visible} von {total} Datensätzen"), "lists.clear_filters": ("Clear filters", "Filter zurücksetzen"), -"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"), "appearance.colors_title": ("System colors", "Systemfarben"), "appearance.colors_help": ("Configure the central colors used throughout the application.", "Konfigurieren Sie die zentral im gesamten System verwendeten Farben."), "appearance.primary_color": ("Primary color", "Primärfarbe"), diff --git a/app/main.py b/app/main.py index 3b13f6f..d32b269 100644 --- a/app/main.py +++ b/app/main.py @@ -1280,14 +1280,14 @@ def _job_log(job_id: str, level: str, message: str) -> None: except OSError: pass -def _run_sync_job(job_id: str) -> None: +def _run_sync_job(job_id: str, dry_run: bool = False) -> None: db = SessionLocal() try: - result = synchronize(db, lambda level, message: _job_log(job_id, level, message)) + result = synchronize(db, lambda level, message: _job_log(job_id, level, message), dry_run=dry_run) with SYNC_JOBS_LOCK: job = SYNC_JOBS[job_id] job["status"] = result.run.status - job["run_id"] = result.run.id + job["run_id"] = result.run.id if not dry_run else None job["finished"] = True except Exception as exc: _job_log(job_id, "error", f"Unerwarteter Fehler: {exc}") @@ -8064,6 +8064,22 @@ def meshcentral_sync_start(request: Request): return {"job_id": job_id} +@app.post("/sync/meshcentral/dry-run") +def meshcentral_sync_dry_run(request: Request): + _require_admin(request) + with SYNC_JOBS_LOCK: + if any(not job.get("finished") for job in SYNC_JOBS.values()): + return JSONResponse({"error": "Es läuft bereits eine Synchronisierung oder ein Dry-Run."}, status_code=409) + job_id = uuid.uuid4().hex + SYNC_JOBS[job_id] = { + "status": "dry-run", "finished": False, "run_id": None, + "logs": [], "created_at": datetime.now().isoformat(), "dry_run": True, + } + _job_log(job_id, "info", "Dry-Run-Auftrag angelegt. Es werden keine Änderungen gespeichert.") + threading.Thread(target=_run_sync_job, args=(job_id, True), daemon=True).start() + return {"job_id": job_id} + + @app.get("/sync/meshcentral/jobs/{job_id}") def meshcentral_sync_job(job_id: str, offset: int = 0): with SYNC_JOBS_LOCK: @@ -8180,8 +8196,10 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_ 'create_missing_assets': form.get('create_missing_assets') == 'on', 'mark_missing_devices': form.get('mark_missing_devices') == 'on', 'store_source_json': form.get('store_source_json') == 'on', + 'serial_match_manufacturer': form.get('serial_match_manufacturer') == 'on', }) - mesh['match_order'] = form.getlist('match_order') or ['node_id', 'serial_number'] + 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'] # Do not retain plaintext passwords from older versions. mesh.pop('password', None) mesh.pop('field_mappings', None) diff --git a/app/meshcentral.py b/app/meshcentral.py index 3186050..5399a03 100644 --- a/app/meshcentral.py +++ b/app/meshcentral.py @@ -2,6 +2,7 @@ import json import os import re import subprocess +import unicodedata from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -666,18 +667,49 @@ def map_device(raw: dict[str, Any], field_mappings: list[dict[str, Any]] | None mapped[target] = value return mapped -def _find_asset(db: Session, mapped: dict[str, Any], order: list[str]) -> tuple[Asset | None, str | None, str | None]: +def _normalize_manufacturer(value: Any) -> str: + """Normalize manufacturer names for safe serial-number matching.""" + if value in (None, ""): + return "" + text = unicodedata.normalize("NFKD", str(value)).casefold() + text = "".join(ch for ch in text if not unicodedata.combining(ch)) + tokens = re.findall(r"[a-z0-9]+", text) + suffixes = { + "inc", "incorporated", "corp", "corporation", "company", "co", + "ltd", "limited", "llc", "gmbh", "ag", "kg", "se", "plc", + } + while tokens and tokens[-1] in suffixes: + tokens.pop() + return "".join(tokens) + + +def _find_asset( + db: Session, + mapped: dict[str, Any], + order: list[str], + *, + serial_match_manufacturer: bool = False, +) -> tuple[Asset | None, str | None, str | None]: for method in order: if method == "node_id" and mapped.get("mesh_node_id"): matches = db.query(Asset).filter(Asset.mesh_node_id == mapped["mesh_node_id"]).all() elif method == "serial_number" and mapped.get("serial_number"): matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all() + if serial_match_manufacturer: + incoming_manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) + if not incoming_manufacturer: + return None, method, "Seriennummer stimmt möglicherweise überein, aber der Hersteller fehlt in den MeshCentral-Daten." + matches = [ + asset for asset in matches + if _normalize_manufacturer(asset.manufacturer) == incoming_manufacturer + ] else: continue if len(matches) == 1: return matches[0], method, None if len(matches) > 1: - return None, method, f"Mehrere Assets stimmen über {method} überein." + detail = "Seriennummer und Hersteller" if method == "serial_number" and serial_match_manufacturer else method + return None, method, f"Mehrere Assets stimmen über {detail} überein." return None, None, None @@ -858,13 +890,14 @@ def _inventory_segments(raw: dict[str, Any]) -> tuple[dict[str, Any], list[Any] software = [] return hardware, software -def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> SyncResult: +def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, dry_run: bool = False) -> SyncResult: log = log or (lambda level, message: None) cfg = load_config()["meshcentral"] - run = SyncRun(status="running") - db.add(run) - db.commit() - db.refresh(run) + run = SyncRun(status="running", devices_found=0, created_count=0, updated_count=0, conflict_count=0, error_count=0) + if not dry_run: + db.add(run) + db.commit() + db.refresh(run) now = datetime.utcnow() seen_node_ids: set[str] = set() mapping_rows = db.query(MeshFieldMapping).filter(MeshFieldMapping.enabled.is_(True)).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all() @@ -894,7 +927,10 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S for target in missing_columns: log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.") run.error_count += 1 - log("info", "Synchronisierung gestartet.") + 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"]))) + if cfg.get("serial_match_manufacturer", False): + log("info", "Seriennummern-Abgleich erfordert zusätzlich einen normalisierten Herstellervergleich.") devices = fetch_devices(log) run.devices_found = len(devices) log("info", f"Verarbeite {len(devices)} Gerät(e).") @@ -918,7 +954,12 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S log("error", "Gerät ohne Node-ID wurde übersprungen.") continue seen_node_ids.add(node_id) - asset, match_method, match_error = _find_asset(db, mapped, cfg.get("match_order", ["node_id", "serial_number"])) + asset, match_method, match_error = _find_asset( + db, + mapped, + cfg.get("match_order", ["node_id", "serial_number"]), + serial_match_manufacturer=bool(cfg.get("serial_match_manufacturer", False)), + ) if match_error: run.conflict_count += 1 log("warning", f"{mapped.get('name', node_id)}: {match_error}") @@ -940,10 +981,11 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S asset = Asset(category_id=category.id, name=mapped.get("name", node_id), mesh_node_id=node_id) db.add(asset) run.created_count += 1 - log("success", f"{mapped.get('name', node_id)}: neues Asset in Kategorie '{category.name}' (ID {category.id}) angelegt.") + log("success", f"{mapped.get('name', node_id)}: {'würde als neues Asset' if dry_run else 'neues Asset'} in Kategorie '{category.name}' (ID {category.id}) {'angelegt werden' if dry_run else 'angelegt'}." ) elif match_method == "serial_number" and not asset.mesh_node_id: asset.mesh_node_id = node_id - log("info", f"{asset.name}: über Seriennummer zugeordnet.") + match_note = "über Seriennummer + Hersteller" if cfg.get("serial_match_manufacturer", False) else "über Seriennummer" + log("info", f"{asset.name}: {match_note} zugeordnet{' (Dry-Run)' if dry_run else ''}.") conflicts: dict[str, Any] = {} history_changes: dict[str, dict[str, str]] = {} @@ -1034,7 +1076,7 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S asset.mesh_sync_message = "Über Seriennummer zugeordnet" if match_method == "serial_number" else "Synchronisiert" if changed: run.updated_count += 1 - log("info", f"{asset.name}: Daten aktualisiert.") + log("info", f"{asset.name}: Daten {'würden aktualisiert' if dry_run else 'aktualisiert'}." ) else: log("debug", f"{asset.name}: keine Änderungen.") except Exception as item_error: @@ -1052,17 +1094,25 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S run.status = "success" if run.error_count == 0 else "partial" run.finished_at = datetime.utcnow() - db.commit() - log("success", f"Synchronisierung beendet: {run.created_count} neu, {run.updated_count} geändert, {run.conflict_count} Konflikte, {run.error_count} Fehler.") + if dry_run: + db.rollback() + log("success", f"Dry-Run beendet: {run.devices_found} gefunden, {run.created_count} würden neu angelegt, {run.updated_count} würden geändert, {run.conflict_count} Konflikte, {run.error_count} Fehler. Es wurden keine Änderungen gespeichert.") + else: + db.commit() + log("success", f"Synchronisierung beendet: {run.created_count} neu, {run.updated_count} geändert, {run.conflict_count} Konflikte, {run.error_count} Fehler.") except Exception as error: db.rollback() - run = db.query(SyncRun).filter(SyncRun.id == run.id).first() + if not dry_run and run.id is not None: + stored_run = db.query(SyncRun).filter(SyncRun.id == run.id).first() + if stored_run is not None: + run = stored_run run.status = "error" - run.error_count += 1 + run.error_count = int(run.error_count or 0) + 1 run.message = str(error) run.finished_at = datetime.utcnow() - db.commit() - log("error", f"Synchronisierung abgebrochen: {error}") + if not dry_run and run.id is not None: + db.commit() + log("error", f"{'Dry-Run' if dry_run else 'Synchronisierung'} abgebrochen: {error}") return SyncResult(run=run) from .presence_state import extract_last_seen diff --git a/app/static/css/app.css b/app/static/css/app.css index 2cb069f..bc4f8c1 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2851,110 +2851,3 @@ 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"] textarea:disabled, .privacy-settings-form[data-privacy-readonly="true"] select:disabled { opacity: 1; color: inherit; -webkit-text-fill-color: currentColor; cursor: default; } - - -/* v0.5.5.41: readable chart status/category filters in dark mode. */ -html[data-theme="dark"] .checkbox-option{ - background:#f8fafc !important; - color:#111827 !important; - border-color:#cbd5e1 !important; -} -html[data-theme="dark"] .checkbox-option label, -html[data-theme="dark"] .checkbox-option span, -html[data-theme="dark"] .checkbox-option strong{ - color:#111827 !important; -} - -/* 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; -} - -/* v0.5.5.43: user-resizable widths for every standard table column. */ -.data-table thead tr:first-child > th:not(.table-row-number-header) { - position: relative; - overflow: visible; -} -.data-table .table-column-resizer { - position: absolute; - top: 0; - right: -4px; - bottom: 0; - width: 9px; - z-index: 4; - cursor: col-resize; - touch-action: none; -} -.data-table .table-column-resizer::after { - content: ""; - position: absolute; - top: 18%; - bottom: 18%; - 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; -} diff --git a/app/static/js/asset-columns.js b/app/static/js/asset-columns.js index dc8349f..e878837 100644 --- a/app/static/js/asset-columns.js +++ b/app/static/js/asset-columns.js @@ -224,12 +224,6 @@ } }); - window.addEventListener('asset-table-column-width-changed', event => { - if (!event.detail?.table || event.detail.table === table) { - applyHidden(); - } - }); - applyHidden(); window.dispatchEvent(new CustomEvent('asset-table-columns-ready', { detail: { table } diff --git a/app/static/js/browser-state.js b/app/static/js/browser-state.js index 87d8b38..6d33197 100644 --- a/app/static/js/browser-state.js +++ b/app/static/js/browser-state.js @@ -35,35 +35,15 @@ 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) { if (table.dataset.browserStateReady === '1') return; if (table.dataset.serverFilters === '1') { table.dataset.browserStateReady = '1'; - restoreSort(table, index); return; } const restoreStarted = performance.now(); const controls = filterControls(table); - if (!controls.length) { - table.dataset.browserStateReady = '1'; - restoreSort(table, index); - return; - } + if (!controls.length) return; table.dataset.browserStateReady = '1'; const key = `${tableKey(table, index)}:filters`; @@ -91,7 +71,6 @@ if (typeof table.assetApplyFilters === 'function') { table.assetApplyFilters(); } - restoreSort(table, index); table.dataset.browserStateRestored = '1'; window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } })); const duration = performance.now() - restoreStarted; @@ -130,25 +109,5 @@ 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'); })(); diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js index 872185c..73015bb 100644 --- a/app/static/js/table-tools.js +++ b/app/static/js/table-tools.js @@ -163,104 +163,6 @@ 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(); - - 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 usesServerFilters = table.dataset.serverFilters === '1'; let serverFilterTimer = null; @@ -271,19 +173,15 @@ // real data-field keys on its movable columns; selection and action // columns must remain without a field key. if (!isAssetTable) { - let syntheticIndex = 0; - [...headerRow.cells].forEach(header => { - if (header.dataset.rowNumberColumn === '1') return; + [...headerRow.cells].forEach((header, index) => { if (!header.dataset.field) { - header.dataset.field = `__col_${syntheticIndex}`; + header.dataset.field = `__col_${index}`; header.dataset.syntheticField = '1'; } - syntheticIndex += 1; }); [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { - if (cell.dataset.rowNumberColumn === '1') return; const header = headerRow.cells[index]; if (header && !cell.dataset.field) { cell.dataset.field = header.dataset.field; @@ -299,160 +197,6 @@ const filterDefinitions = []; - // User-resizable widths for all real table columns. Widths are stored per - // table and column key. The visual row-number column keeps its dedicated - // implementation above because it is generated dynamically. - const columnWidthsStorageKey = `assetmanager:table:${rowNumberStoragePart}:column-widths`; - const minimumColumnWidth = 56; - const maximumColumnWidth = 900; - - function readStoredColumnWidths() { - try { - const parsed = JSON.parse(window.localStorage.getItem(columnWidthsStorageKey) || '{}'); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; - } catch (_) { - return {}; - } - } - - let storedColumnWidths = readStoredColumnWidths(); - - function columnStorageKey(header, columnIndex) { - return header?.dataset.field || header?.dataset.serverFilterName || `__column_${columnIndex}`; - } - - function normalizedColumnWidth(width) { - return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width))); - } - - function applyColumnWidth(columnIndex, width, options = {}) { - const header = headerRow.cells[columnIndex]; - if (!header || header.dataset.rowNumberColumn === '1') return null; - const normalized = normalizedColumnWidth(width); - const cells = [header, filterRow.cells[columnIndex], ...[...tbody.rows].map(row => row.cells[columnIndex])]; - cells.filter(Boolean).forEach(cell => { - cell.style.width = `${normalized}px`; - cell.style.minWidth = `${normalized}px`; - cell.style.maxWidth = `${normalized}px`; - }); - header.dataset.userColumnWidth = String(normalized); - - // The asset list calculates its scroll width from data-column-width. - // Keep that existing mechanism in sync instead of introducing a second - // competing table-width calculation. - if (isAssetTable && header.dataset.field) { - header.dataset.columnWidth = String(normalized); - window.dispatchEvent(new CustomEvent('asset-table-column-width-changed', { - detail: { table, field: header.dataset.field, width: normalized } - })); - } - - if (options.save) { - storedColumnWidths[columnStorageKey(header, columnIndex)] = normalized; - try { - window.localStorage.setItem(columnWidthsStorageKey, JSON.stringify(storedColumnWidths)); - } catch (_) {} - } - return normalized; - } - - function applyStoredColumnWidths() { - [...headerRow.cells].forEach((header, columnIndex) => { - if (header.dataset.rowNumberColumn === '1') return; - const stored = Number.parseInt(storedColumnWidths[columnStorageKey(header, columnIndex)], 10); - if (Number.isFinite(stored)) applyColumnWidth(columnIndex, stored); - }); - } - - function resetColumnWidth(columnIndex) { - const header = headerRow.cells[columnIndex]; - if (!header || header.dataset.rowNumberColumn === '1') return; - delete storedColumnWidths[columnStorageKey(header, columnIndex)]; - try { - window.localStorage.setItem(columnWidthsStorageKey, JSON.stringify(storedColumnWidths)); - } catch (_) {} - - [header, filterRow.cells[columnIndex], ...[...tbody.rows].map(row => row.cells[columnIndex])] - .filter(Boolean) - .forEach(cell => { - cell.style.removeProperty('width'); - cell.style.removeProperty('min-width'); - cell.style.removeProperty('max-width'); - }); - delete header.dataset.userColumnWidth; - - if (isAssetTable && header.dataset.field) { - const configured = Number.parseInt(header.dataset.defaultColumnWidth || '', 10); - const fallback = Number.isFinite(configured) ? configured : 180; - header.dataset.columnWidth = String(fallback); - window.dispatchEvent(new CustomEvent('asset-table-column-width-changed', { - detail: { table, field: header.dataset.field, width: fallback } - })); - } - } - - function installColumnResizers() { - [...headerRow.cells].forEach((header, columnIndex) => { - if (header.dataset.rowNumberColumn === '1' || header.colSpan > 1) return; - if (header.querySelector(':scope > .table-column-resizer')) return; - - if (isAssetTable && !header.dataset.defaultColumnWidth) { - header.dataset.defaultColumnWidth = header.dataset.columnWidth || ''; - } - - 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.rowNumberResizeLabel || 'Drag to change column width'; - header.appendChild(handle); - - handle.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); - }); - - handle.addEventListener('dblclick', event => { - event.preventDefault(); - event.stopPropagation(); - resetColumnWidth(columnIndex); - }); - - handle.addEventListener('pointerdown', event => { - event.preventDefault(); - event.stopPropagation(); - const startX = event.clientX; - const startWidth = Math.round(header.getBoundingClientRect().width); - 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 = Number.parseInt(header.dataset.userColumnWidth || '', 10) || startWidth; - applyColumnWidth(columnIndex, current, { save: true }); - 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 = Number.parseInt(header.dataset.userColumnWidth || '', 10) - || Math.round(header.getBoundingClientRect().width); - applyColumnWidth(columnIndex, current + (event.key === 'ArrowRight' ? 12 : -12), { save: true }); - }); - }); - } - function filterStatusTarget() { const wrapper = table.closest( '#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table' @@ -547,22 +291,16 @@ } function rebuildMissingCellKeys() { - ensureRowNumberCells(); - if (isAssetTable) { - applyStoredColumnWidths(); - return; - } + if (isAssetTable) return; [...tbody.rows].forEach(row => { [...row.cells].forEach((cell, index) => { - if (cell.dataset.rowNumberColumn === '1') return; const header = headerRow.cells[index]; if (header && !cell.dataset.field) { cell.dataset.field = header.dataset.field; } }); }); - applyStoredColumnWidths(); } function numericFilterMatches(rawValue, rawTerm) { @@ -667,20 +405,6 @@ 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() { rebuildMissingCellKeys(); @@ -695,7 +419,6 @@ const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10); const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length; updateFilterPresentation(active, tbody.rows.length, total); - updateRowNumbers(); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { table, @@ -724,7 +447,6 @@ }); updateFilterPresentation(active, visibleRows, tbody.rows.length); - updateRowNumbers(); window.dispatchEvent(new CustomEvent('asset-table-filter-changed', { detail: { @@ -736,43 +458,6 @@ })); } - 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) => { const field = header.dataset.field || ''; const serverName = header.dataset.serverFilterName || ''; @@ -783,18 +468,37 @@ header.addEventListener('click', event => { if (event.target.closest('input,button,a,select,textarea,label')) return; + 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'); if (field) filterCell.dataset.field = field; 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)) { const input = document.createElement('input'); @@ -837,8 +541,6 @@ }); thead.appendChild(filterRow); - installColumnResizers(); - applyStoredColumnWidths(); // Editable cells are always read live, so changed values immediately // participate in filtering without a stale cache. @@ -851,8 +553,6 @@ }); table.assetApplyFilters = applyFilters; - table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options); - table.assetUpdateRowNumbers = updateRowNumbers; table.assetRebuildFilterCache = rebuildMissingCellKeys; table.dataset.tableToolsReady = '1'; ensureFilterStatus(); @@ -886,13 +586,7 @@ const table = event.detail?.table; if (table?.assetRebuildFilterCache) { table.assetRebuildFilterCache(); - 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.assetApplyFilters?.(); } }); })(); diff --git a/app/templates/base.html b/app/templates/base.html index c5fdd36..1486e72 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -14,7 +14,7 @@ --am-warning:{{ colors.get('warning_color', '#d97706') }}; --am-danger:{{ colors.get('danger_color', '#b42318') }}; } -{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %} +{{ title or general.get('title', 'AssetManager') }}{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}{% endif %}
{% if general.get('logo') %}Logo{% endif %}{{ general.get('title', 'AssetManager') }}{% if general.get('company_name') %}– {{ general.get('company_name') }}{% endif %} diff --git a/app/templates/meshcentral_sync.html b/app/templates/meshcentral_sync.html index 47fd8f8..7334737 100644 --- a/app/templates/meshcentral_sync.html +++ b/app/templates/meshcentral_sync.html @@ -1,5 +1,5 @@ {% extends 'base.html' %}{% block content %} -

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

+

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

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

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

{{t('mesh.ready')}}
{{t('mesh.ready_message')}}
@@ -24,7 +24,14 @@ -

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

+

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

+

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

+
+ + + +
+

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

@@ -71,5 +78,19 @@

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

{% for run in runs %}{% else %}{% endfor %}
{{t('mesh.start_time')}}{{t('assets.status')}}{{t('mesh.found')}}{{t('mesh.created')}}{{t('mesh.changed')}}{{t('mesh.conflicts')}}{{t('mesh.errors')}}{{t('mesh.message')}}
{{run.status}}{{run.devices_found}}{{run.created_count}}{{run.updated_count}}{{run.conflict_count}}{{run.error_count}}{{run.message or ''}}
{{t('mesh.no_runs')}}
- + {% endblock %} diff --git a/app/version.py b/app/version.py index 5ebc30f..f889c80 100644 --- a/app/version.py +++ b/app/version.py @@ -1 +1,2 @@ -APP_VERSION = "0.5.5.43" +APP_VERSION = "0.5.5.41" +__version__ = APP_VERSION diff --git a/docs/version-history/README.md b/docs/version-history/README.md index 0f9832d..b531f78 100644 --- a/docs/version-history/README.md +++ b/docs/version-history/README.md @@ -162,4 +162,4 @@ See [UPDATE-NOTE.md](UPDATE-NOTE.md). - [0.5.5.39](UPDATE-0.5.5.39.md) – Privacy read-only tabs and permission-aware dashboard counts. - [0.5.5.40](UPDATE-0.5.5.40.md) – Privacy exports available to all authenticated users. -- [0.5.5.41](UPDATE-0.5.5.41.md) – Dark-mode filter readability, persisted table sorting, and generated row numbers. +- [0.5.5.41](UPDATE-0.5.5.41.md) – Configurable MeshCentral matching and synchronization dry run. diff --git a/docs/version-history/UPDATE-0.5.5.41.md b/docs/version-history/UPDATE-0.5.5.41.md index 709921a..65cd9db 100644 --- a/docs/version-history/UPDATE-0.5.5.41.md +++ b/docs/version-history/UPDATE-0.5.5.41.md @@ -1,8 +1,9 @@ # Version 0.5.5.41 -## Changes +## MeshCentral synchronization matching and dry run -- Improved readability of chart status and category filter options in dark mode. -- Table sorting is now stored per table in browser state and restored when the user returns. -- Every standard data table now receives a generated first column with visual row numbers. -- Row numbers are recalculated after filtering, sorting, and partial server-side table refreshes and are not database identifiers. +- Added configurable matching by MeshCentral node ID and serial number. +- Added an optional normalized manufacturer comparison for serial-number matches. +- Manufacturer normalization ignores case, punctuation, accents and common legal suffixes such as GmbH, Inc. and Corporation. +- Added a MeshCentral synchronization dry run that uses the same mapping and matching logic without persisting asset changes. +- The dry-run live log reports devices that would be created, updated or rejected because of conflicts.