fmeshsync fix with synology,vm's and lxc

This commit is contained in:
root
2026-08-08 19:44:27 +02:00
parent 2786f12ab6
commit 5e32be7d0c
41 changed files with 1335 additions and 1674 deletions
+3 -1
View File
@@ -59,8 +59,10 @@ DEFAULT_CONFIG: dict[str, Any] = {
"timeout_seconds": 120,
"presence_enabled": True,
"presence_interval_seconds": 30,
"match_order": ["node_id", "serial_number"],
"match_order": ["node_id", "mac_address", "serial_number"],
"serial_match_manufacturer": False,
"invalid_serial_values": ["unknown", "n/a", "none", "not specified", "to be filled by o.e.m."],
"manufacturer_invalid_serial_rules": [{"manufacturer": "Synology", "values": ["123456789", "Unknown"]}],
"create_missing_assets": True,
"mark_missing_devices": True,
"field_rules": {},
+20
View File
@@ -7,6 +7,8 @@ BASE_TRANSLATIONS = {
"app.charts": ("Charts", "Diagramme"), "app.categories": ("Categories", "Kategorien"), "app.status_values": ("Status values", "Statuswerte"),
"app.settings": ("Settings", "Einstellungen"), "app.users": ("Users", "Benutzer"), "app.profile": ("Profile", "Profil"),
"app.login": ("Sign in", "Anmelden"), "app.logout": ("Sign out", "Ausloggen"), "app.guest": ("Guest", "Gast"),
"auth.disabled_banner_title": ("Authentication is disabled", "Authentifizierung ist deaktiviert"),
"auth.disabled_banner_text": ("This installation is currently accessible without signing in. For the first setup, edit data/config/config.json, set authentication.mode to local, restart the app container, and then sign in with the protected local administrator configured in .env.", "Diese Installation ist derzeit ohne Anmeldung erreichbar. Für die Ersteinrichtung bearbeite data/config/config.json, setze authentication.mode auf local, starte den App-Container neu und melde dich anschließend mit dem in .env konfigurierten geschützten lokalen Administrator an."),
"common.save": ("Save", "Speichern"), "common.cancel": ("Cancel", "Abbrechen"), "common.delete": ("Delete", "Löschen"),
"common.edit": ("Edit", "Bearbeiten"), "common.search": ("Search", "Suchen"), "common.export": ("Export", "Exportieren"),
"common.import": ("Import", "Importieren"), "common.back": ("Back", "Zurück"), "common.yes": ("Yes", "Ja"), "common.no": ("No", "Nein"),
@@ -74,9 +76,16 @@ BASE_TRANSLATIONS = {
"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_mac": ("MAC address", "MAC-Adresse"),
"mesh.match_serial": ("Serial number", "Seriennummer"),
"mesh.match_mac_help": ("MAC matching is used only for an exact, unique MAC address. Node ID remains the primary device identity.", "Der MAC-Abgleich wird nur bei einer exakten, eindeutigen MAC-Adresse verwendet. Die Node-ID bleibt die primäre Geräteidentität."),
"mesh.match_serial_manufacturer": ("For serial-number matches, also compare normalized manufacturer", "Beim Seriennummern-Abgleich zusätzlich den normalisierten Hersteller vergleichen"),
"mesh.match_serial_manufacturer_help": ("When enabled, a serial-number match is accepted only if the normalized manufacturer also matches. Case, punctuation, accents and common legal suffixes such as GmbH, Inc. or Corporation are ignored.", "Wenn aktiviert, wird eine Übereinstimmung über die Seriennummer nur akzeptiert, wenn auch der normalisierte Hersteller übereinstimmt. Groß-/Kleinschreibung, Satzzeichen, Akzente und übliche Rechtsformzusätze wie GmbH, Inc. oder Corporation werden ignoriert."),
"mesh.invalid_serial_values": ("Invalid serial-number placeholders", "Ungültige Seriennummern-Platzhalter"),
"mesh.invalid_serial_values_help": ("One value per line. These values are never used for serial-number matching and never overwrite an existing asset serial number.", "Ein Wert pro Zeile. Diese Werte werden niemals für den Seriennummern-Abgleich verwendet und überschreiben keine vorhandene Asset-Seriennummer."),
"mesh.manufacturer_invalid_serial_rules": ("Manufacturer-specific serial placeholders", "Herstellerspezifische Seriennummern-Platzhalter"),
"mesh.manufacturer_invalid_serial_rules_help": ("One rule per line in the format Manufacturer | Serial number. Example: Synology | 123456789. Manufacturer matching is normalized and ignores case, punctuation and common legal suffixes.", "Eine Regel pro Zeile im Format Hersteller | Seriennummer. Beispiel: Synology | 123456789. Der Herstellervergleich wird normalisiert und ignoriert Groß-/Kleinschreibung, Satzzeichen und übliche Rechtsformzusätze."),
"mesh.node_id_identity_help": ("If a reported serial number is recognized as a placeholder, AssetManager ignores it for matching and synchronization. The MeshCentral node ID remains the stable unique device identity. The original reported value is retained in the stored MeshCentral source data when source-data storage is enabled.", "Wird eine gemeldete Seriennummer als Platzhalter erkannt, ignoriert AssetManager sie beim Abgleich und bei der Synchronisierung. Die MeshCentral-Node-ID bleibt die stabile eindeutige Geräteidentität. Der ursprünglich gemeldete Wert bleibt bei aktivierter Quelldatenspeicherung in den gespeicherten MeshCentral-Quelldaten erhalten."),
"mesh.dry_run": ("Dry run", "Dry-Run"),
"mesh.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"),
@@ -87,6 +96,8 @@ BASE_TRANSLATIONS = {
"mesh.finished_visible": ("Synchronization finished. The log remains visible.", "Synchronisierung beendet. Das Protokoll bleibt sichtbar."),
"settings.mesh_mapping": ("MeshCentral category mapping", "MeshCentral-Kategoriezuordnung"),
"settings.mesh_mapping_help": ("Mappings are stored using stable category IDs. Renaming a category does not interrupt synchronization. Existing assets keep their current category; mappings apply only to newly imported devices.", "Die Zuordnung wird über stabile Kategorie-IDs gespeichert. Umbenennen einer Kategorie unterbricht die Synchronisierung daher nicht mehr. Bestehende Assets behalten ihre bisherige Kategorie; die Zuordnung gilt nur für neu importierte Geräte."),
"settings.save_mesh_mapping": ("Save category mapping", "Kategoriezuordnung speichern"),
"settings.mesh_mapping_saved": ("MeshCentral category mapping saved", "MeshCentral-Kategoriezuordnung gespeichert"),
"settings.fallback_category": ("Fallback category", "Fallback-Kategorie"), "settings.mobile_device": ("Mobile device", "Mobilgerät"),
"settings.device_type": ("Device type", "Gerätetyp"), "settings.other_device": ("other device", "sonstiges Gerät"),
"settings.mesh_type": ("MeshCentral type", "MeshCentral-Typ"), "settings.use_fallback": ("Use fallback", "Fallback verwenden"),
@@ -146,6 +157,9 @@ 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"),
"lists.column_resize": ("Drag to change the column width", "Ziehen, um die Spaltenbreite 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"),
@@ -549,6 +563,12 @@ BASE_TRANSLATIONS.update({
"duplicates.ignore_case": ("Ignore letter case", "Groß-/Kleinschreibung ignorieren"),
"duplicates.clear_filter": ("Clear duplicate filter", "Dublettenfilter aufheben"),
"duplicates.show": ("Show duplicates", "Duplikate anzeigen"),
"duplicates.active_filter": ("Duplicates: {field}", "Duplikate: {field}"),
"duplicates.result_summary": ("{records} records in {groups} duplicate groups found.", "{records} Datensätze in {groups} Dublettengruppen gefunden."),
"assets.active_selection": ("Active selection", "Aktive Auswahl"),
"assets.category_filter": ("Category: {category}", "Kategorie: {category}"),
"assets.clear_category_filter": ("Remove category filter", "Kategoriefilter entfernen"),
"duplicates.remove_filter": ("Remove duplicate filter", "Dublettenfilter entfernen"),
"duplicates.order_help": ("The top record remains. Empty fields are then filled in the selected order.", "Der oberste Datensatz bleibt bestehen. Danach werden in der Reihenfolge nur noch leere Felder ergänzt."),
"duplicates.order_notice": ("The category and name of the top asset remain authoritative. The other assets are deleted only after confirmation.", "Kategorie und Bezeichnung des obersten Assets bleiben maßgeblich. Die übrigen Assets werden erst nach der Bestätigung gelöscht."),
"duplicates.preview": ("View result", "Ergebnis ansehen"),
+113 -26
View File
@@ -9,6 +9,7 @@ import shutil
import io
import json
import logging
from logging.handlers import WatchedFileHandler
import traceback
import hashlib
import hmac
@@ -192,7 +193,7 @@ def _software_debug_tail(max_lines: int = 400) -> str:
logger = logging.getLogger("assetmanager")
logger.setLevel(logging.INFO)
if not logger.handlers:
file_handler = logging.FileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8")
file_handler = WatchedFileHandler(APP_LOG_DIR / "errors.log", encoding="utf-8")
file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
logger.addHandler(file_handler)
@@ -200,7 +201,7 @@ ldap_logger = logging.getLogger("assetmanager.ldap")
ldap_logger.setLevel(logging.DEBUG)
ldap_logger.propagate = False
if not ldap_logger.handlers:
ldap_file_handler = logging.FileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8")
ldap_file_handler = WatchedFileHandler(APP_LOG_DIR / "ldap.log", encoding="utf-8")
ldap_file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
ldap_logger.addHandler(ldap_file_handler)
ldap_console_handler = logging.StreamHandler()
@@ -6023,6 +6024,39 @@ def _decode_software_callback_body(raw_body: bytes) -> tuple[str, str, list[str]
return decoded_body, detected_encoding, decode_errors
def _sanitize_callback_value(value: Any) -> Any:
"""Remove PostgreSQL-incompatible NUL characters from callback values.
Windows registry and uninstall metadata can occasionally contain embedded
NUL characters. PostgreSQL text and JSON values reject those characters,
so callback payloads are sanitized before they are written to the database.
Other characters and the original data structure are preserved.
"""
if isinstance(value, str):
return value.replace("\x00", "")
if isinstance(value, list):
return [_sanitize_callback_value(item) for item in value]
if isinstance(value, tuple):
return tuple(_sanitize_callback_value(item) for item in value)
if isinstance(value, dict):
return {
_sanitize_callback_value(key) if isinstance(key, str) else key: _sanitize_callback_value(item)
for key, item in value.items()
}
return value
def _count_nul_characters(value: Any) -> int:
"""Count embedded NUL characters recursively for diagnostics only."""
if isinstance(value, str):
return value.count("\x00")
if isinstance(value, (list, tuple)):
return sum(_count_nul_characters(item) for item in value)
if isinstance(value, dict):
return sum(_count_nul_characters(key) + _count_nul_characters(item) for key, item in value.items())
return 0
def _process_software_job_callback(
job_id: int,
token: str,
@@ -6101,6 +6135,14 @@ def _process_software_job_callback(
_software_debug_log(f"CALLBACK JSON TYPE ERROR | job={job_id} | root={type(payload).__name__}")
raise HTTPException(400, "JSON-Objekt erwartet")
nul_character_count = _count_nul_characters(payload)
if nul_character_count:
payload = _sanitize_callback_value(payload)
_software_debug_log(
f"CALLBACK sanitized PostgreSQL-incompatible NUL characters | "
f"job={job_id} | removed={nul_character_count}"
)
_software_debug_log(
f"CALLBACK JSON parsed | job={job_id} | keys={','.join(sorted(str(key) for key in payload.keys()))}"
)
@@ -7910,15 +7952,6 @@ async def settings_save(
success_color: str = Form("#198754"),
warning_color: str = Form("#d97706"),
danger_color: str = Form("#b42318"),
mesh_fallback_category_id: int = Form(1),
mesh_category_1: int | None = Form(None),
mesh_category_2: int | None = Form(None),
mesh_category_3: int | None = Form(None),
mesh_category_4: int | None = Form(None),
mesh_category_5: int | None = Form(None),
mesh_category_6: int | None = Form(None),
mesh_category_7: int | None = Form(None),
mesh_category_8: int | None = Form(None),
auth_mode: str = Form("none"),
ldap_server: str = Form(""),
ldap_port: int = Form(389),
@@ -8012,19 +8045,7 @@ async def settings_save(
"debug_logging": ldap_debug_logging == "on",
})
authentication["ldap"] = ldap
meshcentral = dict(current.get("meshcentral", {}))
meshcentral["fallback_category_id"] = int(mesh_fallback_category_id or 1)
meshcentral["category_mapping"] = {
"1": mesh_category_1,
"2": mesh_category_2,
"3": mesh_category_3,
"4": mesh_category_4,
"5": mesh_category_5,
"6": mesh_category_6,
"7": mesh_category_7,
"8": mesh_category_8,
}
save_config({"general": general, "authentication": authentication, "meshcentral": meshcentral})
save_config({"general": general, "authentication": authentication})
return RedirectResponse("/settings?toast_success=Einstellungen gespeichert", status_code=303)
@@ -8037,11 +8058,13 @@ def meshcentral_sync_page(request: Request, db: Session = Depends(get_db)):
missing = db.query(Asset).filter(Asset.mesh_sync_status == "missing").count()
definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all()
mappings = db.query(MeshFieldMapping).order_by(MeshFieldMapping.priority, MeshFieldMapping.id).all()
categories = db.query(Category).order_by(Category.name.asc()).all()
return templates.TemplateResponse("meshcentral_sync.html", {
"request": request, "runs": runs, "config": public_config(),
"linked": linked, "conflicts": conflicts, "missing": missing,
"definitions": definitions,
"mappings": mappings,
"categories": categories,
"transform_options": [
"raw", "string", "integer", "decimal", "boolean", "json",
"normalize_serial", "normalize_mac", "active_ipv4_address", "active_ipv4_mac",
@@ -8198,8 +8221,34 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_
'store_source_json': form.get('store_source_json') == 'on',
'serial_match_manufacturer': form.get('serial_match_manufacturer') == 'on',
})
submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'serial_number'}]
mesh['match_order'] = submitted_match_order or ['node_id', 'serial_number']
invalid_serial_values = []
for line in str(form.get('invalid_serial_values') or '').splitlines():
value = line.strip()
if value and value.casefold() not in {item.casefold() for item in invalid_serial_values}:
invalid_serial_values.append(value)
mesh['invalid_serial_values'] = invalid_serial_values
manufacturer_rules: dict[str, list[str]] = {}
for line in str(form.get('manufacturer_invalid_serial_rules') or '').splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
if '|' not in line:
continue
manufacturer, serial = (part.strip() for part in line.split('|', 1))
if not manufacturer or not serial:
continue
values = manufacturer_rules.setdefault(manufacturer, [])
if serial.casefold() not in {item.casefold() for item in values}:
values.append(serial)
mesh['manufacturer_invalid_serial_rules'] = [
{'manufacturer': manufacturer, 'values': values}
for manufacturer, values in manufacturer_rules.items()
]
submitted_match_order = [value for value in form.getlist('match_order') if value in {'node_id', 'mac_address', 'serial_number'}]
mesh['match_order'] = submitted_match_order or ['node_id', 'mac_address', 'serial_number']
# Do not retain plaintext passwords from older versions.
mesh.pop('password', None)
mesh.pop('field_mappings', None)
@@ -8208,6 +8257,44 @@ async def meshcentral_settings_save(request: Request, db: Session = Depends(get_
return RedirectResponse('/sync/meshcentral?toast_success=MeshCentral-Einstellungen gespeichert', status_code=303)
@app.post('/sync/meshcentral/categories/save')
async def meshcentral_category_mapping_save(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
form = await request.form()
categories = db.query(Category).all()
valid_category_ids = {int(category.id) for category in categories}
if not valid_category_ids:
raise HTTPException(400, 'No asset categories are available.')
try:
fallback_category_id = int(form.get('mesh_fallback_category_id') or 0)
except (TypeError, ValueError):
fallback_category_id = 0
if fallback_category_id not in valid_category_ids:
raise HTTPException(400, 'Invalid fallback category.')
category_mapping = {}
for type_id in range(1, 9):
raw_value = str(form.get(f'mesh_category_{type_id}') or '').strip()
if not raw_value:
category_mapping[str(type_id)] = None
continue
try:
category_id = int(raw_value)
except ValueError:
raise HTTPException(400, f'Invalid category mapping for MeshCentral type {type_id}.')
if category_id not in valid_category_ids:
raise HTTPException(400, f'Unknown category for MeshCentral type {type_id}.')
category_mapping[str(type_id)] = category_id
current = load_config()
mesh = dict(current.get('meshcentral', {}))
mesh['fallback_category_id'] = fallback_category_id
mesh['category_mapping'] = category_mapping
save_config({'meshcentral': mesh})
return RedirectResponse('/sync/meshcentral?toast_success=' + quote(_translate_request(request, 'settings.mesh_mapping_saved', 'MeshCentral category mapping saved')), status_code=303)
@app.post('/sync/meshcentral/mappings/save')
async def meshcentral_mappings_save(request: Request, db: Session = Depends(get_db)):
_require_admin(request)
-1351
View File
File diff suppressed because it is too large Load Diff
+212 -13
View File
@@ -466,29 +466,126 @@ def _path_values(data: Any, path: str) -> list[Any]:
def _network_entries(value: Any) -> list[dict[str, Any]]:
"""Flatten MeshCentral network entries while retaining the interface name.
``net.netif2`` is keyed by interface name. Keeping that name allows us to
prefer real/primary interfaces over loopback, container and tunnel entries.
"""
entries: list[dict[str, Any]] = []
if isinstance(value, dict):
for group in value.values():
if isinstance(group, list):
entries.extend(item for item in group if isinstance(item, dict))
for adapter_name, group in value.items():
if not isinstance(group, list):
continue
for item in group:
if not isinstance(item, dict):
continue
entry = dict(item)
entry.setdefault('_adapter_name', str(adapter_name or ''))
entries.append(entry)
elif isinstance(value, list):
entries.extend(item for item in value if isinstance(item, dict))
entries.extend(dict(item) for item in value if isinstance(item, dict))
return entries
def _network_interface_rank(name: Any) -> int:
"""Return a stable preference rank for common physical/virtual NIC names."""
value = str(name or '').strip().casefold()
if not value:
return 80
# Explicitly de-prioritize interfaces that are usually local/container/tunnel
# plumbing and should not become the AssetManager management address.
low_priority = (
'loopback', 'docker', 'veth', 'virbr', 'tun', 'tap', 'tailscale',
'wg', 'zt', 'br-',
)
if value == 'lo' or value.startswith(low_priority):
return 200
# Common Linux, Windows and hypervisor interface names. Comparison is
# intentionally case-insensitive.
if value.startswith(('ethernet', 'eth')):
return 10
if value.startswith(('eno', 'ens', 'enp', 'enx')):
return 15
if value == 'vmbr0':
return 20
if value.startswith('vmbr'):
return 22
if value.startswith(('bond', 'team')):
return 25
if value.startswith(('wlan', 'wlp', 'wifi', 'wi-fi', 'wireless')):
return 30
if value.startswith(('vethernet', 'hyper-v', 'vmnet')):
return 35
if value.startswith('br'):
return 40
return 60
def _network_entry_active(entry: dict[str, Any]) -> bool:
status = str(entry.get('status') or '').strip().casefold()
return status not in {'down', 'inactive', 'disabled', 'disconnected', 'notpresent'}
def _valid_ipv4(value: Any) -> bool:
try:
import ipaddress
address = ipaddress.ip_address(str(value or '').strip())
except ValueError:
return False
return address.version == 4 and not address.is_loopback and not address.is_unspecified
def _valid_network_mac(value: Any) -> str | None:
mac = _normalize_mac(value)
if not mac:
return None
raw = re.sub(r'[^0-9A-Fa-f]', '', mac)
# Reject malformed, all-zero and broadcast placeholders. Locally administered
# addresses remain valid because they are common for virtual interfaces.
if len(raw) != 12 or set(raw.casefold()) == {'0'} or set(raw.casefold()) == {'f'}:
return None
return ':'.join(raw[index:index + 2] for index in range(0, 12, 2)).upper()
def _preferred_network_entries(value: Any) -> list[dict[str, Any]]:
"""Order usable network records by interface suitability and source order."""
candidates: list[tuple[int, int, dict[str, Any]]] = []
for index, entry in enumerate(_network_entries(value)):
if entry.get('type') == 'loopback' or not _network_entry_active(entry):
continue
rank = _network_interface_rank(entry.get('_adapter_name'))
candidates.append((rank, index, entry))
candidates.sort(key=lambda item: (item[0], item[1]))
return [entry for _, _, entry in candidates]
def _convert_mapping_value(values: list[Any], transform: str, separator: str) -> Any:
if not values:
return None
source = values[0]
if transform == 'active_ipv4_address':
for entry in _network_entries(source):
if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback':
return entry.get('address')
entries = _preferred_network_entries(source)
# Prefer a usable IPv4 address on well-known management interfaces.
for entry in entries:
if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')):
return str(entry.get('address')).strip()
return None
if transform == 'active_ipv4_mac':
for entry in _network_entries(source):
if entry.get('family') == 'IPv4' and entry.get('status') == 'up' and entry.get('type') != 'loopback':
return _normalize_mac(entry.get('mac'))
entries = _preferred_network_entries(source)
# Prefer the MAC from an interface that also has usable IPv4 data. This
# keeps IP and MAC aligned in the common case and avoids all-zero values.
for entry in entries:
if entry.get('family') == 'IPv4' and _valid_ipv4(entry.get('address')):
mac = _valid_network_mac(entry.get('mac'))
if mac:
return mac
# Some MeshCentral inventories expose MAC data in a separate record.
for entry in entries:
mac = _valid_network_mac(entry.get('mac'))
if mac:
return mac
return None
if transform == 'normalize_serial':
return _normalize_serial(source)
@@ -683,17 +780,72 @@ def _normalize_manufacturer(value: Any) -> str:
return "".join(tokens)
def _normalized_serial_value(value: Any) -> str:
if value in (None, ""):
return ""
return str(value).replace("\x00", "").strip().casefold()
def _invalid_serial_reason(mapped: dict[str, Any], cfg: dict[str, Any]) -> str | None:
"""Return a human-readable reason when a reported serial number is a configured placeholder."""
serial = _normalized_serial_value(mapped.get("serial_number"))
if not serial:
return None
global_values = {
_normalized_serial_value(value)
for value in (cfg.get("invalid_serial_values") or [])
if _normalized_serial_value(value)
}
if serial in global_values:
return "global configured placeholder"
manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
if manufacturer:
for rule in cfg.get("manufacturer_invalid_serial_rules") or []:
if not isinstance(rule, dict):
continue
if _normalize_manufacturer(rule.get("manufacturer")) != manufacturer:
continue
values = {
_normalized_serial_value(value)
for value in (rule.get("values") or [])
if _normalized_serial_value(value)
}
if serial in values:
return f"configured placeholder for {rule.get('manufacturer') or mapped.get('manufacturer')}"
return None
def _serial_identity_key(mapped: dict[str, Any], *, include_manufacturer: bool) -> tuple[str, str] | None:
serial = _normalized_serial_value(mapped.get("serial_number"))
if not serial:
return None
manufacturer = _normalize_manufacturer(mapped.get("manufacturer")) if include_manufacturer else ""
return serial, manufacturer
def _normalized_mac_identity(value: Any) -> str:
mac = _valid_network_mac(value)
return mac.casefold() if mac else ""
def _find_asset(
db: Session,
mapped: dict[str, Any],
order: list[str],
*,
serial_match_manufacturer: bool = False,
serial_is_invalid: bool = False,
serial_is_ambiguous: bool = False,
) -> tuple[Asset | None, str | None, str | None]:
incoming_mac = _normalized_mac_identity(mapped.get("mac_address"))
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"):
elif method == "mac_address" and incoming_mac:
matches = db.query(Asset).filter(func.lower(Asset.mac_address) == str(mapped.get("mac_address")).lower()).all()
elif method == "serial_number" and mapped.get("serial_number") and not serial_is_invalid and not serial_is_ambiguous:
matches = db.query(Asset).filter(func.lower(Asset.serial_number) == mapped["serial_number"].lower()).all()
if serial_match_manufacturer:
incoming_manufacturer = _normalize_manufacturer(mapped.get("manufacturer"))
@@ -703,6 +855,16 @@ def _find_asset(
asset for asset in matches
if _normalize_manufacturer(asset.manufacturer) == incoming_manufacturer
]
# A shared DMI/mainboard serial is common for containers and VMs.
# Never accept such a serial match when both sides have a valid but
# different MAC address. This avoids reassigning another guest's
# MeshCentral node ID.
if incoming_mac:
matches = [
asset for asset in matches
if not _normalized_mac_identity(asset.mac_address)
or _normalized_mac_identity(asset.mac_address) == incoming_mac
]
else:
continue
if len(matches) == 1:
@@ -928,12 +1090,34 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
log("error", f"Systemfeld '{target}' besitzt keine Asset-Datenbankspalte und wird übersprungen.")
run.error_count += 1
log("info", "Dry-Run gestartet; es werden keine Datenbankänderungen gespeichert." if dry_run else "Synchronisierung gestartet.")
log("info", "Asset-Zuordnung: " + "".join(cfg.get("match_order", ["node_id", "serial_number"])))
log("info", "Asset-Zuordnung: " + "".join(cfg.get("match_order", ["node_id", "mac_address", "serial_number"])))
if cfg.get("serial_match_manufacturer", False):
log("info", "Seriennummern-Abgleich erfordert zusätzlich einen normalisierten Herstellervergleich.")
invalid_global_count = len(cfg.get("invalid_serial_values") or [])
invalid_manufacturer_count = len(cfg.get("manufacturer_invalid_serial_rules") or [])
if invalid_global_count or invalid_manufacturer_count:
log("info", f"Dummy-Seriennummernfilter aktiv: {invalid_global_count} globale Werte, {invalid_manufacturer_count} Herstellerregel(n). Node-ID bleibt die stabile Geräteidentität.")
devices = fetch_devices(log)
run.devices_found = len(devices)
log("info", f"Verarbeite {len(devices)} Gerät(e).")
# Determine serial numbers that are already ambiguous within the current
# MeshCentral inventory. This is essential for containers/VMs where the
# host DMI/mainboard serial may be exposed identically to several guests.
serial_identity_counts: dict[tuple[str, str], int] = {}
for raw_for_identity in devices:
mapped_for_identity = map_device(raw_for_identity, field_mappings)
if _invalid_serial_reason(mapped_for_identity, cfg):
continue
identity_key = _serial_identity_key(
mapped_for_identity,
include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
)
if identity_key:
serial_identity_counts[identity_key] = serial_identity_counts.get(identity_key, 0) + 1
ambiguous_serial_keys = {key for key, count in serial_identity_counts.items() if count > 1}
if ambiguous_serial_keys:
log("warning", f"{len(ambiguous_serial_keys)} Seriennummern-Identität(en) sind im aktuellen MeshCentral-Bestand mehrfach vorhanden und werden nicht zum Matching verwendet.")
fallback_id = int(cfg.get("fallback_category_id") or 1)
fallback_category = db.query(Category).filter(Category.id == fallback_id).first()
if fallback_category is None and fallback_id != 1:
@@ -954,11 +1138,24 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
log("error", "Gerät ohne Node-ID wurde übersprungen.")
continue
seen_node_ids.add(node_id)
invalid_serial_reason = _invalid_serial_reason(mapped, cfg)
serial_identity_key = _serial_identity_key(
mapped,
include_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
)
serial_is_ambiguous = bool(serial_identity_key and serial_identity_key in ambiguous_serial_keys)
if serial_is_ambiguous:
log("warning", f"{mapped.get('name', node_id)}: Seriennummer '{mapped.get('serial_number')}' ist im aktuellen MeshCentral-Bestand nicht eindeutig und wird nicht zum Matching verwendet. Node-ID/MAC haben Vorrang.")
if invalid_serial_reason:
reported_serial = mapped.get("serial_number")
log("warning", f"{mapped.get('name', node_id)}: gemeldete Seriennummer '{reported_serial}' als Dummy erkannt ({invalid_serial_reason}); Seriennummer wird weder zum Matching noch zum Überschreiben verwendet. Node-ID: {node_id}.")
asset, match_method, match_error = _find_asset(
db,
mapped,
cfg.get("match_order", ["node_id", "serial_number"]),
cfg.get("match_order", ["node_id", "mac_address", "serial_number"]),
serial_match_manufacturer=bool(cfg.get("serial_match_manufacturer", False)),
serial_is_invalid=bool(invalid_serial_reason),
serial_is_ambiguous=serial_is_ambiguous,
)
if match_error:
run.conflict_count += 1
@@ -997,6 +1194,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d
for target, incoming in mapped.items():
if target == "mesh_device_type" or incoming in (None, "", [], {}):
continue
if target == "serial_number" and invalid_serial_reason:
continue
definition = field_definitions.get(target)
if definition is None:
+110
View File
@@ -2851,3 +2851,113 @@ table.server-filter-loading tbody{opacity:.55;transition:opacity .12s ease}
.privacy-settings-form[data-privacy-readonly="true"] input:disabled,
.privacy-settings-form[data-privacy-readonly="true"] textarea:disabled,
.privacy-settings-form[data-privacy-readonly="true"] select:disabled { opacity: 1; color: inherit; -webkit-text-fill-color: currentColor; cursor: default; }
/* First-start security notice shown while authentication is disabled. */
.authentication-disabled-banner{display:flex;align-items:flex-start;gap:.75rem;padding:.7rem 1.25rem;background:#fff3cd;border-bottom:1px solid #d9b650;color:#4a3a00;font-size:.92rem;line-height:1.35}
.authentication-disabled-banner strong{white-space:nowrap}
html[data-theme="dark"] .authentication-disabled-banner{background:#3b310d;border-bottom-color:#7f6a1d;color:#fff0ad}
@media(max-width:800px){.authentication-disabled-banner{flex-direction:column;gap:.25rem}.authentication-disabled-banner strong{white-space:normal}}
/* Active category / duplicate filters on the asset list. */
.asset-context-filter-details{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.asset-filter-chip{display:inline-flex;align-items:center;gap:6px;padding:3px 7px;border:1px solid currentColor;border-radius:999px;background:rgba(255,255,255,.45)}
.asset-filter-chip-remove{display:inline-grid;place-items:center;width:20px;height:20px;padding:0;border:0;border-radius:50%;background:transparent;color:inherit;font:inherit;font-size:17px;line-height:1;cursor:pointer}
.asset-filter-chip-remove:hover,.asset-filter-chip-remove:focus-visible{background:rgba(0,0,0,.10);outline:none}
html[data-theme="dark"] .asset-filter-chip{background:rgba(0,0,0,.16)}
html[data-theme="dark"] .asset-filter-chip-remove:hover,html[data-theme="dark"] .asset-filter-chip-remove:focus-visible{background:rgba(255,255,255,.12)}
/* v0.5.5.41: generated visual row number column for data tables. */
.data-table .table-row-number-header,
.data-table .table-row-number-cell{
width:1%;
min-width:3.25rem;
white-space:nowrap;
text-align:right;
font-variant-numeric:tabular-nums;
}
.data-table .table-row-number-header{
cursor:default;
}
.data-table .table-row-number-cell{
color:var(--am-muted,#64748b);
user-select:none;
}
/* Visual row-number column: readable by default and user-resizable per table. */
.data-table .table-row-number-header,
.data-table .table-row-number-filter,
.data-table .table-row-number-cell {
width: var(--table-row-number-width, 64px);
min-width: var(--table-row-number-width, 64px);
max-width: var(--table-row-number-width, 64px);
text-align: right;
white-space: nowrap;
}
.data-table .table-row-number-header {
position: relative;
padding-right: 14px;
overflow: visible;
}
.data-table .table-row-number-cell {
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: clip;
}
.data-table .table-row-number-resizer {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
width: 9px;
cursor: col-resize;
z-index: 3;
touch-action: none;
}
.data-table .table-row-number-resizer::after {
content: "";
position: absolute;
top: 20%;
bottom: 20%;
left: 4px;
border-left: 1px solid transparent;
}
.data-table .table-row-number-resizer:hover::after,
.data-table .table-row-number-resizer:focus-visible::after {
border-left-color: currentColor;
}
html.table-column-resizing,
html.table-column-resizing * {
cursor: col-resize !important;
user-select: none !important;
}
/* User-resizable widths for every data-table column. */
.data-table th.table-resizable-column-header {
position: relative;
padding-right: 14px;
overflow: visible;
}
.data-table .table-column-resizer {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
width: 9px;
cursor: col-resize;
z-index: 4;
touch-action: none;
}
.data-table .table-column-resizer::after {
content: "";
position: absolute;
top: 20%;
bottom: 20%;
left: 4px;
border-left: 1px solid transparent;
}
.data-table .table-column-resizer:hover::after,
.data-table .table-column-resizer:focus-visible::after {
border-left-color: currentColor;
}
+48 -5
View File
@@ -9,6 +9,11 @@
const ignoreEmpty = document.getElementById('duplicate-ignore-empty');
const caseInsensitive = document.getElementById('duplicate-case-insensitive');
const summary = document.getElementById('duplicate-search-summary');
const contextStatus = document.getElementById('asset-context-filter-status');
const duplicateChip = document.getElementById('asset-duplicate-filter-chip');
const duplicateLabel = document.getElementById('asset-duplicate-filter-label');
const duplicateRemove = document.getElementById('asset-duplicate-filter-remove');
const categoryRemove = document.getElementById('asset-category-filter-remove');
if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return;
table.dataset.duplicateSearchReady = '1';
@@ -26,6 +31,17 @@
label: th.dataset.label || th.textContent.trim()
}));
function refreshContextStatus() {
const categoryChip = document.getElementById('asset-category-filter-chip');
const duplicateActive = table.dataset.duplicateFilterActive === '1';
if (duplicateChip) duplicateChip.hidden = !duplicateActive;
if (contextStatus) {
const hasActive = Boolean(categoryChip) || duplicateActive;
contextStatus.hidden = !hasActive;
contextStatus.classList.toggle('is-active', hasActive);
}
}
function fillSelect() {
const previous = select.value;
select.innerHTML = '';
@@ -42,8 +58,13 @@
function clearDuplicateFilter() {
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
summary.hidden = true;
window.dispatchEvent(new Event('asset-table-filter-changed'));
delete table.dataset.duplicateFilterActive;
delete table.dataset.duplicateFilterTotal;
delete table.dataset.duplicateFilterField;
if (summary) summary.hidden = true;
if (duplicateLabel) duplicateLabel.textContent = '';
refreshContextStatus();
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {detail: {table}}));
}
function normalize(value) {
@@ -93,9 +114,24 @@
}
});
summary.textContent = `${duplicateRows.size} Datensätze in ${duplicateGroups} Dublettengruppen gefunden.`;
summary.hidden = false;
window.dispatchEvent(new Event('asset-table-filter-changed'));
table.dataset.duplicateFilterActive = '1';
table.dataset.duplicateFilterTotal = String(duplicateRows.size);
table.dataset.duplicateFilterField = field;
const selectedOption = select.options[select.selectedIndex];
const fieldLabel = selectedOption?.textContent?.trim() || field;
const duplicateTemplate = contextStatus?.dataset.duplicateTemplate || 'Duplikate: {field}';
if (duplicateLabel) duplicateLabel.textContent = duplicateTemplate.replace('{field}', fieldLabel);
refreshContextStatus();
const summaryTemplate = dialog.dataset.resultSummaryTemplate || '{records} Datensätze in {groups} Dublettengruppen gefunden.';
if (summary) {
summary.textContent = summaryTemplate
.replace('{records}', String(duplicateRows.size))
.replace('{groups}', String(duplicateGroups));
summary.hidden = false;
}
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {detail: {table}}));
dialog.close();
}
@@ -108,6 +144,13 @@
});
applyButton.addEventListener('click', applyDuplicateFilter);
clearButton.addEventListener('click', clearDuplicateFilter);
duplicateRemove?.addEventListener('click', clearDuplicateFilter);
categoryRemove?.addEventListener('click', () => {
const url = new URL(window.location.href);
url.searchParams.delete('category_id');
window.location.assign(url.toString());
});
refreshContextStatus();
}
if (document.readyState === 'loading') {
+42 -1
View File
@@ -35,15 +35,35 @@
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) return;
if (!controls.length) {
table.dataset.browserStateReady = '1';
restoreSort(table, index);
return;
}
table.dataset.browserStateReady = '1';
const key = `${tableKey(table, index)}:filters`;
@@ -71,6 +91,7 @@
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;
@@ -109,5 +130,25 @@
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');
})();
+8 -4
View File
@@ -11,7 +11,7 @@
}
function isVisible(row) {
if (row.hidden || row.classList.contains('hidden')) return false;
if (row.hidden || row.classList.contains('hidden') || row.classList.contains('duplicate-hidden')) return false;
return true;
}
@@ -40,23 +40,27 @@
const updateStarted = performance.now();
const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow);
const visible = rows.filter(isVisible).length;
const duplicateTotal = Number.parseInt(table.dataset.duplicateFilterTotal || '', 10);
const total = table.dataset.duplicateFilterActive === '1' && Number.isFinite(duplicateTotal)
? duplicateTotal
: rows.length;
const target = countTarget(table, index);
if (target) {
const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen';
target.textContent = template
.replace('{visible}', String(visible))
.replace('{total}', String(rows.length));
.replace('{total}', String(total));
target.title = document.body.dataset.recordCountTitle || '';
}
window.dispatchEvent(new CustomEvent('asset-list-count-updated', {
detail: { table, visible, total: rows.length }
detail: { table, visible, total }
}));
const duration = performance.now() - updateStarted;
const logger = duration >= 50 ? console.warn : console.debug;
logger('[AssetManager][Performance]', 'list-tools:update', {
dauer_ms: Math.round(duration * 10) / 10,
sichtbar: visible,
gesamt: rows.length
gesamt: total
});
}
+294 -27
View File
@@ -163,6 +163,221 @@
const headerRow = thead.rows[0];
// Add a purely visual row-number column before every real table column.
// It is recalculated after filtering, sorting and partial server reloads and
// therefore never represents a database identifier.
if (!headerRow.querySelector('th[data-row-number-column="1"]')) {
const numberHeader = document.createElement('th');
numberHeader.className = 'table-row-number-header';
numberHeader.dataset.rowNumberColumn = '1';
numberHeader.dataset.noSort = '1';
numberHeader.dataset.noFilter = '1';
numberHeader.textContent = '#';
numberHeader.title = document.body.dataset.rowNumberLabel || 'Row number';
headerRow.insertBefore(numberHeader, headerRow.firstChild);
}
const numberHeader = headerRow.querySelector('th[data-row-number-column="1"]');
const rowNumberStoragePart = table.dataset.storageKey || table.id || window.location.pathname;
const rowNumberWidthKey = `assetmanager:table:${rowNumberStoragePart}:row-number-width`;
const defaultRowNumberWidth = 64;
const minimumRowNumberWidth = 48;
const maximumRowNumberWidth = 180;
function readStoredRowNumberWidth() {
try {
const stored = Number.parseInt(window.localStorage.getItem(rowNumberWidthKey) || '', 10);
if (Number.isFinite(stored)) {
return Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, stored));
}
} catch (_) {}
return defaultRowNumberWidth;
}
function applyRowNumberWidth(width) {
const normalized = Math.min(maximumRowNumberWidth, Math.max(minimumRowNumberWidth, Math.round(width)));
table.style.setProperty('--table-row-number-width', `${normalized}px`);
table.dataset.rowNumberWidth = String(normalized);
return normalized;
}
function installRowNumberResizer() {
if (!numberHeader || numberHeader.querySelector('.table-row-number-resizer')) return;
const handle = document.createElement('span');
handle.className = 'table-row-number-resizer';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.tabIndex = 0;
handle.title = document.body.dataset.rowNumberResizeLabel || 'Drag to change column width';
numberHeader.appendChild(handle);
const saveWidth = width => {
const normalized = applyRowNumberWidth(width);
try { window.localStorage.setItem(rowNumberWidthKey, String(normalized)); } catch (_) {}
};
handle.addEventListener('pointerdown', event => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth;
handle.setPointerCapture?.(event.pointerId);
document.documentElement.classList.add('table-column-resizing');
const move = moveEvent => applyRowNumberWidth(startWidth + moveEvent.clientX - startX);
const finish = finishEvent => {
saveWidth(Number.parseInt(table.dataset.rowNumberWidth || '', 10) || startWidth);
handle.releasePointerCapture?.(finishEvent.pointerId);
document.documentElement.classList.remove('table-column-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
handle.addEventListener('keydown', event => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
const current = Number.parseInt(table.dataset.rowNumberWidth || '', 10) || defaultRowNumberWidth;
saveWidth(current + (event.key === 'ArrowRight' ? 8 : -8));
});
}
applyRowNumberWidth(readStoredRowNumberWidth());
installRowNumberResizer();
// Every visible data column can be resized independently. Widths are
// stored per table and per field/index so they survive navigation,
// reloads and container updates. The visual row-number column keeps its
// dedicated implementation above.
const columnWidthStoragePrefix = `assetmanager:table:${rowNumberStoragePart}:column-width:`;
const minimumColumnWidth = 56;
const maximumColumnWidth = 1200;
function columnStorageId(header, columnIndex) {
const field = header?.dataset?.field;
if (field) return field;
return `index-${columnIndex}`;
}
function normalizeColumnWidth(width) {
return Math.min(maximumColumnWidth, Math.max(minimumColumnWidth, Math.round(width)));
}
function applyColumnWidth(columnIndex, width) {
const normalized = normalizeColumnWidth(width);
const rows = [
...thead.rows,
...tbody.rows
];
rows.forEach(row => {
const cell = row.cells?.[columnIndex];
if (!cell || cell.dataset.rowNumberColumn === '1') return;
cell.style.width = `${normalized}px`;
cell.style.minWidth = `${normalized}px`;
cell.style.maxWidth = `${normalized}px`;
});
return normalized;
}
function readStoredColumnWidth(header, columnIndex) {
try {
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
const stored = Number.parseInt(window.localStorage.getItem(key) || '', 10);
if (Number.isFinite(stored)) return normalizeColumnWidth(stored);
} catch (_) {}
return null;
}
function saveColumnWidth(header, columnIndex, width) {
const normalized = applyColumnWidth(columnIndex, width);
try {
const key = `${columnWidthStoragePrefix}${columnStorageId(header, columnIndex)}`;
window.localStorage.setItem(key, String(normalized));
} catch (_) {}
return normalized;
}
function applyStoredColumnWidths() {
[...headerRow.cells].forEach((header, columnIndex) => {
if (header.dataset.rowNumberColumn === '1') return;
const stored = readStoredColumnWidth(header, columnIndex);
if (stored !== null) applyColumnWidth(columnIndex, stored);
});
}
function installColumnResizers() {
[...headerRow.cells].forEach((header, columnIndex) => {
if (header.dataset.rowNumberColumn === '1') return;
if (header.dataset.noResize === '1') return;
if (header.querySelector(':scope > .table-column-resizer')) return;
header.classList.add('table-resizable-column-header');
const handle = document.createElement('span');
handle.className = 'table-column-resizer';
handle.setAttribute('role', 'separator');
handle.setAttribute('aria-orientation', 'vertical');
handle.tabIndex = 0;
handle.title = document.body.dataset.columnResizeLabel || 'Drag to change the column width';
header.appendChild(handle);
handle.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
});
handle.addEventListener('pointerdown', event => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const measured = header.getBoundingClientRect().width;
const startWidth = measured > 0 ? measured : minimumColumnWidth;
handle.setPointerCapture?.(event.pointerId);
document.documentElement.classList.add('table-column-resizing');
const move = moveEvent => {
applyColumnWidth(columnIndex, startWidth + moveEvent.clientX - startX);
};
const finish = finishEvent => {
const current = header.getBoundingClientRect().width || startWidth;
saveColumnWidth(header, columnIndex, current);
handle.releasePointerCapture?.(finishEvent.pointerId);
document.documentElement.classList.remove('table-column-resizing');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', finish);
handle.removeEventListener('pointercancel', finish);
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', finish);
handle.addEventListener('pointercancel', finish);
});
handle.addEventListener('keydown', event => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
event.stopPropagation();
const current = header.getBoundingClientRect().width || minimumColumnWidth;
saveColumnWidth(header, columnIndex, current + (event.key === 'ArrowRight' ? 12 : -12));
});
});
}
function ensureRowNumberCells() {
[...tbody.rows].forEach(row => {
if (row.querySelector('td[data-row-number-column="1"]')) return;
const cell = document.createElement('td');
cell.className = 'table-row-number-cell';
cell.dataset.rowNumberColumn = '1';
cell.setAttribute('aria-hidden', 'true');
row.insertBefore(cell, row.firstChild);
});
}
ensureRowNumberCells();
const isAssetTable = table.id === 'asset-table';
const usesServerFilters = table.dataset.serverFilters === '1';
let serverFilterTimer = null;
@@ -173,15 +388,19 @@
// real data-field keys on its movable columns; selection and action
// columns must remain without a field key.
if (!isAssetTable) {
[...headerRow.cells].forEach((header, index) => {
let syntheticIndex = 0;
[...headerRow.cells].forEach(header => {
if (header.dataset.rowNumberColumn === '1') return;
if (!header.dataset.field) {
header.dataset.field = `__col_${index}`;
header.dataset.field = `__col_${syntheticIndex}`;
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;
@@ -291,10 +510,12 @@
}
function rebuildMissingCellKeys() {
ensureRowNumberCells();
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;
@@ -405,6 +626,20 @@
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();
@@ -419,6 +654,7 @@
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,
@@ -447,6 +683,7 @@
});
updateFilterPresentation(active, visibleRows, tbody.rows.length);
updateRowNumbers();
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
detail: {
@@ -458,6 +695,43 @@
}));
}
function applySort(field, direction, options = {}) {
const header = [...headerRow.cells].find(item => item.dataset.field === field);
if (!header || header.dataset.noSort) return false;
const columnIndex = header.cellIndex;
[...headerRow.cells].forEach(item => {
delete item.dataset.sortDirection;
item.classList.remove('sort-asc', 'sort-desc');
});
const normalizedDirection = direction === 'desc' ? 'desc' : 'asc';
header.dataset.sortDirection = normalizedDirection;
header.classList.add(normalizedDirection === 'asc' ? 'sort-asc' : 'sort-desc');
const rows = [...tbody.rows];
rows.sort((left, right) => {
const definition = { field, columnIndex };
const result = compareValues(
textValue(cellForDefinition(left, definition)),
textValue(cellForDefinition(right, definition))
);
return normalizedDirection === 'asc' ? result : -result;
});
const fragment = document.createDocumentFragment();
rows.forEach(row => fragment.appendChild(row));
tbody.appendChild(fragment);
applyFilters();
if (!options.silent) {
window.dispatchEvent(new CustomEvent('asset-table-sort-changed', {
detail: { table, field, direction: normalizedDirection }
}));
}
return true;
}
[...headerRow.cells].forEach((header, columnIndex) => {
const field = header.dataset.field || '';
const serverName = header.dataset.serverFilterName || '';
@@ -468,37 +742,18 @@
header.addEventListener('click', event => {
if (event.target.closest('input,button,a,select,textarea,label')) return;
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
[...headerRow.cells].forEach(item => {
delete item.dataset.sortDirection;
item.classList.remove('sort-asc', 'sort-desc');
});
header.dataset.sortDirection = direction;
header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const rows = [...tbody.rows];
rows.sort((left, right) => {
const definition = { field, columnIndex };
const result = compareValues(
textValue(cellForDefinition(left, definition)),
textValue(cellForDefinition(right, definition))
);
return direction === 'asc' ? result : -result;
});
const fragment = document.createDocumentFragment();
rows.forEach(row => fragment.appendChild(row));
tbody.appendChild(fragment);
applyFilters();
applySort(field, direction);
});
}
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');
@@ -541,6 +796,8 @@
});
thead.appendChild(filterRow);
installColumnResizers();
applyStoredColumnWidths();
// Editable cells are always read live, so changed values immediately
// participate in filtering without a stale cache.
@@ -553,7 +810,10 @@
});
table.assetApplyFilters = applyFilters;
table.assetApplySort = (field, direction, options = {}) => applySort(field, direction, options);
table.assetUpdateRowNumbers = updateRowNumbers;
table.assetRebuildFilterCache = rebuildMissingCellKeys;
table.assetApplyStoredColumnWidths = applyStoredColumnWidths;
table.dataset.tableToolsReady = '1';
ensureFilterStatus();
@@ -586,7 +846,14 @@
const table = event.detail?.table;
if (table?.assetRebuildFilterCache) {
table.assetRebuildFilterCache();
table.assetApplyFilters?.();
const sortedHeader = table.tHead?.rows?.[0]?.querySelector('[data-sort-direction]');
if (sortedHeader?.dataset.field && table.assetApplySort) {
table.assetApplySort(sortedHeader.dataset.field, sortedHeader.dataset.sortDirection, {silent: true});
} else {
table.assetApplyFilters?.();
}
table.assetUpdateRowNumbers?.();
table.assetApplyStoredColumnWidths?.();
}
});
})();
+19 -1
View File
@@ -36,6 +36,24 @@
</div>
</div>
<div class="filters"><a href="/assets">{{ t('common.all', 'Alle') }}</a>{% for c in categories %}<a href="/assets?category_id={{ c.id }}">{{ c.name }}</a>{% endfor %}</div>
<div id="asset-context-filter-status" class="table-filter-status asset-context-filter-status{% if selected %} is-active{% endif %}" {% if not selected %}hidden{% endif %} role="status" aria-live="polite"
data-active-label="{{ t('assets.active_selection', 'Aktive Auswahl')|e }}"
data-duplicate-template="{{ t('duplicates.active_filter', 'Duplikate: {field}')|e }}">
<span class="table-filter-status-icon" aria-hidden="true"></span>
<strong class="table-filter-status-label">{{ t('assets.active_selection', 'Aktive Auswahl') }}</strong>
<span class="table-filter-status-details asset-context-filter-details">
{% if selected %}
<span class="asset-filter-chip" id="asset-category-filter-chip">
{{ t('assets.category_filter', 'Kategorie: {category}').format(category=selected.name) }}
<button type="button" class="asset-filter-chip-remove" id="asset-category-filter-remove" title="{{ t('assets.clear_category_filter', 'Kategoriefilter entfernen')|e }}" aria-label="{{ t('assets.clear_category_filter', 'Kategoriefilter entfernen')|e }}">×</button>
</span>
{% endif %}
<span class="asset-filter-chip" id="asset-duplicate-filter-chip" hidden>
<span id="asset-duplicate-filter-label"></span>
<button type="button" class="asset-filter-chip-remove" id="asset-duplicate-filter-remove" title="{{ t('duplicates.remove_filter', 'Dublettenfilter entfernen')|e }}" aria-label="{{ t('duplicates.remove_filter', 'Dublettenfilter entfernen')|e }}">×</button>
</span>
</span>
</div>
{% if job_filter %}
<div class="notice job-filter-summary">
<strong>{{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}:</strong>
@@ -193,7 +211,7 @@
</dialog>
{% endif %}
<dialog id="duplicate-search-dialog" class="duplicate-search-dialog">
<dialog id="duplicate-search-dialog" class="duplicate-search-dialog" data-result-summary-template="{{ t('duplicates.result_summary', '{records} Datensätze in {groups} Dublettengruppen gefunden.')|e }}">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ t('duplicates.search_title') }}</h2>
+8 -2
View File
@@ -14,7 +14,7 @@
--am-warning:{{ colors.get('warning_color', '#d97706') }};
--am-danger:{{ colors.get('danger_color', '#b42318') }};
}
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-row-number-label="{{ t('lists.row_number', 'Zeilennummer')|e }}" data-row-number-resize-label="{{ t('lists.row_number_resize', 'Ziehen, um die Breite der Nummernspalte zu ändern')|e }}" data-column-resize-label="{{ t('lists.column_resize', 'Ziehen, um die Spaltenbreite zu ändern')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
<header class="main-header">
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company"> {{ general.get('company_name') }}</span>{% endif %}</a>
@@ -33,6 +33,12 @@
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
<details class="user-menu"><summary>👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾</summary><div class="user-menu-panel">{% if session_user %}<a href="/profile">{{ t('app.profile') }}</a><a href="/logout">{{ t('app.logout') }}</a>{% else %}{% if auth.get('mode', 'none') == 'none' %}<span class="muted">Authentication disabled</span>{% endif %}<a href="/login">{{ t('app.login') }}</a>{% endif %}</div></details></div>
</header>
{% if auth.get('mode', 'none') == 'none' %}
<div class="authentication-disabled-banner" role="status">
<strong>⚠ {{ t('auth.disabled_banner_title') }}</strong>
<span>{{ t('auth.disabled_banner_text') }}</span>
</div>
{% endif %}
<dialog id="about-dialog" class="about-dialog">
<form method="dialog">
<div class="dialog-heading">
@@ -99,7 +105,7 @@
{% endif %}
</aside>
<main class="app-content">{% block content %}{% endblock %}</main>
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js"></script><script src="/static/js/asset-columns.js"></script><script src="/static/js/asset-duplicates.js"></script><script src="/static/js/toasts.js"></script><script>
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js?v={{ app_version }}"></script><script src="/static/js/asset-columns.js?v={{ app_version }}"></script><script src="/static/js/asset-duplicates.js?v={{ app_version }}"></script><script src="/static/js/toasts.js"></script><script>
(() => {
document.addEventListener('keydown', event => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
+54 -3
View File
@@ -28,16 +28,67 @@
<p class="muted">{{ t('mesh.asset_matching_help') }}</p>
<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="mac_address" {% if 'mac_address' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_mac') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="serial_number" {% if 'serial_number' in config.meshcentral.match_order %}checked{% endif %}> {{ t('mesh.match_serial') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="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>
<p class="muted">{{ t('mesh.match_mac_help') }} {{ t('mesh.match_serial_manufacturer_help') }}</p>
<div class="form-grid mesh-identity-settings">
<label>{{ t('mesh.invalid_serial_values') }}
<textarea name="invalid_serial_values" rows="6" placeholder="Unknown&#10;N/A&#10;To be filled by O.E.M.">{{ (config.meshcentral.get('invalid_serial_values', []) or []) | join('\n') }}</textarea>
<small class="muted">{{ t('mesh.invalid_serial_values_help') }}</small>
</label>
<label>{{ t('mesh.manufacturer_invalid_serial_rules') }}
<textarea name="manufacturer_invalid_serial_rules" rows="6" placeholder="Synology | 123456789&#10;Synology | Unknown">{% for rule in config.meshcentral.get('manufacturer_invalid_serial_rules', []) or [] %}{% for value in rule.get('values', []) or [] %}{{ rule.get('manufacturer', '') }} | {{ value }}{{ '\n' }}{% endfor %}{% endfor %}</textarea>
<small class="muted">{{ t('mesh.manufacturer_invalid_serial_rules_help') }}</small>
</label>
</div>
<div class="notice notice-info">{{ t('mesh.node_id_identity_help') }}</div>
<button class="button button-save">💾 {{ t('mesh.save_connection') }}</button>
</form>
</section>
<section class="panel">
<h2>2. {{ t('mesh.field_mappings') }}</h2>
<h2>2. {{ t('settings.mesh_mapping') }}</h2>
{% set mesh = config.meshcentral %}
{% set category_mapping = mesh.get('category_mapping', {}) %}
<p class="muted">{{ t('settings.mesh_mapping_help') }}</p>
<form method="post" action="/sync/meshcentral/categories/save" class="compact-form">
<label>{{ t('settings.fallback_category') }}
<select name="mesh_fallback_category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if category.id == (mesh.get('fallback_category_id', 1)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
<div class="form-grid">
{% set mesh_types = [
('1', 'settings.mesh_type_desktop'),
('2', 'settings.mesh_type_notebook'),
('3', 'settings.mesh_type_mobile'),
('4', 'settings.mesh_type_server'),
('5', 'settings.mesh_type_5'),
('6', 'settings.mesh_type_6'),
('7', 'settings.mesh_type_7'),
('8', 'settings.mesh_type_linux_other')
] %}
{% for type_id, type_label in mesh_types %}
<label>{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})
<select name="mesh_category_{{ type_id }}">
<option value="">{{ t('settings.use_fallback') }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if category_mapping.get(type_id) is not none and category.id == (category_mapping.get(type_id)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
{% endfor %}
</div>
<button class="button button-save">💾 {{ t('settings.save_mesh_mapping') }}</button>
</form>
</section>
<section class="panel">
<h2>3. {{ t('mesh.field_mappings') }}</h2>
<p class="muted">{{ t('mesh.mappings_help') }} {{ t('mesh.mappings_examples') }}: <code>node.osdesc</code>, <code>sys.hardware.windows.cpu[].Name</code>, <code>node.name|name</code>.</p>
<form method="post" action="/sync/meshcentral/mappings/save">
<div class="table-scroll management-table-scroll"><table class="data-table mesh-mapping-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('mesh.priority') }}</th><th>{{ t('mesh.target_field') }}</th><th>Source Path</th><th>{{ t('mesh.transformation') }}</th><th>{{ t('mesh.multi_values') }}</th><th>{{ t('mesh.separator') }}</th><th>{{ t('mesh.update_rule') }}</th><th>{{ t('common.description') }}</th><th></th></tr></thead><tbody>
@@ -61,7 +112,7 @@
</section>
<section class="panel">
<h2>3. {{ t('mesh.new_mapping') }}</h2>
<h2>4. {{ t('mesh.new_mapping') }}</h2>
<p class="muted">{{ t('mesh.new_mapping_help') }}</p>
<form method="post" action="/sync/meshcentral/mappings/new" class="compact-form">
<div class="form-grid">
-35
View File
@@ -147,41 +147,6 @@
.inventory-tab { color: #000; }">{{ settings.general.get('custom_css', '') }}</textarea>
</label>
</section>
<section class="settings-section" data-settings-section="meshcentral">
<h2>{{ t('settings.mesh_mapping') }}</h2>
{% set mesh = settings.meshcentral %}
{% set mapping = mesh.get('category_mapping', {}) %}
<p class="muted">{{ t('settings.mesh_mapping_help') }}</p>
<label>{{ t('settings.fallback_category') }}
<select name="mesh_fallback_category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if category.id == (mesh.get('fallback_category_id', 1)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
<div class="form-grid">
{% set mesh_types = [
('1', 'settings.mesh_type_desktop'),
('2', 'settings.mesh_type_notebook'),
('3', 'settings.mesh_type_mobile'),
('4', 'settings.mesh_type_server'),
('5', 'settings.mesh_type_5'),
('6', 'settings.mesh_type_6'),
('7', 'settings.mesh_type_7'),
('8', 'settings.mesh_type_linux_other')
] %}
{% for type_id, type_label in mesh_types %}
<label>{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})
<select name="mesh_category_{{ type_id }}">
<option value="">{{ t('settings.use_fallback') }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if mapping.get(type_id) is not none and category.id == (mapping.get(type_id)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
{% endfor %}
</div>
</section>
<section class="settings-section" data-settings-section="authentication">
<h2>{{ t('settings.authentication') }}</h2>
<p class="muted">{{ t('settings.auth_help') }}</p>
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.42"
APP_VERSION = "0.5.5.52"
__version__ = APP_VERSION