fix table with handling, icons implemented

This commit is contained in:
root
2026-08-09 08:50:13 +02:00
parent 5e32be7d0c
commit 3ff0794cfc
43 changed files with 1027 additions and 95 deletions
+4 -1
View File
@@ -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.
+4
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.5.5.52
0.5.5.63
+4 -3
View File
@@ -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"}
+32 -8
View File
@@ -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}"),
})
+136 -20
View File
@@ -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)
+4 -4
View File
@@ -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
+3
View File
@@ -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():
+2
View File
@@ -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)
+120 -6
View File
@@ -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}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+133
View File
@@ -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);
})();
+228 -18
View File
@@ -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);
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;
+16 -1
View File
@@ -73,7 +73,22 @@
</div>
<div class="detail">
<div>{% if asset.image_path %}<img class="asset-image" src="{{ asset.image_path }}">{% elif asset.category.image_path %}<img class="asset-image" src="{{ asset.category.image_path }}">{% endif %}</div>
<div>
{% set visual_source = asset.visual_source or 'auto' %}
{% if visual_source == 'asset_image' %}
{% if asset.image_path %}<img class="asset-image" src="{{ asset.image_path }}">{% endif %}
{% elif visual_source == 'category_image' %}
{% if asset.category.image_path %}<img class="asset-image" src="{{ asset.category.image_path }}">{% endif %}
{% elif visual_source == 'mesh_icon' %}
{% if asset.mesh_icon and asset.mesh_icon >= 1 and asset.mesh_icon <= 8 %}<img class="asset-image meshcentral-device-icon" src="/static/img/meshcentral-device-icons/icons256-{{ asset.mesh_icon }}-1.png" alt="">{% endif %}
{% elif asset.image_path %}
<img class="asset-image" src="{{ asset.image_path }}">
{% elif asset.category.image_path %}
<img class="asset-image" src="{{ asset.category.image_path }}">
{% elif asset.mesh_icon and asset.mesh_icon >= 1 and asset.mesh_icon <= 8 %}
<img class="asset-image meshcentral-device-icon" src="/static/img/meshcentral-device-icons/icons256-{{ asset.mesh_icon }}-1.png" alt="">
{% endif %}
</div>
<dl>{% 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 %}<dt>{{ t(d.translation_key, d.label) if d else f.label }}</dt><dd>{{ v }}</dd>{% endif %}{% endif %}{% endfor %}{% if asset.room_number %}<dt>{{ t('assets.room') }}</dt><dd>{{ asset.room_number }}</dd>{% endif %}
{% if parent_asset %}<dt>{{ t('assets.parent') }}</dt><dd><a href="/assets/{{ parent_asset.id }}">{{ parent_asset.name }}</a></dd>{% endif %}
{% if child_assets %}<dt>{{ t('assets.child_assets') }}</dt><dd>{% for child in child_assets %}<a href="/assets/{{ child.id }}">{{ child.name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</dd>{% endif %}
+45 -2
View File
@@ -23,18 +23,61 @@
{% elif d and d.data_type in ['integer','decimal'] %}<input type="number" {% if d.data_type=='decimal' %}step="any"{% endif %} name="{{f.field_name}}" value="{{value or ''}}" {% if f.required %}required{% endif %}>
{% elif d and d.data_type == 'json' %}<textarea name="{{f.field_name}}">{{value|tojson if value else ''}}</textarea>
{% else %}<input name="{{f.field_name}}" value="{{value or ''}}" {% if f.required %}required{% endif %}>{% endif %}</label>{% endfor %}{% endif %}
<label>{{t('assets.image', 'Image')}}<input type="file" name="image" accept="image/*"></label><button class="button button-save">💾 {{t('common.save')}}</button></form>
<div class="form-field asset-image-editor" data-image-editor>
<label for="asset-image-input">{{t('assets.image', 'Image')}}</label>
<div class="asset-edit-image-preview" id="asset-current-image-preview" data-image-preview {% if not (asset and not duplicate_mode and asset.image_path) %}hidden style="display:none"{% endif %}>
<img src="{{ asset.image_path if asset and not duplicate_mode and asset.image_path else '' }}" alt="" class="asset-edit-preview-image">
<div class="asset-edit-image-preview-actions">
<span class="muted">{{ t('assets.current_image', 'Aktuelles Assetbild') }}</span>
<button type="button" class="button button-danger button-small" id="remove-asset-image-button" data-image-remove aria-pressed="false" {% if not (asset and not duplicate_mode and asset.image_path) %}hidden disabled{% endif %}>{{ t('assets.remove_image', 'Assetbild entfernen') }}</button>
</div>
</div>
<input type="hidden" name="remove_image" id="remove-asset-image" value="0" data-image-remove-input>
<div class="image-remove-pending" data-image-remove-pending hidden>{{ t('assets.image_remove_pending', 'Assetbild wird beim Speichern entfernt.') }}</div>
<div class="standard-image-library">
<strong>{{ t('images.library', 'Standardbild-Bibliothek') }}</strong>
<div class="muted image-library-help">{{ t('images.library_help', 'Wähle ein bereits vorhandenes Standardbild oder füge ein Bild einmalig zur Bibliothek hinzu.') }}</div>
{% if standard_images %}
<div class="standard-image-grid">
{% for item in standard_images %}
<label class="standard-image-choice" title="{{ item.name }}">
<input type="radio" name="library_image" value="{{ item.path }}" data-image-library-choice data-image-src="{{ item.path }}" {% if asset and not duplicate_mode and asset.image_path == item.path %}checked{% endif %}>
<img src="{{ item.path }}" alt="">
<span>{{ item.name }}</span>
</label>
{% endfor %}
</div>
{% else %}
<div class="muted image-library-empty">{{ t('images.library_empty', 'Noch keine Standardbilder vorhanden.') }}</div>
{% endif %}
<label class="image-upload-label">{{ t('images.library_upload', 'Neues Standardbild hinzufügen und verwenden') }}
<input type="file" name="library_upload" accept="image/*" data-image-library-upload>
</label>
<small class="muted">{{ t('images.library_upload_help', 'Das Bild wird einmal in der gemeinsamen Bibliothek gespeichert und kann danach bei weiteren Assets und Kategorien wiederverwendet werden.') }}</small>
</div>
</div>
<label>{{ t('assets.visual_source', 'Anzeigequelle') }}
<select name="visual_source">
{% set current_visual_source = asset.visual_source if asset and asset.visual_source else 'auto' %}
<option value="auto" {% if current_visual_source == 'auto' %}selected{% endif %}>{{ t('assets.visual_source.auto', 'Automatisch: Assetbild → Kategoriebild → MeshCentral-Icon') }}</option>
<option value="asset_image" {% if current_visual_source == 'asset_image' %}selected{% endif %}>{{ t('assets.visual_source.asset_image', 'Nur Assetbild') }}</option>
<option value="category_image" {% if current_visual_source == 'category_image' %}selected{% endif %}>{{ t('assets.visual_source.category_image', 'Kategoriebild') }}</option>
<option value="mesh_icon" {% if current_visual_source == 'mesh_icon' %}selected{% endif %}>{{ t('assets.visual_source.mesh_icon', 'MeshCentral-Icon') }}</option>
</select>
<small class="muted">{{ t('assets.visual_source.help', 'Legt fest, welches Bild oder Icon für dieses Asset in Listen und Details bevorzugt angezeigt wird.') }}</small>
</label><button class="button button-save">💾 {{t('common.save')}}</button></form>
{% if asset and not duplicate_mode %}
<script>
(() => {
const input = document.getElementById('mesh-node-id-input');
const clearButton = document.getElementById('mesh-node-id-clear');
if (!input || !clearButton) return;
if (input && clearButton) {
clearButton.addEventListener('click', () => {
input.value = '';
input.focus();
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
})();
</script>
{% endif %}
+21
View File
@@ -104,6 +104,7 @@
<div class="table-scroll sticky-table" id="asset-table-scroll">
<table class="data-table asset-table-fixed" id="asset-table" data-storage-key="asset-columns-{{ selected.id if selected else 'all' }}" data-fixed-column-width="{% if is_admin %}224{% else %}180{% endif %}">
<thead><tr>
<th class="asset-visual-column" data-no-sort="1" data-no-filter="1" data-field="asset_visual" data-label="{{ t('assets.device_visual', 'Gerät')|e }}" title="{{ t('assets.device_visual', 'Gerät')|e }}">{{ t('assets.device_visual', 'Gerät') }}</th>
{% if is_admin %}<th class="selection-column" data-no-sort="1" data-no-filter="1"><input type="checkbox" id="select-all-assets" aria-label="{{ t('assets.select_all') }}"></th>{% endif %}
{% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %}
{% if not has_name %}<th draggable="true" data-field="name" data-column-width="220" data-label="{{ t('field.name', 'Bezeichnung')|e }}" style="width:220px;min-width:220px;max-width:220px">{{ t('field.name', 'Bezeichnung') }}</th>{% endif %}
@@ -120,6 +121,26 @@
</tr></thead>
<tbody>
{% for a in assets %}<tr data-platform="{{ asset_platforms.get(a.id, 'unknown') }}">
<td class="asset-visual-column" data-field="asset_visual">
<a href="/assets/{{ a.id }}" class="asset-visual-link" aria-label="{{ a.name|e }}">
{% set visual_source = a.visual_source or 'auto' %}
{% if visual_source == 'asset_image' %}
{% if a.image_path %}<img src="{{ a.image_path }}" alt="" class="asset-list-device-image">{% else %}<span class="asset-list-device-placeholder" aria-hidden="true"></span>{% endif %}
{% elif visual_source == 'category_image' %}
{% if a.category and a.category.image_path %}<img src="{{ a.category.image_path }}" alt="" class="asset-list-device-image category-device-image" title="{{ a.category.name|e }}">{% else %}<span class="asset-list-device-placeholder" aria-hidden="true"></span>{% endif %}
{% elif visual_source == 'mesh_icon' %}
{% if a.mesh_icon and a.mesh_icon >= 1 and a.mesh_icon <= 8 %}<img src="/static/img/meshcentral-device-icons/icons256-{{ a.mesh_icon }}-1.png" alt="" class="asset-list-device-image meshcentral-device-icon" title="{{ t('assets.mesh_icon_title', 'MeshCentral-Geräte-Icon {icon}').format(icon=a.mesh_icon)|e }}">{% else %}<span class="asset-list-device-placeholder" aria-hidden="true"></span>{% endif %}
{% elif a.image_path %}
<img src="{{ a.image_path }}" alt="" class="asset-list-device-image">
{% elif a.category and a.category.image_path %}
<img src="{{ a.category.image_path }}" alt="" class="asset-list-device-image category-device-image" title="{{ a.category.name|e }}">
{% elif a.mesh_icon and a.mesh_icon >= 1 and a.mesh_icon <= 8 %}
<img src="/static/img/meshcentral-device-icons/icons256-{{ a.mesh_icon }}-1.png" alt="" class="asset-list-device-image meshcentral-device-icon" title="{{ t('assets.mesh_icon_title', 'MeshCentral-Geräte-Icon {icon}').format(icon=a.mesh_icon)|e }}">
{% else %}
<span class="asset-list-device-placeholder" aria-hidden="true"></span>
{% endif %}
</a>
</td>
{% if is_admin %}<td class="selection-column"><input type="checkbox" class="asset-select" value="{{ a.id }}" aria-label="{{ t('assets.select_named', 'Asset {name} markieren').format(name=a.name)|e }}"></td>{% endif %}
{% if not has_name %}<td data-field="name"><a href="/assets/{{ a.id }}">{{ a.name }}</a></td>{% endif %}
{% if not selected %}<td data-field="category">{{ a.category.name }}</td>{% endif %}
+1 -1
View File
@@ -105,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?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>
</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 src="/static/js/image-editor.js?v={{ app_version }}"></script><script>
(() => {
document.addEventListener('keydown', event => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
+21 -17
View File
@@ -122,19 +122,20 @@
</dialog>
<div class="table-scroll management-table-scroll">
<table class="data-table categories-table">
<table class="data-table categories-table" data-storage-key="categories">
<thead>
<tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1">
<th class="selection-column" data-no-sort="1" data-no-filter="1" data-field="selection">
<input type="checkbox" id="select-all-categories" aria-label="{{ t('categories.select_all', 'Alle Kategorien markieren') }}">
</th>
<th>{{ t('common.name') }}</th>
<th>{{ t('common.assets') }}</th>
<th>{{ t('categories.fields_total', 'Felder gesamt') }}</th>
<th>{{ t('categories.active_fields') }}</th>
<th>{{ t('categories.list_fields', 'In Liste') }}</th>
<th>{{ t('categories.attributes', 'Aktive Attribute') }}</th>
<th data-no-filter="1" data-no-sort="1">{{ t('common.actions') }}</th>
<th data-no-sort="1" data-no-filter="1" data-field="category_image">{{ t('categories.image', 'Bild') }}</th>
<th data-field="name">{{ t('common.name') }}</th>
<th data-field="assets_count">{{ t('common.assets') }}</th>
<th data-field="fields_total">{{ t('categories.fields_total', 'Felder gesamt') }}</th>
<th data-field="active_fields">{{ t('categories.active_fields') }}</th>
<th data-field="list_fields">{{ t('categories.list_fields', 'In Liste') }}</th>
<th data-field="attributes">{{ t('categories.attributes', 'Aktive Attribute') }}</th>
<th data-no-filter="1" data-no-sort="1" data-field="actions">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
@@ -143,23 +144,26 @@
{% set active_fields = c.visible_fields|selectattr('active')|list %}
{% set list_fields = active_fields|selectattr('show_in_list')|list %}
<tr>
<td class="selection-column">
<td class="selection-column" data-field="selection">
<input class="category-select" type="checkbox" form="category-bulk-form" name="category_ids" value="{{ c.id }}"
aria-label="{{ c.name|e }} {{ t('common.select', 'markieren') }}">
</td>
<td>{{ c.name }}</td>
<td>{{ count }}</td>
<td>{{ c.visible_fields|length }}</td>
<td>{{ active_fields|length }}</td>
<td>{{ list_fields|length }}</td>
<td class="category-attributes">
<td class="category-image-cell" data-field="category_image">
{% if c.image_path %}<img src="{{ c.image_path }}" alt="" class="category-table-image">{% else %}<span class="asset-list-device-placeholder" aria-hidden="true"></span>{% endif %}
</td>
<td data-field="name">{{ c.name }}</td>
<td data-field="assets_count">{{ count }}</td>
<td data-field="fields_total">{{ c.visible_fields|length }}</td>
<td data-field="active_fields">{{ active_fields|length }}</td>
<td data-field="list_fields">{{ list_fields|length }}</td>
<td class="category-attributes" data-field="attributes">
{% for field in active_fields %}
<span class="attribute-chip{% if field.show_in_list %} attribute-chip-list{% endif %}" title="{{ t('categories.shown_in_list', 'In Liste sichtbar') if field.show_in_list else t('categories.active_only', 'Aktiv, aber nicht in Liste sichtbar') }}">{{ field_label(field) }}</span>
{% else %}
<span class="muted">{{ t('categories.no_active_fields', 'Keine aktiven Felder') }}</span>
{% endfor %}
</td>
<td class="category-actions">
<td class="category-actions" data-field="actions">
<div class="action-buttons">
<a class="button button-secondary" href="/categories/{{ c.id }}/edit" title="{{ t('common.edit') }}">{{ t('common.edit') }}</a>
<button type="submit" class="button button-secondary" form="duplicate-category-{{ c.id }}" title="{{ t('common.duplicate') }}">{{ t('common.duplicate') }}</button>
+35 -2
View File
@@ -3,7 +3,39 @@
<form method="post" enctype="multipart/form-data">
<label>{{ t('common.name') }}<input name="name" value="{{category.name if category else ''}}" required></label>
<label>{{ t('common.description') }}<textarea name="description">{{category.description if category and category.description else ''}}</textarea></label>
<label>{{ t('categories.image') }}<input type="file" name="image" accept="image/*"></label>
<div class="form-field category-image-editor" data-image-editor>
<label for="category-image-input">{{ t('categories.image') }}</label>
<div class="asset-edit-image-preview" id="category-current-image-preview" data-image-preview {% if not (category and category.image_path) %}hidden style="display:none"{% endif %}>
<img src="{{ category.image_path if category and category.image_path else '' }}" class="asset-edit-preview-image" alt="">
<div class="asset-edit-image-preview-actions">
<span class="muted">{{ t('categories.current_image', 'Aktuelles Kategoriebild') }}</span>
<button type="button" class="button button-danger button-small" id="remove-category-image-button" data-image-remove aria-pressed="false" {% if not (category and category.image_path) %}hidden disabled{% endif %}>{{ t('categories.remove_image', 'Kategoriebild entfernen') }}</button>
</div>
</div>
<input type="hidden" name="remove_image" id="remove-category-image" value="0" data-image-remove-input>
<div class="image-remove-pending" data-image-remove-pending hidden>{{ t('categories.image_remove_pending', 'Kategoriebild wird beim Speichern entfernt.') }}</div>
<div class="standard-image-library">
<strong>{{ t('images.library', 'Standardbild-Bibliothek') }}</strong>
<div class="muted image-library-help">{{ t('images.library_help', 'Wähle ein bereits vorhandenes Standardbild oder füge ein Bild einmalig zur Bibliothek hinzu.') }}</div>
{% if standard_images %}
<div class="standard-image-grid">
{% for item in standard_images %}
<label class="standard-image-choice" title="{{ item.name }}">
<input type="radio" name="library_image" value="{{ item.path }}" data-image-library-choice data-image-src="{{ item.path }}" {% if category and category.image_path == item.path %}checked{% endif %}>
<img src="{{ item.path }}" alt="">
<span>{{ item.name }}</span>
</label>
{% endfor %}
</div>
{% else %}
<div class="muted image-library-empty">{{ t('images.library_empty', 'Noch keine Standardbilder vorhanden.') }}</div>
{% endif %}
<label class="image-upload-label">{{ t('images.library_upload', 'Neues Standardbild hinzufügen und verwenden') }}
<input type="file" name="library_upload" accept="image/*" data-image-library-upload>
</label>
<small class="muted">{{ t('images.library_upload_help', 'Das Bild wird einmal in der gemeinsamen Bibliothek gespeichert und kann danach bei weiteren Assets und Kategorien wiederverwendet werden.') }}</small>
</div>
</div>
<h2>{{ t('categories.fields_title') }}</h2>
<p class="muted">Labels, descriptions, data types and read-only settings are managed centrally under Settings → Fields.</p>
<table class="data-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('categories.database_field') }}</th><th>{{ t('categories.display_name') }}</th><th>{{ t('categories.required') }}</th><th>{{ t('categories.in_list') }}</th><th>{{ t('categories.readonly') }}</th></tr></thead><tbody>
@@ -13,4 +45,5 @@
<td><input class="checkbox" type="checkbox" name="required_{{definition.id}}" {% if (cfg and cfg.required) or definition.field_name == 'name' %}checked{% endif %}{% if definition.readonly or definition.field_name == 'name' %} disabled{% endif %}></td>
<td><input class="checkbox" type="checkbox" name="list_{{definition.id}}" {% if (cfg and cfg.show_in_list) or definition.field_name == 'name' %}checked{% endif %}></td>
<td>{{ t('common.yes') if definition.readonly else t('common.no') }}</td></tr>{% endfor %}</tbody></table>
<button>💾 {{ t('common.save') }}</button></form>{% endblock %}
<button>💾 {{ t('common.save') }}</button></form>
{% endblock %}
+2 -2
View File
@@ -52,7 +52,7 @@
<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>
<p class="muted">{{ t('settings.mesh_icon_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>
@@ -73,7 +73,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 }})
<label class="mesh-icon-category-label"><span class="mesh-icon-category-title"><img src="/static/img/meshcentral-device-icons/icons256-{{ type_id }}-1.png" alt="" class="mesh-icon-preview">{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})</span>
<select name="mesh_category_{{ type_id }}">
<option value="">{{ t('settings.use_fallback') }}</option>
{% for category in categories %}
+1 -1
View File
@@ -1,2 +1,2 @@
APP_VERSION = "0.5.5.52"
APP_VERSION = "0.5.5.63"
__version__ = APP_VERSION
Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+3 -1
View File
@@ -4,8 +4,10 @@ set -eu
CONFIG_PATH="${APP_CONFIG:-/app/config/config.json}"
APPINFO_PATH="${APPINFO_PATH:-/app/config/APPINFO.json}"
DEFAULT_APPINFO="/app/default-config/APPINFO.json"
STANDARD_IMAGE_DIR="${STANDARD_IMAGE_DIR:-/app/app/static/uploads/library}"
mkdir -p "$(dirname "$CONFIG_PATH")" "$(dirname "$APPINFO_PATH")"
mkdir -p "$(dirname "$CONFIG_PATH")" "$(dirname "$APPINFO_PATH")" "$STANDARD_IMAGE_DIR"
echo "Standard image library: $STANDARD_IMAGE_DIR"
if [ ! -f "$CONFIG_PATH" ]; then
echo "Initializing AssetManager configuration: $CONFIG_PATH"
+47
View File
@@ -0,0 +1,47 @@
# Asset visuals
AssetManager can display a visual marker for each asset in lists and detail views.
## Display priority
The default `auto` mode uses the following order:
1. asset-specific image
2. category image
3. MeshCentral device icon (`mesh_icon`)
4. neutral placeholder
An administrator can override this per asset and explicitly select the asset image, category image, or MeshCentral icon. If the selected source is not available, no alternative source is forced; this makes manual overrides predictable.
## Category images
Categories can already store an uploaded image. In 0.5.5.54 this image is also used consistently as the category icon in the asset list and asset detail view. Category images are therefore the preferred way to represent asset types that MeshCentral does not cover, such as monitors, keyboards, headsets, mice, cameras, NAS systems, modems, firewalls, printers, switches, routers, keys, or RFID items.
## MeshCentral icons
MeshCentral reports its device icon as `node.icon`; AssetManager stores this in the `mesh_icon` system field. The field uses the normal MeshCentral mapping update policy (`fill empty`, `MeshCentral wins`, or `local wins`). Display source selection is independent from synchronization policy.
The bundled MeshCentral device icons are third-party assets and remain documented in `THIRD_PARTY_NOTICES.md`.
## Upload lifecycle and shared references
Uploaded asset/category images use unique UUID-based filenames. The original client filename is therefore never used as the storage key and cannot overwrite an existing upload.
Image removal and replacement only change the database reference. The physical upload is deliberately retained because duplicated categories or other records may still reference the same file. A future maintenance task may identify and remove upload files that are no longer referenced by any record.
## Standard image library
Version 0.5.5.60 adds a reusable standard image library for assets and categories.
- Persistent Docker host directory: `./data/uploads/library`
- Container directory: `/app/app/static/uploads/library`
- Public application path: `/static/uploads/library/`
- Supported formats: PNG, JPG/JPEG, WEBP and GIF
- Existing library images can be selected directly in the asset and category forms.
- A new image can be uploaded to the shared library from either form and is then reusable.
- Library images are referenced, not copied per asset/category.
- Removing or replacing an association never deletes the physical library file.
- Uploads never overwrite an existing file. If a name already exists, a numeric suffix is added (`switch-1.png`, `switch-2.png`, ...).
- Administrators may also place supported image files directly into the persistent host directory.
Orphaned or unused library images are intentionally retained. A later maintenance function can safely identify and remove unreferenced files.
+55
View File
@@ -231,3 +231,58 @@ Check:
### A configuration file is missing
If `config.json` or `APPINFO.json` is absent, restart the application container. The entrypoint creates a missing default file without overwriting an existing one.
### Persistent standard image library
Add the following persistent volume to the application service:
```yaml
- ./data/uploads:/app/app/static/uploads
```
Create the host directory before the first start if desired:
```bash
mkdir -p data/uploads/library
```
Images uploaded through the AssetManager standard image library are stored there. You can also copy PNG, JPG/JPEG, WEBP or GIF files directly into this directory. They are then offered for selection in asset and category forms after the page is reloaded.
## Standard image library
Reusable asset/category images are stored persistently on the Docker host in:
```text
./data/uploads/library
```
The library is a subdirectory of the already existing uploads volume. No additional Docker mount is required. The existing Compose mapping:
```text
./data/uploads -> /app/app/static/uploads
```
therefore exposes the library as:
```text
Host: ./data/uploads/library
Container: /app/app/static/uploads/library
```
Images may be uploaded from the AssetManager forms or copied directly into `./data/uploads/library` on the Docker host. They remain persistent because `data/uploads` is already part of the standard AssetManager volume layout.
For image-based installations use:
```bash
docker compose down
docker compose pull
docker compose up -d
```
You can verify the active mount with:
```bash
```
Images copied directly into `./data/uploads/library` are available in the AssetManager image library after reloading the page.
+17
View File
@@ -0,0 +1,17 @@
# AssetManager 0.5.5.53
## MeshCentral device icon integration
- Separates the MeshCentral group type (`mtype`) from the per-device `node.icon` / agent platform type.
- Stores the MeshCentral device icon in the new read-only `mesh_icon` asset field.
- Uses `mesh_icon` for category assignment when new assets are created from MeshCentral; existing category mapping values 1-8 remain compatible.
- Repairs only the known historical system mapping that combined `mtype` and `icon`; user-created field mappings are left unchanged.
- Shows MeshCentral device icon previews in the category mapping UI.
- Adds a visual device column to the asset table directly after the row number: an uploaded asset image is preferred, otherwise the corresponding MeshCentral device icon is shown.
- Redistributes the MeshCentral device icon assets used by this feature under the upstream Apache License 2.0; see `THIRD_PARTY_NOTICES.md`.
## Compatibility
- Existing assets are not automatically recategorized by this change.
- Run a MeshCentral synchronization to populate `mesh_icon` for existing linked assets.
- The existing `mesh_mtype` field remains available and now explicitly represents the MeshCentral group type only.
+10
View File
@@ -0,0 +1,10 @@
# AssetManager 0.5.5.54
## Asset visual sources
- Added a persistent per-asset display source setting.
- Default display order is asset image, category image, MeshCentral icon, then placeholder.
- Added explicit per-asset choices for asset image, category image, or MeshCentral icon.
- Category images are now used consistently in the asset list as well as the asset detail view.
- Category edit view now shows the current category image and allows clearing it.
- Added documentation for the visual-source model and its relationship to MeshCentral field update rules.
+10
View File
@@ -0,0 +1,10 @@
# AssetManager 0.5.5.55
## Changes
- Asset images are visible while editing an asset and can be removed explicitly.
- Replacing an asset image removes the previous uploaded file.
- Category images are displayed in the category table.
- Column resizing is available centrally for regular application tables, with persistent widths per table and column.
- Category table columns use stable storage keys for persistent widths.
- Dashboard category images use contained scaling so images are not cropped in their preview frame.
+7
View File
@@ -0,0 +1,7 @@
# AssetManager 0.5.5.56
- Uses the same transparent image presentation in asset and category tables.
- Asset and category edit forms now use the same immediate image-removal button workflow.
- Replacing or removing a category image removes the superseded upload file.
- Table resize handles are kept inside each header cell and pointer tracking is handled globally, improving column resizing on management tables such as Categories, Translations, Status Values and Fields.
- Dashboard/category images use `object-fit: contain` so the complete image remains visible.
+6
View File
@@ -0,0 +1,6 @@
# AssetManager 0.5.5.57
- Fixed column resizing on management tables such as categories, translations, status values, fields, and dashboard/recent-job tables by routing all non-asset tables through the shared resize implementation.
- Added permanently visible column separators and a larger resize hit area.
- Unified asset and category image removal behavior. The preview now disappears immediately, a pending-removal message is shown, and the removal is applied on save.
- Added a shared image editor script for consistent behavior across asset and category forms.
+9
View File
@@ -0,0 +1,9 @@
# AssetManager 0.5.5.58
## Table column resizing
- Manual column resizing now keeps all intermediate columns unchanged.
- When a non-rightmost column is resized, only the far-right visible column absorbs the width difference.
- The far-right column keeps a minimum width to prevent collapsing.
- Resizing the far-right column itself changes the overall table width and can therefore produce horizontal scrolling.
- Both changed widths are persisted per table and column.
+13
View File
@@ -0,0 +1,13 @@
# AssetManager 0.5.5.59
## Image association safety
- Removing or replacing an asset/category image now removes only the database association.
- Physical upload files are intentionally retained because duplicated records can reference the same file.
- Orphaned image files are reserved for a later maintenance cleanup task.
- Uploads continue to use unique UUID-based filenames, so files with the same original filename never overwrite one another and the same source image can be uploaded multiple times safely.
## Image editor preview
- After an image is marked for removal, selecting a replacement immediately previews the newly selected local image instead of restoring the old server image.
- The removal marker is cleared automatically when a replacement image is selected.
+11
View File
@@ -0,0 +1,11 @@
# AssetManager 0.5.5.60
## Reusable standard image library
- Added a shared standard image library for assets and categories.
- Existing images can be selected from a thumbnail gallery instead of uploading the same image repeatedly.
- New standard images can be added once directly from an asset or category form.
- Standard images use the persistent Docker directory `./data/image-library` mounted at `/app/app/static/img/library`.
- Standard-library images are referenced and are never deleted when an asset/category association is removed or replaced.
- Duplicate upload names never overwrite existing files; a numeric suffix is added automatically.
- Direct per-record uploads remain available and continue to use unique UUID file names.
+10
View File
@@ -0,0 +1,10 @@
# AssetManager 0.5.5.61
## Standard image library path and upload workflow
- Clarified that the persistent standard image library lives in `./data/image-library` on the Docker host.
- The bind mount exposes that same directory at `/app/app/static/img/library` inside the application container; this is not a second copy.
- Added explicit `STANDARD_IMAGE_DIR` configuration and startup creation/logging of the active library directory.
- Removed the direct per-record image upload controls from asset and category forms. New images are added to the shared library and can then be reused.
- Existing legacy `/static/uploads/...` references remain supported for backward compatibility.
- Documentation now explains that adding a new bind mount requires recreating the container, not only restarting it.
+9
View File
@@ -0,0 +1,9 @@
# Update 0.5.5.62
## Standard image library without an additional Docker mount
- Removed the dedicated `data/image-library` bind mount introduced in 0.5.5.60.
- Reuses the existing persistent uploads volume.
- Standard images are stored in `data/uploads/library` on the Docker host and `/app/app/static/uploads/library` in the container.
- No duplicate image storage and no additional Compose volume entry are required.
- Removed the misleading source-tree `app/static/img/library` directory.