Privacy Policy DSGVO with sheets for categories, export-, check- and first erase-functions

This commit is contained in:
2026-08-08 10:16:36 +00:00
parent daac478714
commit daeb08c054
15 changed files with 162 additions and 526 deletions
+1 -1
View File
@@ -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. 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.
+1 -1
View File
@@ -1 +1 @@
0.5.5.43 0.5.5.41
+1
View File
@@ -60,6 +60,7 @@ DEFAULT_CONFIG: dict[str, Any] = {
"presence_enabled": True, "presence_enabled": True,
"presence_interval_seconds": 30, "presence_interval_seconds": 30,
"match_order": ["node_id", "serial_number"], "match_order": ["node_id", "serial_number"],
"serial_match_manufacturer": False,
"create_missing_assets": True, "create_missing_assets": True,
"mark_missing_devices": True, "mark_missing_devices": True,
"field_rules": {}, "field_rules": {},
+6 -2
View File
@@ -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.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.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.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.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"),
"mesh.found": ("Found", "Gefunden"), "mesh.created": ("Created", "Neu"), "mesh.changed": ("Changed", "Geändert"), "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.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"),
"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"),
+22 -4
View File
@@ -1280,14 +1280,14 @@ def _job_log(job_id: str, level: str, message: str) -> None:
except OSError: except OSError:
pass pass
def _run_sync_job(job_id: str) -> None: def _run_sync_job(job_id: str, dry_run: bool = False) -> None:
db = SessionLocal() db = SessionLocal()
try: 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: with SYNC_JOBS_LOCK:
job = SYNC_JOBS[job_id] job = SYNC_JOBS[job_id]
job["status"] = result.run.status 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 job["finished"] = True
except Exception as exc: except Exception as exc:
_job_log(job_id, "error", f"Unerwarteter Fehler: {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} 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}") @app.get("/sync/meshcentral/jobs/{job_id}")
def meshcentral_sync_job(job_id: str, offset: int = 0): def meshcentral_sync_job(job_id: str, offset: int = 0):
with SYNC_JOBS_LOCK: 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', 'create_missing_assets': form.get('create_missing_assets') == 'on',
'mark_missing_devices': form.get('mark_missing_devices') == 'on', 'mark_missing_devices': form.get('mark_missing_devices') == 'on',
'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',
}) })
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. # 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)
+62 -12
View File
@@ -2,6 +2,7 @@ import json
import os import os
import re import re
import subprocess import subprocess
import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path 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 mapped[target] = value
return mapped 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: 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 == "serial_number" and mapped.get("serial_number"):
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:
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: else:
continue continue
if len(matches) == 1: if len(matches) == 1:
return matches[0], method, None return matches[0], method, None
if len(matches) > 1: 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 return None, None, None
@@ -858,10 +890,11 @@ def _inventory_segments(raw: dict[str, Any]) -> tuple[dict[str, Any], list[Any]
software = [] software = []
return hardware, 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) log = log or (lambda level, message: None)
cfg = load_config()["meshcentral"] cfg = load_config()["meshcentral"]
run = SyncRun(status="running") 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.add(run)
db.commit() db.commit()
db.refresh(run) db.refresh(run)
@@ -894,7 +927,10 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None) -> S
for target in missing_columns: for target in missing_columns:
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", "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) 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).")
@@ -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.") log("error", "Gerät ohne Node-ID wurde übersprungen.")
continue continue
seen_node_ids.add(node_id) 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: if match_error:
run.conflict_count += 1 run.conflict_count += 1
log("warning", f"{mapped.get('name', node_id)}: {match_error}") 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) asset = Asset(category_id=category.id, name=mapped.get("name", node_id), mesh_node_id=node_id)
db.add(asset) db.add(asset)
run.created_count += 1 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: elif match_method == "serial_number" and not asset.mesh_node_id:
asset.mesh_node_id = 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] = {} conflicts: dict[str, Any] = {}
history_changes: dict[str, dict[str, str]] = {} 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" asset.mesh_sync_message = "Über Seriennummer zugeordnet" if match_method == "serial_number" else "Synchronisiert"
if changed: if changed:
run.updated_count += 1 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: else:
log("debug", f"{asset.name}: keine Änderungen.") log("debug", f"{asset.name}: keine Änderungen.")
except Exception as item_error: 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.status = "success" if run.error_count == 0 else "partial"
run.finished_at = datetime.utcnow() run.finished_at = datetime.utcnow()
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() db.commit()
log("success", f"Synchronisierung beendet: {run.created_count} neu, {run.updated_count} geändert, {run.conflict_count} Konflikte, {run.error_count} Fehler.") 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: except Exception as error:
db.rollback() 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.status = "error"
run.error_count += 1 run.error_count = int(run.error_count or 0) + 1
run.message = str(error) run.message = str(error)
run.finished_at = datetime.utcnow() run.finished_at = datetime.utcnow()
if not dry_run and run.id is not None:
db.commit() db.commit()
log("error", f"Synchronisierung abgebrochen: {error}") log("error", f"{'Dry-Run' if dry_run else 'Synchronisierung'} abgebrochen: {error}")
return SyncResult(run=run) return SyncResult(run=run)
from .presence_state import extract_last_seen from .presence_state import extract_last_seen
-107
View File
@@ -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"] 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; }
/* 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;
}
-6
View File
@@ -224,12 +224,6 @@
} }
}); });
window.addEventListener('asset-table-column-width-changed', event => {
if (!event.detail?.table || event.detail.table === table) {
applyHidden();
}
});
applyHidden(); applyHidden();
window.dispatchEvent(new CustomEvent('asset-table-columns-ready', { window.dispatchEvent(new CustomEvent('asset-table-columns-ready', {
detail: { table } detail: { table }
+1 -42
View File
@@ -35,35 +35,15 @@
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) { if (!controls.length) return;
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`;
@@ -91,7 +71,6 @@
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;
@@ -130,25 +109,5 @@
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');
})(); })();
+27 -333
View File
@@ -163,104 +163,6 @@
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();
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;
@@ -271,19 +173,15 @@
// 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) {
let syntheticIndex = 0; [...headerRow.cells].forEach((header, index) => {
[...headerRow.cells].forEach(header => {
if (header.dataset.rowNumberColumn === '1') return;
if (!header.dataset.field) { if (!header.dataset.field) {
header.dataset.field = `__col_${syntheticIndex}`; header.dataset.field = `__col_${index}`;
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;
@@ -299,160 +197,6 @@
const filterDefinitions = []; 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() { function filterStatusTarget() {
const wrapper = table.closest( const wrapper = table.closest(
'#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table' '#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table'
@@ -547,22 +291,16 @@
} }
function rebuildMissingCellKeys() { function rebuildMissingCellKeys() {
ensureRowNumberCells(); if (isAssetTable) return;
if (isAssetTable) {
applyStoredColumnWidths();
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;
} }
}); });
}); });
applyStoredColumnWidths();
} }
function numericFilterMatches(rawValue, rawTerm) { function numericFilterMatches(rawValue, rawTerm) {
@@ -667,20 +405,6 @@
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();
@@ -695,7 +419,6 @@
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,
@@ -724,7 +447,6 @@
}); });
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: {
@@ -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) => { [...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 || '';
@@ -783,18 +468,37 @@
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');
@@ -837,8 +541,6 @@
}); });
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.
@@ -851,8 +553,6 @@
}); });
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.dataset.tableToolsReady = '1'; table.dataset.tableToolsReady = '1';
ensureFilterStatus(); ensureFilterStatus();
@@ -886,13 +586,7 @@
const table = event.detail?.table; const table = event.detail?.table;
if (table?.assetRebuildFilterCache) { if (table?.assetRebuildFilterCache) {
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.assetApplyFilters?.();
} }
table.assetUpdateRowNumbers?.();
}
}); });
})(); })();
+1 -1
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-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-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-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>
+24 -3
View File
@@ -1,5 +1,5 @@
{% extends 'base.html' %}{% block content %} {% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{t('mesh.title')}}</h1><button id="sync-start">{{t('mesh.start')}}</button></div> <div class="toolbar"><h1>{{t('mesh.title')}}</h1><div class="toolbar-actions"><button id="sync-dry-run" type="button" class="button button-secondary">{{ t('mesh.dry_run') }}</button><button id="sync-start" type="button">{{t('mesh.start')}}</button></div></div>
<div class="cards"><div class="card"><strong>{{linked}}</strong><span>{{t('mesh.linked_assets')}}</span></div><div class="card"><strong>{{conflicts}}</strong><span>{{t('mesh.conflicts')}}</span></div><div class="card"><strong>{{missing}}</strong><span>{{t('mesh.missing')}}</span></div></div> <div class="cards"><div class="card"><strong>{{linked}}</strong><span>{{t('mesh.linked_assets')}}</span></div><div class="card"><strong>{{conflicts}}</strong><span>{{t('mesh.conflicts')}}</span></div><div class="card"><strong>{{missing}}</strong><span>{{t('mesh.missing')}}</span></div></div>
<section class="panel"><div class="log-header"><h2>{{t('mesh.live_log')}}</h2><span id="sync-state" class="sync-status">{{t('mesh.ready')}}</span></div><div id="sync-log" class="sync-log"><div class="log-line log-info">{{t('mesh.ready_message')}}</div></div></section> <section class="panel"><div class="log-header"><h2>{{t('mesh.live_log')}}</h2><span id="sync-state" class="sync-status">{{t('mesh.ready')}}</span></div><div id="sync-log" class="sync-log"><div class="log-line log-info">{{t('mesh.ready_message')}}</div></div></section>
@@ -24,7 +24,14 @@
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="mark_missing_devices" {% if config.meshcentral.mark_missing_devices %}checked{% endif %}> {{ t('mesh.mark_missing_devices') }}</label> <label class="checkbox-label"><input class="checkbox" type="checkbox" name="mark_missing_devices" {% if config.meshcentral.mark_missing_devices %}checked{% endif %}> {{ t('mesh.mark_missing_devices') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="store_source_json" {% if config.meshcentral.get('store_source_json',true) %}checked{% endif %}> {{ t('mesh.store_source_json') }}</label> <label class="checkbox-label"><input class="checkbox" type="checkbox" name="store_source_json" {% if config.meshcentral.get('store_source_json',true) %}checked{% endif %}> {{ t('mesh.store_source_json') }}</label>
</div> </div>
<h3>{{ t('mesh.asset_matching') }}</h3><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 %}> Node-ID</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 %}> Seriennummer</label></div> <h3>{{ t('mesh.asset_matching') }}</h3>
<p class="muted">{{ t('mesh.asset_matching_help') }}</p>
<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="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>
</div>
<p class="muted">{{ t('mesh.match_serial_manufacturer_help') }}</p>
<button class="button button-save">💾 {{ t('mesh.save_connection') }}</button> <button class="button button-save">💾 {{ t('mesh.save_connection') }}</button>
</form> </form>
</section> </section>
@@ -71,5 +78,19 @@
</section> </section>
<section class="panel"><h2>{{t('mesh.recent_runs')}}</h2><table><thead><tr><th>{{t('mesh.start_time')}}</th><th>{{t('assets.status')}}</th><th>{{t('mesh.found')}}</th><th>{{t('mesh.created')}}</th><th>{{t('mesh.changed')}}</th><th>{{t('mesh.conflicts')}}</th><th>{{t('mesh.errors')}}</th><th>{{t('mesh.message')}}</th></tr></thead><tbody>{% for run in runs %}<tr><td><time class="local-time" datetime="{{ utc_iso(run.started_at) }}" data-utc="{{ utc_iso(run.started_at) }}">{{ run.started_at }}</time></td><td>{{run.status}}</td><td>{{run.devices_found}}</td><td>{{run.created_count}}</td><td>{{run.updated_count}}</td><td>{{run.conflict_count}}</td><td>{{run.error_count}}</td><td>{{run.message or ''}}</td></tr>{% else %}<tr><td colspan="8">{{t('mesh.no_runs')}}</td></tr>{% endfor %}</tbody></table></section> <section class="panel"><h2>{{t('mesh.recent_runs')}}</h2><table><thead><tr><th>{{t('mesh.start_time')}}</th><th>{{t('assets.status')}}</th><th>{{t('mesh.found')}}</th><th>{{t('mesh.created')}}</th><th>{{t('mesh.changed')}}</th><th>{{t('mesh.conflicts')}}</th><th>{{t('mesh.errors')}}</th><th>{{t('mesh.message')}}</th></tr></thead><tbody>{% for run in runs %}<tr><td><time class="local-time" datetime="{{ utc_iso(run.started_at) }}" data-utc="{{ utc_iso(run.started_at) }}">{{ run.started_at }}</time></td><td>{{run.status}}</td><td>{{run.devices_found}}</td><td>{{run.created_count}}</td><td>{{run.updated_count}}</td><td>{{run.conflict_count}}</td><td>{{run.error_count}}</td><td>{{run.message or ''}}</td></tr>{% else %}<tr><td colspan="8">{{t('mesh.no_runs')}}</td></tr>{% endfor %}</tbody></table></section>
<script>(()=>{const button=document.getElementById('sync-start'),logBox=document.getElementById('sync-log'),state=document.getElementById('sync-state');let offset=0,timer=null;function addLog(e){const l=document.createElement('div');l.className=`log-line log-${e.level||'info'}`;const localTime=window.AssetManagerTime?.formatUtc(e.time,e.time||'--')||(e.time||'--');l.textContent=`[${localTime}] ${e.message}`;logBox.appendChild(l);logBox.scrollTop=logBox.scrollHeight}async function jsonResponse(response,fallback){const text=await response.text();let data;try{data=JSON.parse(text)}catch(_){throw new Error(`${fallback} (HTTP ${response.status}): ${text.slice(0,300)||'{{ t('mesh.empty_response') }}'}`)}if(!response.ok)throw new Error(data.error||data.detail||fallback);return data}async function poll(id){try{const r=await fetch(`/sync/meshcentral/jobs/${id}?offset=${offset}`,{cache:'no-store'}),d=await jsonResponse(r,'{{ t('mesh.log_load_error') }}');d.logs.forEach(addLog);offset=d.next_offset;state.textContent=d.status;if(d.finished){clearInterval(timer);button.disabled=false}}catch(e){clearInterval(timer);button.disabled=false;addLog({level:'error',message:e.message})}}button.onclick=async()=>{button.disabled=true;logBox.innerHTML='';offset=0;try{const r=await fetch('/sync/meshcentral/start',{method:'POST'}),d=await jsonResponse(r,'{{ t('mesh.start_error') }}');await poll(d.job_id);timer=setInterval(()=>poll(d.job_id),800)}catch(e){addLog({level:'error',message:e.message});button.disabled=false}}})();</script> <script>(()=>{
const startButton=document.getElementById('sync-start');
const dryButton=document.getElementById('sync-dry-run');
const buttons=[startButton,dryButton].filter(Boolean);
const logBox=document.getElementById('sync-log');
const state=document.getElementById('sync-state');
let offset=0,timer=null;
function setBusy(busy){buttons.forEach(button=>button.disabled=busy)}
function addLog(e){const l=document.createElement('div');l.className=`log-line log-${e.level||'info'}`;const localTime=window.AssetManagerTime?.formatUtc(e.time,e.time||'--')||(e.time||'--');l.textContent=`[${localTime}] ${e.message}`;logBox.appendChild(l);logBox.scrollTop=logBox.scrollHeight}
async function jsonResponse(response,fallback){const text=await response.text();let data;try{data=JSON.parse(text)}catch(_){throw new Error(`${fallback} (HTTP ${response.status}): ${text.slice(0,300)||'{{ t('mesh.empty_response') }}'}`)}if(!response.ok)throw new Error(data.error||data.detail||fallback);return data}
async function poll(id){try{const r=await fetch(`/sync/meshcentral/jobs/${id}?offset=${offset}`,{cache:'no-store'}),d=await jsonResponse(r,'{{ t('mesh.log_load_error') }}');d.logs.forEach(addLog);offset=d.next_offset;state.textContent=d.status;if(d.finished){clearInterval(timer);setBusy(false)}}catch(e){clearInterval(timer);setBusy(false);addLog({level:'error',message:e.message})}}
async function startJob(url){setBusy(true);logBox.innerHTML='';offset=0;try{const r=await fetch(url,{method:'POST'}),d=await jsonResponse(r,'{{ t('mesh.start_error') }}');await poll(d.job_id);timer=setInterval(()=>poll(d.job_id),800)}catch(e){addLog({level:'error',message:e.message});setBusy(false)}}
startButton.onclick=()=>startJob('/sync/meshcentral/start');
if(dryButton)dryButton.onclick=()=>startJob('/sync/meshcentral/dry-run');
})();</script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -1 +1,2 @@
APP_VERSION = "0.5.5.43" APP_VERSION = "0.5.5.41"
__version__ = APP_VERSION
+1 -1
View File
@@ -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.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.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.
+6 -5
View File
@@ -1,8 +1,9 @@
# Version 0.5.5.41 # Version 0.5.5.41
## Changes ## MeshCentral synchronization matching and dry run
- Improved readability of chart status and category filter options in dark mode. - Added configurable matching by MeshCentral node ID and serial number.
- Table sorting is now stored per table in browser state and restored when the user returns. - Added an optional normalized manufacturer comparison for serial-number matches.
- Every standard data table now receives a generated first column with visual row numbers. - Manufacturer normalization ignores case, punctuation, accents and common legal suffixes such as GmbH, Inc. and Corporation.
- Row numbers are recalculated after filtering, sorting, and partial server-side table refreshes and are not database identifiers. - 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.