diff --git a/README.md b/README.md index 7e58159..1080668 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.52 +# AssetManager 0.5.5.59 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral. @@ -130,3 +130,6 @@ Contributions and bug reports are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md Copyright © 2026 Roland Reich Licensed under the Apache License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for the complete license text. + + +Reusable asset/category images are persisted in `./data/uploads/library` and mounted into the application container. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 50a1eae..06f0c7f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -35,3 +35,7 @@ Product names and trademarks belong to their respective owners. Their mention on ## Review when dependencies change Review license terms and required notices whenever a dependency is added or updated. Automatically collected metadata does not replace review of the original license texts. + +## MeshCentral device icon assets + +AssetManager redistributes the device icon image files `icons256-1-1.png` through `icons256-8-1.png` from the MeshCentral repository (`public/images/`). These files are used only to visualize the MeshCentral `node.icon` / agent `platformType` value. MeshCentral is licensed under the Apache License 2.0. Copyright and trademark rights remain with their respective owners. diff --git a/VERSION b/VERSION index 23903a9..8b1ae97 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.52 +0.5.5.63 diff --git a/app/fields.py b/app/fields.py index 6eb16de..f3a057a 100644 --- a/app/fields.py +++ b/app/fields.py @@ -30,11 +30,12 @@ ASSET_FIELDS = { "mesh_group": "MeshCentral-Gruppe", "mesh_node_id": "MeshCentral Node-ID", "mesh_online": "Online", - "mesh_mtype": "MeshCentral device type (mtype)", + "mesh_mtype": "MeshCentral group type (mtype)", + "mesh_icon": "MeshCentral device icon", } DEFAULT_FIELDS = ["asset_tag", "name", "manufacturer", "model", "serial_number", "status", "assigned_to", "assigned_on", "location", "room_number", "notes"] -COMPUTER_FIELDS = DEFAULT_FIELDS + ["hostname", "operating_system", "os_patch_level", "mac_address", "ip_address", "cpu", "memory_gb", "memory_details", "storage_gb", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group", "mesh_node_id", "mesh_online", "mesh_mtype"] +COMPUTER_FIELDS = DEFAULT_FIELDS + ["hostname", "operating_system", "os_patch_level", "mac_address", "ip_address", "cpu", "memory_gb", "memory_details", "storage_gb", "storage_details", "gpu_name", "tpm_version", "antivirus", "mesh_group", "mesh_node_id", "mesh_online", "mesh_mtype", "mesh_icon"] FIELD_DATA_TYPES = { "short_text": "Short text", @@ -53,4 +54,4 @@ FIELD_DATA_TYPES = { "status": "Status", } -SYSTEM_READONLY_FIELDS = {"mesh_node_id", "mesh_online", "mesh_mtype"} +SYSTEM_READONLY_FIELDS = {"mesh_node_id", "mesh_online", "mesh_mtype", "mesh_icon"} diff --git a/app/i18n.py b/app/i18n.py index 63397e0..6b2d311 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -144,7 +144,8 @@ BASE_TRANSLATIONS = { "field.gpu_name": ("Graphics adapter", "Grafikkarte"), "field.tpm_version": ("TPM version", "TPM-Version"), "field.memory_details": ("Memory modules", "Arbeitsspeicher-Module"), "field.antivirus": ("Active antivirus", "Aktiver Virenschutz"), "field.mesh_group": ("MeshCentral group", "MeshCentral-Gruppe"), "field.mesh_node_id": ("MeshCentral node ID", "MeshCentral Node-ID"), -"field.mesh_mtype": ("MeshCentral device type (mtype)", "MeshCentral-Gerätetyp (mtype)"), +"field.mesh_mtype": ("MeshCentral group type (mtype)", "MeshCentral-Gruppentyp (mtype)"), +"field.mesh_icon": ("MeshCentral device icon", "MeshCentral-Geräte-Icon"), "common.close": ("Close", "Schließen"), "common.loading": ("Loading…", "Lade…"), @@ -463,13 +464,13 @@ BASE_TRANSLATIONS.update({ BASE_TRANSLATIONS.update({ "settings.mesh_type_desktop": ("Desktop / PC", "Desktop / PC"), "settings.mesh_type_notebook": ("Notebook / laptop", "Notebook / Laptop"), - "settings.mesh_type_mobile": ("Mobile device", "Mobilgerät"), - "settings.mesh_type_server": ("Server", "Server"), - "settings.mesh_type_5": ("Device type 5", "Gerätetyp 5"), - "settings.mesh_type_6": ("Device type 6", "Gerätetyp 6"), - "settings.mesh_type_7": ("Device type 7", "Gerätetyp 7"), - "settings.mesh_type_linux_other": ("Linux / other device", "Linux / sonstiges Gerät"), - "settings.meshcentral_type": ("MeshCentral type", "MeshCentral-Typ"), + "settings.mesh_type_mobile": ("Tablet / mobile device", "Tablet / Mobilgerät"), + "settings.mesh_type_server": ("Server / appliance", "Server / Appliance"), + "settings.mesh_type_5": ("Embedded / appliance", "Embedded / Appliance"), + "settings.mesh_type_6": ("Network device / appliance", "Netzwerkgerät / Appliance"), + "settings.mesh_type_7": ("Raspberry Pi", "Raspberry Pi"), + "settings.mesh_type_linux_other": ("Display / terminal", "Display / Terminal"), + "settings.meshcentral_type": ("MeshCentral icon", "MeshCentral-Icon"), "settings.ldap_server": ("LDAP server", "LDAP-Server"), "settings.port": ("Port", "Port"), "settings.use_ssl": ("Use SSL/LDAPS", "SSL/LDAPS verwenden"), @@ -481,6 +482,29 @@ BASE_TRANSLATIONS.update({ "settings.display_name_attribute": ("Display name attribute", "Attribut Anzeigename"), "settings.email_attribute": ("Email attribute", "Attribut E-Mail"), "settings.bind_password_help": ("The bind password remains outside the config, for example in .env as LDAP_BIND_PASSWORD=...", "Das Bind-Passwort bleibt außerhalb der Config, z. B. in .env als LDAP_BIND_PASSWORD=..."), + "settings.mesh_icon_mapping_help": ("The MeshCentral device icon (node.icon/platformType) is used for category selection when a new asset is created. Existing assets are not recategorized automatically.", "Für die Kategorieauswahl bei neu angelegten Assets wird das MeshCentral-Geräte-Icon (node.icon/platformType) verwendet. Bestehende Assets werden nicht automatisch umkategorisiert."), + "assets.device_visual": ("Device", "Gerät"), + "assets.current_image": ("Current asset image", "Aktuelles Assetbild"), + "assets.remove_image": ("Remove asset image", "Assetbild entfernen"), + "assets.image_remove_pending": ("The asset image will be removed when you save.", "Assetbild wird beim Speichern entfernt."), + "assets.image_replace_help": ("A newly selected image replaces the current asset image.", "Ein neu ausgewähltes Bild ersetzt das vorhandene Assetbild."), + "assets.visual_source": ("Display source", "Anzeigequelle"), + "assets.visual_source.auto": ("Automatic: asset image → category image → MeshCentral icon", "Automatisch: Assetbild → Kategoriebild → MeshCentral-Icon"), + "assets.visual_source.asset_image": ("Asset image only", "Nur Assetbild"), + "assets.visual_source.category_image": ("Category image", "Kategoriebild"), + "assets.visual_source.mesh_icon": ("MeshCentral icon", "MeshCentral-Icon"), + "assets.visual_source.help": ("Selects which image or icon is preferred for this asset in lists and details.", "Legt fest, welches Bild oder Icon für dieses Asset in Listen und Details bevorzugt angezeigt wird."), + "categories.image_help": ("This image is used as the category icon when an asset does not use its own image.", "Dieses Bild wird als Kategorie-Icon verwendet, wenn ein Asset kein eigenes Bild verwendet."), + "categories.current_image": ("Current category image", "Aktuelles Kategoriebild"), + "categories.remove_image": ("Remove category image", "Kategoriebild entfernen"), + "categories.image_remove_pending": ("The category image will be removed when you save.", "Kategoriebild wird beim Speichern entfernt."), + "images.direct_upload": ("Upload individual image", "Eigenes Bild hochladen"), + "images.library": ("Standard image library", "Standardbild-Bibliothek"), + "images.library_help": ("Select an existing standard image or add an image to the library once.", "Wähle ein bereits vorhandenes Standardbild oder füge ein Bild einmalig zur Bibliothek hinzu."), + "images.library_empty": ("No standard images available yet.", "Noch keine Standardbilder vorhanden."), + "images.library_upload": ("Add new standard image and use it", "Neues Standardbild hinzufügen und verwenden"), + "images.library_upload_help": ("The image is stored once in the shared library and can then be reused for other assets and categories.", "Das Bild wird einmal in der gemeinsamen Bibliothek gespeichert und kann danach bei weiteren Assets und Kategorien wiederverwendet werden."), + "assets.mesh_icon_title": ("MeshCentral device icon {icon}", "MeshCentral-Geräte-Icon {icon}"), }) diff --git a/app/main.py b/app/main.py index aee7dc8..231e23a 100644 --- a/app/main.py +++ b/app/main.py @@ -61,6 +61,9 @@ from openpyxl.styles import Font, PatternFill, Alignment BASE_DIR = Path(__file__).resolve().parent UPLOAD_DIR = BASE_DIR / "static" / "uploads" UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(BASE_DIR / "static" / "uploads" / "library"))) +STANDARD_IMAGE_DIR.mkdir(parents=True, exist_ok=True) +STANDARD_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync")) LOG_DIR.mkdir(parents=True, exist_ok=True) APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) @@ -1308,7 +1311,7 @@ def _seed_field_definitions(db: Session) -> None: if not item: item = FieldDefinition( field_name=field_name, label=label, description=None, - data_type=("boolean" if field_name == "mesh_online" else "integer" if field_name == "mesh_mtype" else "long_text" if field_name in {"notes","storage_details","memory_details","gpu_name","antivirus"} else "date" if field_name in {"purchase_date","warranty_until","assigned_on"} else "status" if field_name == "status" else "ip" if field_name == "ip_address" else "short_text"), + data_type=("boolean" if field_name == "mesh_online" else "integer" if field_name in {"mesh_mtype", "mesh_icon"} else "long_text" if field_name in {"notes","storage_details","memory_details","gpu_name","antivirus"} else "date" if field_name in {"purchase_date","warranty_until","assigned_on"} else "status" if field_name == "status" else "ip" if field_name == "ip_address" else "short_text"), readonly=field_name in SYSTEM_READONLY_FIELDS, is_system=True, translation_key=f"field.{field_name}", sort_order=order, excel_import=field_name not in SYSTEM_READONLY_FIELDS, @@ -1360,7 +1363,8 @@ DEFAULT_MESH_FIELD_MAPPINGS = [ ("sys.hardware.tpm.SpecVersion", "tpm_version", "string", "first", "\n", True, 170, "TPM-Spezifikationsversion"), ("node.av|av", "antivirus", "active_antivirus", "first", "\n", True, 180, "Aktive Antivirenprodukte"), ("node.groupname|groupname", "mesh_group", "string", "first", "\n", True, 190, "MeshCentral-Gerätegruppe"), - ("node.mtype|node.icon|mtype|icon", "mesh_mtype", "integer", "first", "\n", True, 200, "MeshCentral Gerätetyp (mtype)"), + ("node.mtype|mtype", "mesh_mtype", "integer", "first", "\n", True, 200, "MeshCentral Gruppentyp (mtype)"), + ("node.icon|icon", "mesh_icon", "integer", "first", "\n", True, 205, "MeshCentral Geräte-Icon (platformType)"), ] @@ -1371,6 +1375,14 @@ def _seed_mesh_field_mappings(db: Session) -> None: for row in rows: by_target.setdefault(row.target_field_name, []).append(row) + # Repair the historical system mapping that mixed group mtype and device icon. + # Existing user-created mappings are left untouched. + for row in by_target.get("mesh_mtype", []): + if row.is_system and row.source_path == "node.mtype|node.icon|mtype|icon": + row.source_path = "node.mtype|mtype" + row.description = "MeshCentral Gruppentyp (mtype)" + db.flush() + # Import legacy config mappings only when that exact database mapping does # not already exist. Existing database rows remain the source of truth. config_mappings = load_config().get("meshcentral", {}).get("field_mappings", []) or [] @@ -1798,12 +1810,20 @@ def delete_asset_image(image_path: str | None) -> None: pass -def save_upload(upload: UploadFile | None) -> str | None: +def _validate_image_upload(upload: UploadFile | None) -> tuple[str, str] | None: if not upload or not upload.filename: return None suffix = Path(upload.filename).suffix.lower() - if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: + if suffix not in STANDARD_IMAGE_EXTENSIONS: raise HTTPException(400, "Nur PNG, JPG, WEBP oder GIF sind erlaubt.") + return Path(upload.filename).stem, suffix + + +def save_upload(upload: UploadFile | None) -> str | None: + validated = _validate_image_upload(upload) + if not validated: + return None + _, suffix = validated filename = f"{uuid.uuid4().hex}{suffix}" target = UPLOAD_DIR / filename with target.open("wb") as buffer: @@ -1811,6 +1831,68 @@ def save_upload(upload: UploadFile | None) -> str | None: return f"/static/uploads/{filename}" +def _standard_image_url(filename: str) -> str: + return "/static/uploads/library/" + urllib.parse.quote(filename) + + +def list_standard_images() -> list[dict[str, str]]: + """Return reusable images stored in the persistent standard image library.""" + images: list[dict[str, str]] = [] + try: + candidates = sorted(STANDARD_IMAGE_DIR.iterdir(), key=lambda item: item.name.casefold()) + except OSError: + return images + for path in candidates: + if not path.is_file() or path.suffix.lower() not in STANDARD_IMAGE_EXTENSIONS: + continue + images.append({"name": path.name, "path": _standard_image_url(path.name)}) + return images + + +def resolve_standard_image(value: Any) -> str | None: + """Accept only image paths currently present in the standard image library.""" + requested = str(value or "").strip() + if not requested: + return None + allowed = {item["path"] for item in list_standard_images()} + if requested not in allowed: + raise HTTPException(400, "Ungültiges Standardbild ausgewählt.") + return requested + + +def save_standard_image(upload: UploadFile | None) -> str | None: + """Store an image once in the reusable standard image library. + + Existing files are never overwritten. If the original file name already + exists, a numeric suffix is appended (for example switch-1.png). + """ + validated = _validate_image_upload(upload) + if not validated: + return None + stem, suffix = validated + safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("-._") or "image" + filename = f"{safe_stem}{suffix}" + counter = 1 + while (STANDARD_IMAGE_DIR / filename).exists(): + filename = f"{safe_stem}-{counter}{suffix}" + counter += 1 + target = STANDARD_IMAGE_DIR / filename + with target.open("wb") as buffer: + shutil.copyfileobj(upload.file, buffer) + return _standard_image_url(filename) + + +def choose_form_image(form: Any, upload: UploadFile | None) -> str | None: + """Resolve image input precedence: direct upload, new library upload, library selection.""" + direct = save_upload(upload) + if direct: + return direct + library_upload = form.get("library_upload") + if library_upload is not None and getattr(library_upload, "filename", None): + return save_standard_image(library_upload) + return resolve_standard_image(form.get("library_image")) + + def _display_value(value) -> str: if value is None: return "" @@ -2860,7 +2942,7 @@ def asset_new(request: Request, category_id: int | None = None, db: Session = De _require_admin(request) categories = db.query(Category).options(joinedload(Category.visible_fields)).all() selected = next((c for c in categories if c.id == category_id), categories[0] if categories else None) - return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}}) + return templates.TemplateResponse("asset_form.html", {"request": request, "asset": None, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).order_by(Asset.name).all(), "custom_values": {}, "standard_images": list_standard_images()}) @app.get("/assets/{asset_id}/duplicate") @@ -2878,9 +2960,10 @@ def asset_duplicate(asset_id: int, request: Request, db: Session = Depends(get_d duplicate.asset_tag = None duplicate.mesh_node_id = None duplicate.name = f"{original.name} – Kopie" + duplicate.visual_source = getattr(original, "visual_source", "auto") or "auto" return templates.TemplateResponse("asset_form.html", { "request": request, "asset": duplicate, "categories": categories, "selected": original.category, - "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": True, "parent_candidates": db.query(Asset).filter(Asset.id != original.id).order_by(Asset.name).all(), "custom_values": {}, + "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": True, "parent_candidates": db.query(Asset).filter(Asset.id != original.id).order_by(Asset.name).all(), "custom_values": {}, "standard_images": list_standard_images(), }) @@ -2905,7 +2988,10 @@ async def asset_create(request: Request, category_id: int = Form(...), image: Up if not data.get("name"): raise HTTPException(400, "Bezeichnung fehlt") _validate_asset_identifiers(db, asset_tag=data.get("asset_tag"), manufacturer=data.get("manufacturer"), serial_number=data.get("serial_number")) - asset = Asset(category_id=category_id, image_path=save_upload(image), **data) + visual_source = str(form.get("visual_source") or "auto").strip().lower() + if visual_source not in {"auto", "asset_image", "category_image", "mesh_icon"}: + visual_source = "auto" + asset = Asset(category_id=category_id, image_path=choose_form_image(form, image), visual_source=visual_source, **data) db.add(asset) try: db.flush() @@ -3732,7 +3818,7 @@ def asset_edit(asset_id: int, request: Request, category_id: int | None = None, selected = next((category for category in categories if category.id == category_id), None) if category_id else asset.category if selected is None: selected = asset.category - return templates.TemplateResponse("asset_form.html", {"request": request, "asset": asset, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).filter(Asset.id != asset.id).order_by(Asset.name).all(), "custom_values": {f.field_name: _custom_value_get(asset, f.definition, db) for f in selected.visible_fields if f.definition and not f.definition.is_system}}) + return templates.TemplateResponse("asset_form.html", {"request": request, "asset": asset, "categories": categories, "selected": selected, "fields": ASSET_FIELDS, "status_options": _active_status_options(db, request), "duplicate_mode": False, "parent_candidates": db.query(Asset).filter(Asset.id != asset.id).order_by(Asset.name).all(), "custom_values": {f.field_name: _custom_value_get(asset, f.definition, db) for f in selected.visible_fields if f.definition and not f.definition.is_system}, "standard_images": list_standard_images()}) @app.post("/assets/{asset_id}/edit") @@ -3834,11 +3920,33 @@ async def asset_update(asset_id: int, request: Request, category_id: int = Form( changes[_definition_label(field)] = {"old": _display_value(old_custom), "new": _display_value(new_custom)} _custom_value_set(asset, field.definition, new_custom, db) _validate_asset_identifiers(db, asset_tag=asset.asset_tag, manufacturer=asset.manufacturer, serial_number=asset.serial_number, exclude_id=asset.id) - new_image = save_upload(image) + visual_source = str(form.get("visual_source") or "auto").strip().lower() + if visual_source not in {"auto", "asset_image", "category_image", "mesh_icon"}: + visual_source = "auto" + old_visual_source = getattr(asset, "visual_source", "auto") or "auto" + if old_visual_source != visual_source: + changes[_translate_request(request, "assets.visual_source", "Display source")] = { + "old": _translate_request(request, f"assets.visual_source.{old_visual_source}", old_visual_source), + "new": _translate_request(request, f"assets.visual_source.{visual_source}", visual_source), + } + asset.visual_source = visual_source + remove_image_requested = str(form.get("remove_image") or "").strip().lower() in {"1", "true", "on", "yes"} + new_image = choose_form_image(form, image) + image_label = _translate_request(request, "assets.image", "Image") if new_image: - if asset.image_path != new_image: - changes["Bild"] = {"old": asset.image_path or "", "new": new_image} + old_image = asset.image_path + if old_image != new_image: + changes[image_label] = {"old": old_image or "", "new": new_image} asset.image_path = new_image + # Do not delete the previous upload here. Image files can be referenced + # by more than one record (for example after duplication). Orphaned + # uploads are intentionally left for a later maintenance/cleanup task. + elif remove_image_requested and asset.image_path: + old_image = asset.image_path + changes[image_label] = {"old": old_image, "new": ""} + asset.image_path = None + # Only remove the association. The physical file may still be referenced + # elsewhere and is cleaned up separately by maintenance tooling. try: _record_asset_history(db, asset, changes, source="manual-update", changed_by=_changed_by(request)) db.commit() @@ -4238,12 +4346,11 @@ def assets_bulk_delete(request: Request, asset_ids: list[str] = Form([]), catego assets = db.query(Asset).filter(Asset.id.in_(ids)).all() if len(assets) != len(ids): raise HTTPException(404, "Mindestens ein ausgewähltes Asset wurde nicht gefunden.") - image_paths = [asset.image_path for asset in assets if asset.image_path] for asset in assets: db.delete(asset) db.commit() - for image_path in image_paths: - delete_asset_image(image_path) + # Uploaded image files are intentionally retained. They may be shared by + # duplicated records and can be removed later by an orphan cleanup task. target = f"/assets?category_id={category_id}" if category_id else "/assets" return RedirectResponse(f"{target}{'&' if '?' in target else '?'}toast_success={quote(str(len(assets)) + ' Assets gelöscht')}", status_code=303) @@ -4253,10 +4360,10 @@ def asset_delete(asset_id: int, request: Request, db: Session = Depends(get_db)) _require_admin(request) asset = _get_visible_asset(db, request, asset_id) - image_path = asset.image_path db.delete(asset) db.commit() - delete_asset_image(image_path) + # Keep the uploaded image file. It may still be referenced by another + # record; orphan cleanup is handled separately. return RedirectResponse("/assets", status_code=303) @@ -4614,14 +4721,14 @@ def categories_activate_default_fields( def category_new(request: Request, db: Session = Depends(get_db)): _require_admin(request) definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() - return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "definitions": definitions, "selected_fields": {}}) + return templates.TemplateResponse("category_form.html", {"request": request, "category": None, "definitions": definitions, "selected_fields": {}, "standard_images": list_standard_images()}) @app.post("/categories/new") async def category_create(request: Request, name: str = Form(...), description: str = Form(""), image: UploadFile | None = File(None), db: Session = Depends(get_db)): _require_admin(request) form = await request.form() - category = Category(name=name.strip(), description=description or None, image_path=save_upload(image)) + category = Category(name=name.strip(), description=description or None, image_path=choose_form_image(form, image)) db.add(category) db.flush() definitions = db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order).all() @@ -4644,7 +4751,7 @@ def category_edit(category_id: int, request: Request, db: Session = Depends(get_ raise HTTPException(404, "Kategorie nicht gefunden") selected_fields = {f.field_name: f for f in category.visible_fields} definitions=db.query(FieldDefinition).filter(FieldDefinition.is_active.is_(True)).order_by(FieldDefinition.sort_order, FieldDefinition.label).all() - return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "definitions": definitions, "selected_fields": selected_fields}) + return templates.TemplateResponse("category_form.html", {"request": request, "category": category, "definitions": definitions, "selected_fields": selected_fields, "standard_images": list_standard_images()}) @app.post("/categories/{category_id}/edit") @@ -4656,9 +4763,18 @@ async def category_update(category_id: int, request: Request, name: str = Form(. form = await request.form() category.name = name.strip() category.description = description.strip() or None - new_image = save_upload(image) + remove_image_requested = str(form.get("remove_image") or "").strip().lower() in {"1", "true", "on", "yes"} + new_image = choose_form_image(form, image) if new_image: + old_image = category.image_path category.image_path = new_image + # Category images can be shared after duplicating a category. Replacing + # the association must therefore never delete the old physical file. + elif remove_image_requested and category.image_path: + old_image = category.image_path + category.image_path = None + # Only remove the category association. Shared/orphaned uploads are + # deliberately retained for a later maintenance cleanup. # Explicitly delete and flush existing definitions. This prevents # unique-constraint violations when identical field names are inserted afterwards. db.query(CategoryField).filter(CategoryField.category_id == category.id).delete(synchronize_session=False) diff --git a/app/meshcentral.py b/app/meshcentral.py index 19e5262..d3769f7 100644 --- a/app/meshcentral.py +++ b/app/meshcentral.py @@ -739,7 +739,7 @@ def _custom_value(row: AssetFieldValue | None, definition: FieldDefinition) -> A def _coerce_system_value(target: str, value: Any) -> Any: # Most asset columns are text fields. Integer and foreign-key fields # are written with the appropriate type so dynamic mappings remain safe there as well. - if target in {"mesh_mtype", "parent_asset_id", "category_id"}: + if target in {"mesh_mtype", "mesh_icon", "parent_asset_id", "category_id"}: try: return int(value) if value not in (None, "") else None except (TypeError, ValueError): @@ -1165,8 +1165,8 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d if asset is None: if not cfg.get("create_missing_assets", True): continue - device_type = str(mapped.get("mesh_mtype") or 0) - mapped_category_id = category_mapping.get(device_type) + device_icon = str(mapped.get("mesh_icon") or 0) + mapped_category_id = category_mapping.get(device_icon) try: mapped_category_id = int(mapped_category_id) if mapped_category_id not in (None, "") else None except (TypeError, ValueError): @@ -1174,7 +1174,7 @@ def synchronize(db: Session, log: Callable[[str, str], None] | None = None, *, d category = db.query(Category).filter(Category.id == mapped_category_id).first() if mapped_category_id else None if category is None: category = fallback_category - log("warning", f"{mapped.get('name', node_id)}: keine gültige Kategorie für MeshCentral-Typ {device_type}; Fallback '{category.name}' (ID {category.id}) verwendet.") + log("warning", f"{mapped.get('name', node_id)}: keine gültige Kategorie für MeshCentral-Icon {device_icon}; Fallback '{category.name}' (ID {category.id}) verwendet.") asset = Asset(category_id=category.id, name=mapped.get("name", node_id), mesh_node_id=node_id) db.add(asset) run.created_count += 1 diff --git a/app/migrations.py b/app/migrations.py index c5af32e..6d20430 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -88,6 +88,8 @@ ASSET_COLUMNS = { "room_number": "VARCHAR(80) NULL", "parent_asset_id": "INTEGER NULL REFERENCES assets(id) ON DELETE SET NULL", "mesh_mtype": "INTEGER NULL", + "mesh_icon": "INTEGER NULL", + "visual_source": "VARCHAR(24) NOT NULL DEFAULT 'auto'", } @@ -106,6 +108,7 @@ def apply_lightweight_migrations() -> None: connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_room_number ON assets (room_number)")) connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_parent_asset_id ON assets (parent_asset_id)")) connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_mtype ON assets (mesh_mtype)")) + connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_icon ON assets (mesh_icon)")) inspector = inspect(engine) with engine.begin() as connection: if "users" in inspector.get_table_names(): diff --git a/app/models.py b/app/models.py index e167be9..059df6e 100644 --- a/app/models.py +++ b/app/models.py @@ -95,6 +95,7 @@ class Asset(Base): warranty_until: Mapped[str | None] = mapped_column(String(20), nullable=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) image_path: Mapped[str | None] = mapped_column(String(255), nullable=True) + visual_source: Mapped[str] = mapped_column(String(24), default="auto") hostname: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True) operating_system: Mapped[str | None] = mapped_column(String(150), nullable=True) @@ -111,6 +112,7 @@ class Asset(Base): antivirus: Mapped[str | None] = mapped_column(Text, nullable=True) mesh_group: Mapped[str | None] = mapped_column(String(180), nullable=True) mesh_mtype: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + mesh_icon: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) mesh_node_id: Mapped[str | None] = mapped_column(String(180), unique=True, nullable=True, index=True) mesh_last_sync: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/static/css/app.css b/app/static/css/app.css index 1535c5e..17d20c7 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -1,4 +1,4 @@ -:root{font-family:Arial,sans-serif;color:#222;background:#f3f5f7}*{box-sizing:border-box}body{margin:0}header{height:58px;background:#036;color:#fff;display:flex;align-items:center;padding:0 24px;gap:36px}.brand{font-size:22px;font-weight:bold}nav{display:flex;gap:20px}header a{color:#fff;text-decoration:none}main{width:100%;max-width:none;margin:24px 0;padding:0 24px}.toolbar{display:flex;justify-content:space-between;align-items:center}.button,button{background:#0878bd;color:#fff;border:0;border-radius:4px;padding:10px 15px;text-decoration:none;cursor:pointer}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.card{display:flex;gap:14px;background:#fff;border:1px solid #d8dde2;border-radius:6px;padding:14px;text-decoration:none;color:#222}.card img,.placeholder{width:58px;height:58px;object-fit:cover;border-radius:5px}.placeholder{display:grid;place-items:center;background:#dbeaf3;font-size:26px}.card small{display:block;color:#667;margin-top:8px}table{width:100%;border-collapse:collapse;background:#fff}th,td{text-align:left;border-bottom:1px solid #ddd;padding:10px}th{background:#e8edf1}a{color:#056ca8}.filters{display:flex;gap:10px;margin:12px 0 18px;flex-wrap:wrap}.filters a{background:#fff;border:1px solid #ccd4da;padding:7px 10px;border-radius:4px;text-decoration:none}form{background:#fff;padding:20px;border:1px solid #d8dde2;border-radius:6px}label{display:block;margin-bottom:14px;font-weight:bold}input,select,textarea{display:block;width:100%;padding:9px;margin-top:5px;border:1px solid #bbb;border-radius:4px;font:inherit}textarea{min-height:100px}.detail{display:grid;grid-template-columns:280px 1fr;gap:24px;background:#fff;padding:20px}.asset-image{max-width:100%;max-height:260px;object-fit:contain}dl{display:grid;grid-template-columns:220px 1fr;margin:0}dt,dd{padding:9px;border-bottom:1px solid #eee;margin:0}dt{font-weight:bold}@media(max-width:700px){.detail{grid-template-columns:1fr}dl{grid-template-columns:1fr}header{padding:0 10px;gap:12px}nav{gap:9px}} +:root{font-family:Arial,sans-serif;color:#222;background:#f3f5f7}*{box-sizing:border-box}body{margin:0}header{height:58px;background:#036;color:#fff;display:flex;align-items:center;padding:0 24px;gap:36px}.brand{font-size:22px;font-weight:bold}nav{display:flex;gap:20px}header a{color:#fff;text-decoration:none}main{width:100%;max-width:none;margin:24px 0;padding:0 24px}.toolbar{display:flex;justify-content:space-between;align-items:center}.button,button{background:#0878bd;color:#fff;border:0;border-radius:4px;padding:10px 15px;text-decoration:none;cursor:pointer}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px}.card{display:flex;gap:14px;background:#fff;border:1px solid #d8dde2;border-radius:6px;padding:14px;text-decoration:none;color:#222}.card img,.placeholder{width:58px;height:58px;object-fit:contain;border-radius:5px}.card img{flex:0 0 58px;padding:2px;background:transparent}.placeholder{display:grid;place-items:center;background:#dbeaf3;font-size:26px}.card small{display:block;color:#667;margin-top:8px}table{width:100%;border-collapse:collapse;background:#fff}th,td{text-align:left;border-bottom:1px solid #ddd;padding:10px}th{background:#e8edf1}a{color:#056ca8}.filters{display:flex;gap:10px;margin:12px 0 18px;flex-wrap:wrap}.filters a{background:#fff;border:1px solid #ccd4da;padding:7px 10px;border-radius:4px;text-decoration:none}form{background:#fff;padding:20px;border:1px solid #d8dde2;border-radius:6px}label{display:block;margin-bottom:14px;font-weight:bold}input,select,textarea{display:block;width:100%;padding:9px;margin-top:5px;border:1px solid #bbb;border-radius:4px;font:inherit}textarea{min-height:100px}.detail{display:grid;grid-template-columns:280px 1fr;gap:24px;background:#fff;padding:20px}.asset-image{max-width:100%;max-height:260px;object-fit:contain}dl{display:grid;grid-template-columns:220px 1fr;margin:0}dt,dd{padding:9px;border-bottom:1px solid #eee;margin:0}dt{font-weight:bold}@media(max-width:700px){.detail{grid-template-columns:1fr}dl{grid-template-columns:1fr}header{padding:0 10px;gap:12px}nav{gap:9px}} .panel{background:#fff;border:1px solid #ccd4dc;border-radius:4px;padding:18px;margin-top:18px;overflow:auto}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.card{background:#fff;border:1px solid #ccd4dc;padding:16px;display:flex;flex-direction:column}.card strong{font-size:28px}.config-list{display:grid;grid-template-columns:minmax(130px,220px) 1fr;gap:8px 16px}.sync-status{display:inline-block;padding:3px 8px;border-radius:12px;background:#e5e7eb}.status-success,.status-synced,.status-created,.status-updated{background:#d9f2df}.status-conflict,.status-partial{background:#fff0c2}.status-error,.status-missing{background:#ffd9d9}code{word-break:break-all} .log-header{display:flex;align-items:center;justify-content:space-between;gap:16px}.log-header h2{margin:0}.sync-log{height:340px;overflow:auto;background:#111827;color:#e5e7eb;border-radius:5px;padding:12px;font-family:Consolas,"Courier New",monospace;font-size:13px;line-height:1.55;white-space:pre-wrap}.log-line{padding:1px 0}.log-debug{color:#9ca3af}.log-info{color:#dbeafe}.log-success{color:#86efac}.log-warning{color:#fde68a}.log-error{color:#fca5a5}button:disabled{opacity:.65;cursor:wait} .toolbar-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.inline-form{display:inline;margin:0;padding:0;background:transparent;border:0}.button-danger{background:#b42318}.button-danger:hover{background:#912018}.empty-state{text-align:center;color:#667;padding:24px} @@ -2934,12 +2934,12 @@ html.table-column-resizing * { /* User-resizable widths for every data-table column. */ -.data-table th.table-resizable-column-header { +table th.table-resizable-column-header { position: relative; padding-right: 14px; overflow: visible; } -.data-table .table-column-resizer { +table .table-column-resizer { position: absolute; top: 0; right: -4px; @@ -2949,7 +2949,7 @@ html.table-column-resizing * { z-index: 4; touch-action: none; } -.data-table .table-column-resizer::after { +table .table-column-resizer::after { content: ""; position: absolute; top: 20%; @@ -2957,7 +2957,121 @@ html.table-column-resizing * { left: 4px; border-left: 1px solid transparent; } -.data-table .table-column-resizer:hover::after, -.data-table .table-column-resizer:focus-visible::after { +table .table-column-resizer:hover::after, +table .table-column-resizer:focus-visible::after { border-left-color: currentColor; } + + +/* Asset list device preview: first real column after the generated row number. */ +.asset-visual-column { + width: 64px; + min-width: 64px; + max-width: 64px; + text-align: center; + padding-left: 6px !important; + padding-right: 6px !important; +} +.asset-visual-link { display:inline-flex; align-items:center; justify-content:center; width:48px; height:42px; } +.asset-list-device-image { max-width:46px; max-height:38px; width:auto; height:auto; object-fit:contain; border-radius:4px; } +.asset-list-device-placeholder { font-size:24px; opacity:.35; line-height:1; } +.mesh-icon-category-label { align-items:stretch; } +.mesh-icon-category-title { display:flex; align-items:center; gap:8px; min-height:38px; } +.mesh-icon-preview { width:38px; height:38px; object-fit:contain; flex:0 0 38px; } + +/* v0.5.5.55 asset/category image management */ +.asset-edit-image-preview{display:flex;align-items:center;gap:14px;margin:8px 0 10px;padding:10px;border:1px solid #d8dde2;border-radius:6px;background:var(--am-surface-soft,#f8fafc);max-width:520px} +.asset-edit-preview-image{width:110px;height:90px;object-fit:contain;border-radius:5px;background:transparent;border:1px solid #e1e5e9} +.asset-edit-image-preview-actions{display:flex;flex-direction:column;align-items:flex-start;gap:8px} +.button-small{padding:7px 10px;font-size:.9rem} +.category-image-cell{text-align:center} +.category-table-image{width:52px;height:42px;object-fit:contain;border-radius:4px;display:inline-block;background:transparent} +html[data-theme="dark"] .asset-edit-image-preview{border-color:#46515d;background:#252b31} +html[data-theme="dark"] .asset-edit-preview-image{background:transparent;border-color:#4a5561} + +/* v0.5.5.56: consistent transparent image presentation and reliable table resize handles. */ +.asset-list-device-image, +.category-table-image, +.asset-edit-preview-image, +.card img { + background: transparent !important; +} + +.card img, +.asset-list-device-image, +.category-table-image, +.asset-edit-preview-image { + object-fit: contain !important; + object-position: center center; +} + +table th.table-resizable-column-header { + position: relative; +} +table th.table-resizable-column-header > .table-column-resizer { + right: 0; + width: 12px; + z-index: 30; +} +table th.table-resizable-column-header > .table-column-resizer::after { + left: auto; + right: 0; + border-left-color: color-mix(in srgb, currentColor 28%, transparent); +} +table th.table-resizable-column-header > .table-column-resizer:hover::after, +table th.table-resizable-column-header > .table-column-resizer:focus-visible::after { + border-left-color: currentColor; +} + +/* v0.5.5.57: universal table resize visibility and reliable image editor state. */ +.asset-edit-image-preview[hidden], +.image-remove-pending[hidden] { + display: none !important; +} +.image-remove-pending { + margin: 8px 0 10px; + padding: 9px 11px; + max-width: 520px; + border: 1px solid var(--am-warning, #d97706); + border-radius: 6px; + background: color-mix(in srgb, var(--am-warning, #d97706) 10%, transparent); + font-size: .9rem; +} + +/* Every resizable header has a permanent separator; the transparent hit area + remains wider than the visible line for easy mouse/touch dragging. */ +table th.table-resizable-column-header { + position: relative !important; + box-shadow: inset -1px 0 0 color-mix(in srgb, currentColor 28%, transparent); +} +table th.table-resizable-column-header > .table-column-resizer { + display: block !important; + pointer-events: auto !important; + position: absolute !important; + top: 0 !important; + right: -5px !important; + bottom: 0 !important; + width: 11px !important; + z-index: 100 !important; + cursor: col-resize !important; + touch-action: none; + background: transparent; +} +table th.table-resizable-column-header:hover > .table-column-resizer::after, +table th.table-resizable-column-header > .table-column-resizer:focus-visible::after { + border-left-color: currentColor !important; +} + +/* Reusable standard image library */ +.standard-image-library{margin-top:14px;padding-top:12px;border-top:1px solid var(--border,#d8dde2);max-width:760px} +.image-library-help,.image-library-empty{margin:4px 0 9px} +.standard-image-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px;margin:8px 0 12px;max-height:330px;overflow:auto;padding:2px} +.standard-image-choice{position:relative;display:flex;flex-direction:column;align-items:center;gap:5px;min-width:0;padding:7px;border:1px solid var(--border,#d8dde2);border-radius:7px;cursor:pointer;background:transparent} +.standard-image-choice:hover{border-color:var(--primary,#2583d8)} +.standard-image-choice:has(input:checked){outline:2px solid var(--primary,#2583d8);outline-offset:-2px} +.standard-image-choice input{position:absolute;top:6px;left:6px;margin:0} +.standard-image-choice img{width:72px;height:58px;object-fit:contain;background:transparent} +.standard-image-choice span{display:block;width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center;font-size:.78rem} +.image-upload-label{display:flex;flex-direction:column;align-items:flex-start;gap:5px;margin-top:8px} +html[data-theme="dark"] .standard-image-library{border-color:#46515d} +html[data-theme="dark"] .standard-image-choice{border-color:#46515d} diff --git a/app/static/img/meshcentral-device-icons/icons256-1-1.png b/app/static/img/meshcentral-device-icons/icons256-1-1.png new file mode 100644 index 0000000..ba3def1 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-1-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-2-1.png b/app/static/img/meshcentral-device-icons/icons256-2-1.png new file mode 100644 index 0000000..a22ae56 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-2-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-3-1.png b/app/static/img/meshcentral-device-icons/icons256-3-1.png new file mode 100644 index 0000000..80b02f6 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-3-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-4-1.png b/app/static/img/meshcentral-device-icons/icons256-4-1.png new file mode 100644 index 0000000..cab4c53 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-4-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-5-1.png b/app/static/img/meshcentral-device-icons/icons256-5-1.png new file mode 100644 index 0000000..126a660 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-5-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-6-1.png b/app/static/img/meshcentral-device-icons/icons256-6-1.png new file mode 100644 index 0000000..347aa17 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-6-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-7-1.png b/app/static/img/meshcentral-device-icons/icons256-7-1.png new file mode 100644 index 0000000..efeaf01 Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-7-1.png differ diff --git a/app/static/img/meshcentral-device-icons/icons256-8-1.png b/app/static/img/meshcentral-device-icons/icons256-8-1.png new file mode 100644 index 0000000..66a80ac Binary files /dev/null and b/app/static/img/meshcentral-device-icons/icons256-8-1.png differ diff --git a/app/static/js/image-editor.js b/app/static/js/image-editor.js new file mode 100644 index 0000000..e62e57e --- /dev/null +++ b/app/static/js/image-editor.js @@ -0,0 +1,133 @@ +(() => { + function initImageEditor(editor) { + if (!editor || editor.dataset.imageEditorReady === '1') return; + + const removeButton = editor.querySelector('[data-image-remove]'); + const removeInput = editor.querySelector('[data-image-remove-input]'); + let preview = editor.querySelector('[data-image-preview]'); + let previewImage = preview?.querySelector('img'); + const imageInput = editor.querySelector('input[type="file"][data-image-input]'); + const libraryUpload = editor.querySelector('input[type="file"][data-image-library-upload]'); + const libraryChoices = Array.from(editor.querySelectorAll('[data-image-library-choice]')); + const pending = editor.querySelector('[data-image-remove-pending]'); + + if (!removeInput) return; + + const originalPreviewSrc = previewImage?.getAttribute('src') || ''; + let objectUrl = null; + + const releaseObjectUrl = () => { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + }; + + const ensurePreview = () => { + if (preview && previewImage) return; + preview = document.createElement('div'); + preview.className = 'asset-edit-image-preview'; + preview.dataset.imagePreview = ''; + preview.hidden = true; + preview.style.display = 'none'; + + previewImage = document.createElement('img'); + previewImage.className = 'asset-edit-preview-image'; + previewImage.alt = ''; + preview.appendChild(previewImage); + + const firstControl = editor.querySelector('[data-image-remove-input]'); + editor.insertBefore(preview, firstControl); + }; + + const showPreview = src => { + ensurePreview(); + if (src) previewImage.src = src; + preview.hidden = false; + preview.style.display = ''; + if (removeButton) { + removeButton.hidden = false; + removeButton.disabled = false; + } + }; + + const hidePreview = () => { + if (!preview) return; + preview.hidden = true; + preview.style.display = 'none'; + if (removeButton) { removeButton.hidden = true; removeButton.disabled = true; } + }; + + const clearLibraryChoices = () => { + libraryChoices.forEach(choice => { choice.checked = false; }); + }; + + const clearRemoveState = () => { + removeInput.value = '0'; + if (pending) pending.hidden = true; + if (removeButton) { + removeButton.disabled = false; + removeButton.setAttribute('aria-pressed', 'false'); + } + }; + + const setRemoveState = remove => { + removeInput.value = remove ? '1' : '0'; + if (pending) pending.hidden = !remove; + if (removeButton) { + removeButton.disabled = remove; + removeButton.setAttribute('aria-pressed', remove ? 'true' : 'false'); + } + if (remove) { + releaseObjectUrl(); + if (imageInput) imageInput.value = ''; + if (libraryUpload) libraryUpload.value = ''; + clearLibraryChoices(); + hidePreview(); + } + }; + + removeButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + setRemoveState(true); + }); + + imageInput?.addEventListener('change', () => { + if (!imageInput.files || !imageInput.files.length) return; + releaseObjectUrl(); + objectUrl = URL.createObjectURL(imageInput.files[0]); + if (libraryUpload) libraryUpload.value = ''; + clearLibraryChoices(); + clearRemoveState(); + showPreview(objectUrl); + }); + + libraryUpload?.addEventListener('change', () => { + if (!libraryUpload.files || !libraryUpload.files.length) return; + releaseObjectUrl(); + objectUrl = URL.createObjectURL(libraryUpload.files[0]); + if (imageInput) imageInput.value = ''; + clearLibraryChoices(); + clearRemoveState(); + showPreview(objectUrl); + }); + + libraryChoices.forEach(choice => { + choice.addEventListener('change', () => { + if (!choice.checked) return; + releaseObjectUrl(); + if (imageInput) imageInput.value = ''; + if (libraryUpload) libraryUpload.value = ''; + clearRemoveState(); + showPreview(choice.dataset.imageSrc || choice.value); + }); + }); + + window.addEventListener('beforeunload', releaseObjectUrl, { once: true }); + editor.dataset.imageEditorReady = '1'; + editor.dataset.originalImageSrc = originalPreviewSrc; + } + + document.querySelectorAll('[data-image-editor]').forEach(initImageEditor); +})(); diff --git a/app/static/js/table-tools.js b/app/static/js/table-tools.js index 9467a38..c0466bb 100644 --- a/app/static/js/table-tools.js +++ b/app/static/js/table-tools.js @@ -302,18 +302,48 @@ return normalized; } + function freezeTableGeometry() { + if (table.id === 'asset-table') return; + const widths = [...headerRow.cells].map(cell => Math.max(minimumColumnWidth, Math.round(cell.getBoundingClientRect().width || minimumColumnWidth))); + const total = widths.reduce((sum, width) => sum + width, 0); + widths.forEach((width, columnIndex) => { + if (headerRow.cells[columnIndex]?.dataset.rowNumberColumn === '1') return; + applyColumnWidth(columnIndex, width); + }); + table.style.tableLayout = 'fixed'; + table.style.width = `${total}px`; + table.style.minWidth = `${total}px`; + } + + function synchronizeTableWidth() { + if (table.id === 'asset-table') return; + const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); + if (total > 0) { + table.style.width = `${total}px`; + table.style.minWidth = `${total}px`; + } + } + function applyStoredColumnWidths() { + let restored = false; [...headerRow.cells].forEach((header, columnIndex) => { if (header.dataset.rowNumberColumn === '1') return; const stored = readStoredColumnWidth(header, columnIndex); - if (stored !== null) applyColumnWidth(columnIndex, stored); + if (stored !== null) { + applyColumnWidth(columnIndex, stored); + restored = true; + } }); + if (restored && table.id !== 'asset-table') { + table.style.tableLayout = 'fixed'; + requestAnimationFrame(synchronizeTableWidth); + } } function installColumnResizers() { [...headerRow.cells].forEach((header, columnIndex) => { if (header.dataset.rowNumberColumn === '1') return; - if (header.dataset.noResize === '1') return; + if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return; if (header.querySelector(':scope > .table-column-resizer')) return; header.classList.add('table-resizable-column-header'); @@ -334,34 +364,72 @@ event.preventDefault(); event.stopPropagation(); const startX = event.clientX; - const measured = header.getBoundingClientRect().width; - const startWidth = measured > 0 ? measured : minimumColumnWidth; - handle.setPointerCapture?.(event.pointerId); + freezeTableGeometry(); + + const headers = [...headerRow.cells]; + const rightmostIndex = headers.length - 1; + const startWidth = header.getBoundingClientRect().width || minimumColumnWidth; + const rightmostHeader = headers[rightmostIndex]; + const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth; + const keepTableWidth = columnIndex !== rightmostIndex; + document.documentElement.classList.add('table-column-resizing'); - const move = moveEvent => { - applyColumnWidth(columnIndex, startWidth + moveEvent.clientX - startX); + const resizePair = delta => { + if (!keepTableWidth) { + applyColumnWidth(columnIndex, startWidth + delta); + synchronizeTableWidth(); + return; + } + + // All columns between the dragged column and the far-right column + // stay unchanged. The far-right column alone absorbs the delta. + const minDelta = minimumColumnWidth - startWidth; + const maxDelta = rightmostStartWidth - minimumColumnWidth; + const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta)); + applyColumnWidth(columnIndex, startWidth + effectiveDelta); + applyColumnWidth(rightmostIndex, rightmostStartWidth - effectiveDelta); }; - const finish = finishEvent => { - const current = header.getBoundingClientRect().width || startWidth; - saveColumnWidth(header, columnIndex, current); - handle.releasePointerCapture?.(finishEvent.pointerId); + + const move = moveEvent => resizePair(moveEvent.clientX - startX); + const finish = () => { + saveColumnWidth(header, columnIndex, header.getBoundingClientRect().width || startWidth); + if (keepTableWidth && rightmostHeader) { + saveColumnWidth(rightmostHeader, rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth); + } else { + synchronizeTableWidth(); + } document.documentElement.classList.remove('table-column-resizing'); - handle.removeEventListener('pointermove', move); - handle.removeEventListener('pointerup', finish); - handle.removeEventListener('pointercancel', finish); + window.removeEventListener('pointermove', move, true); + window.removeEventListener('pointerup', finish, true); + window.removeEventListener('pointercancel', finish, true); }; - handle.addEventListener('pointermove', move); - handle.addEventListener('pointerup', finish); - handle.addEventListener('pointercancel', finish); + window.addEventListener('pointermove', move, true); + window.addEventListener('pointerup', finish, true); + window.addEventListener('pointercancel', finish, true); }); handle.addEventListener('keydown', event => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); event.stopPropagation(); + freezeTableGeometry(); + const headers = [...headerRow.cells]; + const rightmostIndex = headers.length - 1; + const step = event.key === 'ArrowRight' ? 12 : -12; const current = header.getBoundingClientRect().width || minimumColumnWidth; - saveColumnWidth(header, columnIndex, current + (event.key === 'ArrowRight' ? 12 : -12)); + if (columnIndex === rightmostIndex) { + saveColumnWidth(header, columnIndex, current + step); + synchronizeTableWidth(); + return; + } + const rightmostHeader = headers[rightmostIndex]; + const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minimumColumnWidth; + const minDelta = minimumColumnWidth - current; + const maxDelta = rightmostWidth - minimumColumnWidth; + const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step)); + saveColumnWidth(header, columnIndex, current + effectiveDelta); + if (rightmostHeader) saveColumnWidth(rightmostHeader, rightmostIndex, rightmostWidth - effectiveDelta); }); }); } @@ -796,8 +864,10 @@ }); thead.appendChild(filterRow); - installColumnResizers(); - applyStoredColumnWidths(); + if (isAssetTable) { + installColumnResizers(); + applyStoredColumnWidths(); + } // Editable cells are always read live, so changed values immediately // participate in filtering without a stale cache. @@ -840,7 +910,147 @@ })); } + function initUniversalTableResize(table) { + if (!table || table.id === 'asset-table' || table.dataset.columnResizeReady === '1') return; + const thead = table.tHead; + const tbody = table.tBodies?.[0]; + if (!thead || !tbody || !thead.rows.length) return; + + const headerRow = thead.rows[0]; + const storagePart = table.dataset.storageKey || table.id || `${window.location.pathname}:table-${[...document.querySelectorAll('main table')].indexOf(table)}`; + const storagePrefix = `assetmanager:table:${storagePart}:column-width:`; + const minWidth = 56; + const maxWidth = 1200; + const normalize = width => Math.min(maxWidth, Math.max(minWidth, Math.round(width))); + + const columnKey = (header, index) => header.dataset.field || `index-${index}`; + const applyWidth = (index, width) => { + const normalized = normalize(width); + [...thead.rows, ...tbody.rows].forEach(row => { + const cell = row.cells?.[index]; + if (!cell) return; + cell.style.width = `${normalized}px`; + cell.style.minWidth = `${normalized}px`; + cell.style.maxWidth = `${normalized}px`; + }); + return normalized; + }; + + const freezeGeometry = () => { + const widths = [...headerRow.cells].map(cell => normalize(cell.getBoundingClientRect().width || minWidth)); + const total = widths.reduce((sum, width) => sum + width, 0); + widths.forEach((width, index) => applyWidth(index, width)); + table.style.tableLayout = 'fixed'; + table.style.width = `${total}px`; + table.style.minWidth = `${total}px`; + }; + + const syncTableWidth = () => { + const total = [...headerRow.cells].reduce((sum, cell) => sum + Math.round(cell.getBoundingClientRect().width || 0), 0); + if (total > 0) { + table.style.width = `${total}px`; + table.style.minWidth = `${total}px`; + } + }; + + [...headerRow.cells].forEach((header, index) => { + if (header.dataset.noResize === '1' || header.dataset.rowNumberColumn === '1') return; + const key = `${storagePrefix}${columnKey(header, index)}`; + try { + const stored = Number.parseInt(localStorage.getItem(key) || '', 10); + if (Number.isFinite(stored)) applyWidth(index, stored); + } catch (_) {} + + 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); + + const save = width => { + const normalized = applyWidth(index, width); + try { localStorage.setItem(key, String(normalized)); } catch (_) {} + }; + handle.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); }); + handle.addEventListener('pointerdown', event => { + event.preventDefault(); event.stopPropagation(); + const startX = event.clientX; + freezeGeometry(); + const headers = [...headerRow.cells]; + const rightmostIndex = headers.length - 1; + const startWidth = header.getBoundingClientRect().width || minWidth; + const rightmostHeader = headers[rightmostIndex]; + const rightmostStartWidth = rightmostHeader?.getBoundingClientRect().width || minWidth; + const keepTableWidth = index !== rightmostIndex; + document.documentElement.classList.add('table-column-resizing'); + + const resizePair = delta => { + if (!keepTableWidth) { + applyWidth(index, startWidth + delta); + syncTableWidth(); + return; + } + const minDelta = minWidth - startWidth; + const maxDelta = rightmostStartWidth - minWidth; + const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, delta)); + applyWidth(index, startWidth + effectiveDelta); + applyWidth(rightmostIndex, rightmostStartWidth - effectiveDelta); + }; + + const move = e => resizePair(e.clientX - startX); + const finish = () => { + save(header.getBoundingClientRect().width || startWidth); + if (keepTableWidth && rightmostHeader) { + const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`; + const rightWidth = applyWidth(rightmostIndex, rightmostHeader.getBoundingClientRect().width || rightmostStartWidth); + try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {} + } else { + syncTableWidth(); + } + document.documentElement.classList.remove('table-column-resizing'); + window.removeEventListener('pointermove', move, true); + window.removeEventListener('pointerup', finish, true); + window.removeEventListener('pointercancel', finish, true); + }; + window.addEventListener('pointermove', move, true); + window.addEventListener('pointerup', finish, true); + window.addEventListener('pointercancel', finish, true); + }); + handle.addEventListener('keydown', event => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); event.stopPropagation(); + freezeGeometry(); + const headers = [...headerRow.cells]; + const rightmostIndex = headers.length - 1; + const step = event.key === 'ArrowRight' ? 12 : -12; + const current = header.getBoundingClientRect().width || minWidth; + if (index === rightmostIndex) { + save(current + step); + syncTableWidth(); + return; + } + const rightmostHeader = headers[rightmostIndex]; + const rightmostWidth = rightmostHeader?.getBoundingClientRect().width || minWidth; + const minDelta = minWidth - current; + const maxDelta = rightmostWidth - minWidth; + const effectiveDelta = Math.max(minDelta, Math.min(maxDelta, step)); + save(current + effectiveDelta); + if (rightmostHeader) { + const rightKey = `${storagePrefix}${columnKey(rightmostHeader, rightmostIndex)}`; + const rightWidth = applyWidth(rightmostIndex, rightmostWidth - effectiveDelta); + try { localStorage.setItem(rightKey, String(rightWidth)); } catch (_) {} + } + }); + }); + table.dataset.columnResizeReady = '1'; + } + document.querySelectorAll('table.data-table').forEach(initTable); + document.querySelectorAll('main table:not(#asset-table):not([data-column-resize="off"])').forEach(initUniversalTableResize); window.addEventListener('asset-table-content-reloaded', event => { const table = event.detail?.table; diff --git a/app/templates/asset_detail.html b/app/templates/asset_detail.html index 1b786f5..72d1177 100644 --- a/app/templates/asset_detail.html +++ b/app/templates/asset_detail.html @@ -73,7 +73,22 @@
-
{% if asset.image_path %}{% elif asset.category.image_path %}{% endif %}
+
+ {% set visual_source = asset.visual_source or 'auto' %} + {% if visual_source == 'asset_image' %} + {% if asset.image_path %}{% endif %} + {% elif visual_source == 'category_image' %} + {% if asset.category.image_path %}{% endif %} + {% elif visual_source == 'mesh_icon' %} + {% if asset.mesh_icon and asset.mesh_icon >= 1 and asset.mesh_icon <= 8 %}{% endif %} + {% elif asset.image_path %} + + {% elif asset.category.image_path %} + + {% elif asset.mesh_icon and asset.mesh_icon >= 1 and asset.mesh_icon <= 8 %} + + {% endif %} +
{% for f in active_fields %}{% if f.field_name not in ['room_number', 'parent_asset_id'] %}{% set d=f.definition %}{% set v=custom_values.get(f.field_name) if d and not d.is_system else asset|attr(f.field_name) %}{% if v %}
{{ t(d.translation_key, d.label) if d else f.label }}
{{ v }}
{% endif %}{% endif %}{% endfor %}{% if asset.room_number %}
{{ t('assets.room') }}
{{ asset.room_number }}
{% endif %} {% if parent_asset %}
{{ t('assets.parent') }}
{{ parent_asset.name }}
{% endif %} {% if child_assets %}
{{ t('assets.child_assets') }}
{% for child in child_assets %}{{ child.name }}{% if not loop.last %}, {% endif %}{% endfor %}
{% endif %} diff --git a/app/templates/asset_form.html b/app/templates/asset_form.html index 0960a34..ba39069 100644 --- a/app/templates/asset_form.html +++ b/app/templates/asset_form.html @@ -23,18 +23,61 @@ {% elif d and d.data_type in ['integer','decimal'] %} {% elif d and d.data_type == 'json' %} {% else %}{% endif %}{% endfor %}{% endif %} - +
+ + + + +
+ {{ t('images.library', 'Standardbild-Bibliothek') }} +
{{ t('images.library_help', 'Wähle ein bereits vorhandenes Standardbild oder füge ein Bild einmalig zur Bibliothek hinzu.') }}
+ {% if standard_images %} +
+ {% for item in standard_images %} + + {% endfor %} +
+ {% else %} +
{{ t('images.library_empty', 'Noch keine Standardbilder vorhanden.') }}
+ {% endif %} + + {{ t('images.library_upload_help', 'Das Bild wird einmal in der gemeinsamen Bibliothek gespeichert und kann danach bei weiteren Assets und Kategorien wiederverwendet werden.') }} +
+
+ {% if asset and not duplicate_mode %} {% endif %} diff --git a/app/templates/assets.html b/app/templates/assets.html index c01fc12..3f703e0 100644 --- a/app/templates/assets.html +++ b/app/templates/assets.html @@ -104,6 +104,7 @@
+ {% if is_admin %}{% endif %} {% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %} {% if not has_name %}{% endif %} @@ -120,6 +121,26 @@ {% for a in assets %} + {% if is_admin %}{% endif %} {% if not has_name %}{% endif %} {% if not selected %}{% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 20f7626..34b91b3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -105,7 +105,7 @@ {% endif %}
{% block content %}{% endblock %}
-
{{ t('assets.device_visual', 'Gerät') }}{{ t('field.name', 'Bezeichnung') }}
+ + {% set visual_source = a.visual_source or 'auto' %} + {% if visual_source == 'asset_image' %} + {% if a.image_path %}{% else %}{% endif %} + {% elif visual_source == 'category_image' %} + {% if a.category and a.category.image_path %}{% else %}{% endif %} + {% elif visual_source == 'mesh_icon' %} + {% if a.mesh_icon and a.mesh_icon >= 1 and a.mesh_icon <= 8 %}{% else %}{% endif %} + {% elif a.image_path %} + + {% elif a.category and a.category.image_path %} + + {% elif a.mesh_icon and a.mesh_icon >= 1 and a.mesh_icon <= 8 %} + + {% else %} + + {% endif %} + + {{ a.name }}{{ a.category.name }}