Initialer Import des AssetManagers

This commit is contained in:
2026-08-02 21:28:50 +00:00
commit ed72ce8821
256 changed files with 26361 additions and 0 deletions
View File
+337
View File
@@ -0,0 +1,337 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import threading
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from .version import APP_VERSION
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups"))
CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config/config.json"))
APPINFO_PATH = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json"))
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/app/static/uploads"))
BACKUP_INTERVAL_HOURS = max(1, int(os.getenv("BACKUP_INTERVAL_HOURS", "8")))
BACKUP_RETENTION_DAYS = max(1, int(os.getenv("BACKUP_RETENTION_DAYS", "3")))
BACKUP_PREFIX = "assetmanager-backup-"
BACKUP_SUFFIX = ".zip"
_LOCK = threading.Lock()
def _database_url() -> str:
value = os.getenv("DATABASE_URL", "")
if value.startswith("postgresql+psycopg://"):
value = "postgresql://" + value[len("postgresql+psycopg://"):]
elif value.startswith("postgres+psycopg://"):
value = "postgresql://" + value[len("postgres+psycopg://"):]
return value
def _safe_database_url() -> str:
value = _database_url()
try:
parsed = urlsplit(value)
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
username = parsed.username or ""
auth = f"{username}@" if username else ""
return urlunsplit((parsed.scheme, f"{auth}{host}{port}", parsed.path, "", ""))
except Exception:
return "postgresql://"
def ensure_backup_dir() -> Path:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
return BACKUP_DIR
def _is_mount_point(path: Path) -> bool:
"""Best-effort check whether path is a distinct container mount."""
try:
resolved = path.resolve()
if resolved.is_mount():
return True
mountinfo = Path("/proc/self/mountinfo")
if mountinfo.is_file():
target = str(resolved)
for line in mountinfo.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split()
if len(parts) > 4 and parts[4].replace("\\040", " ") == target:
return True
except OSError:
pass
return False
def _directory_status(path: Path, create: bool = False) -> dict:
error = ""
writable = False
exists = path.exists()
try:
if create:
path.mkdir(parents=True, exist_ok=True)
exists = True
if exists and path.is_dir():
with tempfile.NamedTemporaryFile(prefix=".assetmanager-write-test-", dir=path, delete=True):
writable = True
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
return {
"path": str(path),
"exists": exists,
"writable": writable,
"is_mount": _is_mount_point(path) if exists else False,
"error": error,
}
def system_storage_information(log_dir: Path | None = None) -> dict:
backups = list_backups()
total_size = sum(int(item.get("size") or 0) for item in backups)
latest = backups[0] if backups else None
return {
"database_connected": True,
"config": _directory_status(CONFIG_PATH.parent),
"uploads": _directory_status(UPLOAD_DIR),
"logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", "/app/data/logs"))),
"backups": _directory_status(BACKUP_DIR, create=True),
"backup_count": len(backups),
"backup_total_size": total_size,
"latest_backup": latest,
}
def validate_backup_name(name: str) -> str:
candidate = Path(name).name
if candidate != name or not candidate.startswith(BACKUP_PREFIX) or not candidate.endswith(BACKUP_SUFFIX):
raise ValueError("invalid backup name")
return candidate
def backup_path(name: str) -> Path:
return ensure_backup_dir() / validate_backup_name(name)
def _run(command: list[str]) -> None:
process = subprocess.run(command, capture_output=True, text=True)
if process.returncode != 0:
detail = (process.stderr or process.stdout or "command failed").strip()
raise RuntimeError(detail[-2000:])
def create_backup(created_by: str = "system", reason: str = "manual") -> dict:
with _LOCK:
directory = ensure_backup_dir()
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y%m%d-%H%M%S")
final_path = directory / f"{BACKUP_PREFIX}{timestamp}{BACKUP_SUFFIX}"
sequence = 1
while final_path.exists():
final_path = directory / f"{BACKUP_PREFIX}{timestamp}-{sequence}{BACKUP_SUFFIX}"
sequence += 1
with tempfile.TemporaryDirectory(prefix="assetmanager-backup-") as temp_name:
temp = Path(temp_name)
dump_path = temp / "database.dump"
database_url = _database_url()
if not database_url:
raise RuntimeError("DATABASE_URL is not configured")
_run(["pg_dump", "--format=custom", "--no-owner", "--no-privileges", "--file", str(dump_path), database_url])
included: list[str] = ["database.dump"]
files_dir = temp / "files"
files_dir.mkdir()
if CONFIG_PATH.is_file():
shutil.copy2(CONFIG_PATH, files_dir / "config.json")
included.append("files/config.json")
if APPINFO_PATH.is_file():
shutil.copy2(APPINFO_PATH, files_dir / "APPINFO.json")
included.append("files/APPINFO.json")
if UPLOAD_DIR.is_dir():
target = files_dir / "uploads"
shutil.copytree(UPLOAD_DIR, target)
included.append("files/uploads/")
metadata = {
"format": 1,
"application": "AssetManager",
"version": _read_version(),
"created_at": now.isoformat(),
"created_by": created_by or "system",
"reason": reason,
"database": _safe_database_url(),
"included": included,
}
(temp / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(temp / "VERSION").write_text(metadata["version"] + "\n", encoding="utf-8")
included.append("VERSION")
temporary_zip = final_path.with_suffix(".zip.part")
try:
with zipfile.ZipFile(temporary_zip, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for path in sorted(temp.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(temp))
temporary_zip.replace(final_path)
finally:
temporary_zip.unlink(missing_ok=True)
cleanup_backups()
return describe_backup(final_path)
def _read_version() -> str:
for candidate in (Path("/app/VERSION"), Path(__file__).resolve().parent.parent / "VERSION", Path(__file__).resolve().parent / "VERSION"):
try:
value = candidate.read_text(encoding="utf-8").strip()
if value and value.casefold() != "unknown":
return value
except OSError:
pass
return APP_VERSION
def _archive_version(archive: zipfile.ZipFile, metadata: dict) -> str:
value = str(metadata.get("version") or "").strip()
if value and value.casefold() != "unknown":
return value
names = set(archive.namelist())
for name in ("VERSION", "files/VERSION", "assetmanager/VERSION"):
if name in names:
try:
value = archive.read(name).decode("utf-8", errors="replace").strip()
if value and value.casefold() != "unknown":
return value
except Exception:
pass
for name in ("files/APPINFO.json", "APPINFO.json"):
if name in names:
try:
value = str(json.loads(archive.read(name).decode("utf-8")).get("version") or "").strip()
if value and value.casefold() != "unknown":
return value
except Exception:
pass
return ""
def _metadata(path: Path) -> dict:
try:
with zipfile.ZipFile(path) as archive:
metadata = json.loads(archive.read("metadata.json").decode("utf-8"))
if not isinstance(metadata, dict):
return {}
metadata = dict(metadata)
metadata["version"] = _archive_version(archive, metadata)
return metadata
except Exception:
return {}
def describe_backup(path: Path) -> dict:
stat = path.stat()
metadata = _metadata(path)
return {
"name": path.name,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc),
"created_at": metadata.get("created_at", ""),
"created_by": metadata.get("created_by", ""),
"reason": metadata.get("reason", ""),
"version": metadata.get("version", ""),
"valid": bool(metadata),
}
def list_backups() -> list[dict]:
directory = ensure_backup_dir()
items = [describe_backup(path) for path in directory.glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}") if path.is_file()]
return sorted(items, key=lambda item: item["modified_at"], reverse=True)
def cleanup_backups() -> int:
threshold = datetime.now(timezone.utc) - timedelta(days=BACKUP_RETENTION_DAYS)
removed = 0
for item in list_backups():
if item["modified_at"] < threshold:
backup_path(item["name"]).unlink(missing_ok=True)
removed += 1
return removed
def delete_backup(name: str) -> None:
backup_path(name).unlink()
def store_uploaded_backup(filename: str, content: bytes) -> dict:
ensure_backup_dir()
if len(content) < 100:
raise ValueError("backup file is empty")
with tempfile.NamedTemporaryFile(prefix="backup-upload-", suffix=".zip", delete=False, dir=BACKUP_DIR) as handle:
temp_path = Path(handle.name)
handle.write(content)
try:
with zipfile.ZipFile(temp_path) as archive:
names = set(archive.namelist())
if "metadata.json" not in names or "database.dump" not in names:
raise ValueError("unsupported backup archive")
metadata = json.loads(archive.read("metadata.json").decode("utf-8"))
if metadata.get("application") != "AssetManager":
raise ValueError("unsupported backup archive")
now = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
target = BACKUP_DIR / f"{BACKUP_PREFIX}{now}-upload{BACKUP_SUFFIX}"
counter = 1
while target.exists():
target = BACKUP_DIR / f"{BACKUP_PREFIX}{now}-upload-{counter}{BACKUP_SUFFIX}"
counter += 1
temp_path.replace(target)
return describe_backup(target)
finally:
temp_path.unlink(missing_ok=True)
def restore_backup(name: str) -> dict:
path = backup_path(name)
with _LOCK, tempfile.TemporaryDirectory(prefix="assetmanager-restore-") as temp_name:
temp = Path(temp_name)
with zipfile.ZipFile(path) as archive:
archive.extractall(temp)
dump_path = temp / "database.dump"
if not dump_path.is_file():
raise ValueError("database dump missing")
database_url = _database_url()
_run(["pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", database_url, str(dump_path)])
files = temp / "files"
if (files / "config.json").is_file():
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(files / "config.json", CONFIG_PATH)
if (files / "APPINFO.json").is_file():
APPINFO_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(files / "APPINFO.json", APPINFO_PATH)
if (files / "uploads").is_dir():
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
for child in UPLOAD_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
shutil.copytree(files / "uploads", UPLOAD_DIR, dirs_exist_ok=True)
return _metadata(path)
def automatic_backup_loop(stop_event: threading.Event) -> None:
# Wait after application start; do not create a backup during every restart.
while not stop_event.wait(BACKUP_INTERVAL_HOURS * 3600):
try:
create_backup(created_by="system", reason="automatic")
except Exception:
# Main application logger records route errors; scheduler must stay alive.
pass
+114
View File
@@ -0,0 +1,114 @@
import json
import os
from pathlib import Path
from typing import Any
CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config.json"))
DEFAULT_CONFIG: dict[str, Any] = {
"general": {
"title": "AssetManager",
"company_name": "",
"logo": "",
"custom_css": "",
},
"authentication": {
"mode": "none",
"session_secret_env": "SESSION_SECRET",
"ldap": {
"server": "",
"port": 389,
"use_ssl": False,
"start_tls": False,
"bind_dn": "",
"bind_password_env": "LDAP_BIND_PASSWORD",
"user_base_dn": "",
"user_filter": "(sAMAccountName={username})",
"display_name_attribute": "displayName",
"email_attribute": "mail"
}
},
"software": {
"dispatch_delay_seconds": 2,
"callback_base_url": "",
"callback_timeout_minutes": 30,
"callback_worker_count": 4,
"callback_test_timeout_seconds": 10,
"callback_test_verify_tls": True,
"debug_logging": True,
"debug_log_max_lines": 1000,
"automatic_retry_enabled": True,
"automatic_retry_max_age_hours": 12,
"automatic_retry_interval_seconds": 60,
"automatic_retry_max_retries": 3,
"inventory_exclusion_rules": []
},
"meshcentral": {
"enabled": False,
"url": "",
"username": "",
"password": "",
"password_env": "MESHCENTRAL_PASSWORD",
"tenant": "",
"meshctrl_path": "/opt/meshcentral/node_modules/meshcentral/meshctrl.js",
"verify_tls": True,
"fallback_category_id": 1,
"category_mapping": {"1": 1, "2": 1, "3": 1, "4": 1, "5": 1, "6": 1, "7": 1, "8": 1},
"timeout_seconds": 120,
"presence_enabled": True,
"presence_interval_seconds": 30,
"match_order": ["node_id", "serial_number"],
"create_missing_assets": True,
"mark_missing_devices": True,
"field_rules": {},
},
}
def _merge(base: dict[str, Any], supplied: dict[str, Any]) -> dict[str, Any]:
for section, values in supplied.items():
if isinstance(values, dict) and isinstance(base.get(section), dict):
base[section].update(values)
else:
base[section] = values
return base
def load_config() -> dict[str, Any]:
config = json.loads(json.dumps(DEFAULT_CONFIG))
if CONFIG_PATH.exists():
with CONFIG_PATH.open("r", encoding="utf-8") as handle:
supplied = json.load(handle)
_merge(config, supplied)
mesh = config["meshcentral"]
env_name = mesh.get("password_env")
if env_name and os.getenv(env_name):
mesh["password"] = os.getenv(env_name)
return config
def save_config(config: dict[str, Any]) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
current: dict[str, Any] = {}
if CONFIG_PATH.exists():
with CONFIG_PATH.open("r", encoding="utf-8") as handle:
current = json.load(handle)
_merge(current, config)
temporary = CONFIG_PATH.with_suffix(CONFIG_PATH.suffix + ".tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(current, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temporary.replace(CONFIG_PATH)
def public_config() -> dict[str, Any]:
config = load_config()
mesh = dict(config["meshcentral"])
mesh["password"] = "***" if mesh.get("password") else ""
return {
"config_path": str(CONFIG_PATH),
"general": dict(config.get("general", {})),
"authentication": dict(config.get("authentication", {})),
"software": dict(config.get("software", {})),
"meshcentral": mesh,
}
+17
View File
@@ -0,0 +1,17 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./assetmanager.db")
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, pool_pre_ping=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+56
View File
@@ -0,0 +1,56 @@
ASSET_FIELDS = {
"asset_tag": "Inventarnummer",
"name": "Bezeichnung",
"manufacturer": "Hersteller",
"model": "Modell",
"serial_number": "Seriennummer",
"status": "Status",
"assigned_to": "Zugeordnet an",
"assigned_on": "Ausgegeben am",
"location": "Standort",
"room_number": "Raumnummer",
"parent_asset_id": "Übergeordnetes Gerät",
"department": "Abteilung",
"purchase_date": "Kaufdatum",
"warranty_until": "Garantie bis",
"notes": "Notizen",
"hostname": "Computername",
"operating_system": "Betriebssystem",
"os_patch_level": "OS-Patchlevel",
"mac_address": "MAC-Adresse",
"ip_address": "IP-Adresse",
"cpu": "CPU",
"memory_gb": "Arbeitsspeicher (GB)",
"storage_gb": "Speicher gesamt (GB)",
"storage_details": "Datenträger",
"gpu_name": "Grafikkarte",
"tpm_version": "TPM-Version",
"memory_details": "Arbeitsspeicher-Module",
"antivirus": "Aktiver Virenschutz",
"mesh_group": "MeshCentral-Gruppe",
"mesh_node_id": "MeshCentral Node-ID",
"mesh_online": "Online",
"mesh_mtype": "MeshCentral device type (mtype)",
}
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"]
FIELD_DATA_TYPES = {
"short_text": "Short text",
"long_text": "Long text",
"integer": "Integer",
"decimal": "Decimal number",
"boolean": "Boolean",
"date": "Date",
"datetime": "Date and time",
"url": "URL",
"email": "E-mail address",
"ip": "IP address",
"json": "JSON",
"asset_reference": "Asset reference",
"user_reference": "User reference",
"status": "Status",
}
SYSTEM_READONLY_FIELDS = {"mesh_node_id", "mesh_online", "mesh_mtype"}
+1193
View File
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy.orm import Session
from .models import AssetJobState, SoftwareJob
TERMINAL_JOB_STATES = {"success", "failed", "partial", "timeout", "cancelled"}
def _int_or_none(value: Any) -> int | None:
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def job_definition_id(job: SoftwareJob) -> int | None:
if str(job.job_type or "") != "job_definition":
return None
parameters = job.parameters if isinstance(job.parameters, dict) else {}
return _int_or_none(parameters.get("definition_id"))
def job_definition_revision(job: SoftwareJob) -> int | None:
if str(job.job_type or "") != "job_definition":
return None
parameters = job.parameters if isinstance(job.parameters, dict) else {}
return _int_or_none(parameters.get("definition_revision"))
def job_state_key(job: SoftwareJob) -> str:
job_type = str(job.job_type or "software_inventory").strip() or "software_inventory"
if job_type == "job_definition":
definition_id = job_definition_id(job)
return f"job_definition:{definition_id}" if definition_id else f"job_definition:job:{job.id}"
return job_type[:120]
def filter_state_key(job_type: str, definition_id: int | None = None) -> str:
normalized = str(job_type or "").strip()
if normalized == "job_definition" and definition_id:
return f"job_definition:{int(definition_id)}"
return normalized[:120]
def sync_asset_job_state(
db: Session,
job: SoftwareJob,
*,
increment_execution: bool = False,
status_at: datetime | None = None,
) -> AssetJobState:
"""Update the compact per-asset state for one job.
This table is an intentionally small projection of ``software_jobs``. The
full job history remains authoritative; this projection exists only for
fast filters, dashboards and the later software-package desired-state
module.
"""
state_key = job_state_key(job)
state = (
db.query(AssetJobState)
.filter(AssetJobState.asset_id == job.asset_id, AssetJobState.state_key == state_key)
.with_for_update()
.first()
)
now = status_at or datetime.utcnow()
if state is None:
state = AssetJobState(
asset_id=job.asset_id,
state_key=state_key,
job_type=str(job.job_type or "software_inventory")[:50],
job_definition_id=job_definition_id(job),
execution_count=0,
)
db.add(state)
db.flush()
revision = job_definition_revision(job)
state.job_type = str(job.job_type or "software_inventory")[:50]
state.job_definition_id = job_definition_id(job)
state.definition_revision = revision
state.last_job_id = job.id
state.last_status = str(job.status or "created")[:30]
state.last_status_at = now
state.last_created_at = job.created_at
state.last_sent_at = job.sent_at
state.last_started_at = job.started_at
state.last_completed_at = job.finished_at or job.callback_completed_at
state.updated_at = now
if increment_execution:
state.execution_count = int(state.execution_count or 0) + 1
elif int(state.execution_count or 0) <= 0:
state.execution_count = max(1, int(job.attempt_count or 0))
if state.last_status == "success":
state.last_success_at = job.finished_at or job.callback_completed_at or now
state.last_success_revision = revision
return state
def backfill_asset_job_states(db: Session) -> int:
"""Create compact states from historic jobs once after the table is added."""
if db.query(AssetJobState.id).limit(1).first() is not None:
return 0
rows = (
db.query(
SoftwareJob.id,
SoftwareJob.asset_id,
SoftwareJob.job_type,
SoftwareJob.parameters,
SoftwareJob.status,
SoftwareJob.attempt_count,
SoftwareJob.created_at,
SoftwareJob.sent_at,
SoftwareJob.started_at,
SoftwareJob.finished_at,
SoftwareJob.callback_completed_at,
)
.order_by(SoftwareJob.created_at.asc(), SoftwareJob.id.asc())
.all()
)
if not rows:
return 0
states: dict[tuple[int, str], dict[str, Any]] = {}
for row in rows:
parameters = row.parameters if isinstance(row.parameters, dict) else {}
definition_id = _int_or_none(parameters.get("definition_id")) if row.job_type == "job_definition" else None
revision = _int_or_none(parameters.get("definition_revision")) if row.job_type == "job_definition" else None
state_key = (
f"job_definition:{definition_id}"
if row.job_type == "job_definition" and definition_id
else (str(row.job_type or "software_inventory")[:120])
)
key = (int(row.asset_id), state_key)
current = states.get(key)
if current is None:
current = {
"asset_id": int(row.asset_id),
"state_key": state_key,
"job_type": str(row.job_type or "software_inventory")[:50],
"job_definition_id": definition_id,
"definition_revision": revision,
"last_success_revision": None,
"last_job_id": int(row.id),
"last_status": str(row.status or "created")[:30],
"last_status_at": row.finished_at or row.callback_completed_at or row.started_at or row.sent_at or row.created_at or datetime.utcnow(),
"last_created_at": row.created_at,
"last_sent_at": row.sent_at,
"last_started_at": row.started_at,
"last_completed_at": row.finished_at or row.callback_completed_at,
"last_success_at": None,
"execution_count": 0,
"updated_at": datetime.utcnow(),
}
states[key] = current
current.update({
"job_type": str(row.job_type or "software_inventory")[:50],
"job_definition_id": definition_id,
"definition_revision": revision,
"last_job_id": int(row.id),
"last_status": str(row.status or "created")[:30],
"last_status_at": row.finished_at or row.callback_completed_at or row.started_at or row.sent_at or row.created_at or datetime.utcnow(),
"last_created_at": row.created_at,
"last_sent_at": row.sent_at,
"last_started_at": row.started_at,
"last_completed_at": row.finished_at or row.callback_completed_at,
"updated_at": datetime.utcnow(),
})
current["execution_count"] = int(current.get("execution_count") or 0) + max(1, int(row.attempt_count or 0))
if str(row.status or "") == "success":
success_at = row.finished_at or row.callback_completed_at or row.started_at or row.sent_at or row.created_at
if success_at and (current.get("last_success_at") is None or success_at >= current["last_success_at"]):
current["last_success_at"] = success_at
current["last_success_revision"] = revision
db.bulk_insert_mappings(AssetJobState, list(states.values()))
db.commit()
return len(states)
+7581
View File
File diff suppressed because it is too large Load Diff
+1351
View File
File diff suppressed because it is too large Load Diff
+1068
View File
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
from sqlalchemy import inspect, text
from .database import engine
USER_COLUMNS = {
"allowed_mesh_groups": "JSON DEFAULT '[]'::json",
"asset_access_scopes": "JSON DEFAULT '[]'::json",
"profile_department": "VARCHAR(150) NULL",
"profile_location": "VARCHAR(150) NULL",
"language_code": "VARCHAR(10) DEFAULT 'en'",
"persist_browser_state": "BOOLEAN DEFAULT FALSE",
"toast_position": "VARCHAR(30) DEFAULT 'top-right'",
"toast_duration_seconds": "INTEGER DEFAULT 6",
"theme_mode": "VARCHAR(20) DEFAULT 'light'",
"is_protected": "BOOLEAN NOT NULL DEFAULT FALSE",
}
CHART_COLUMNS = {
"value_mode": "VARCHAR(30) DEFAULT 'raw'",
"value_mappings": "JSON DEFAULT '[]'::json",
"excluded_statuses": "JSON DEFAULT '[]'::json",
"is_system": "BOOLEAN DEFAULT FALSE",
}
FIELD_DEFINITION_COLUMNS = {
"is_default": "BOOLEAN NOT NULL DEFAULT FALSE",
"list_width": "INTEGER NULL",
}
CATEGORY_FIELD_COLUMNS = {
"readonly": "BOOLEAN DEFAULT FALSE",
"field_definition_id": "INTEGER NULL REFERENCES field_definitions(id) ON DELETE CASCADE",
}
STATUS_COLUMNS = {
"use_for_issue": "BOOLEAN DEFAULT FALSE",
"use_for_return": "BOOLEAN DEFAULT FALSE",
}
MESH_MAPPING_COLUMNS = {
"update_rule": "VARCHAR(30) NOT NULL DEFAULT 'fill_empty'",
"is_system": "BOOLEAN NOT NULL DEFAULT FALSE",
}
INSTALLED_SOFTWARE_COLUMNS = {
"platform": "VARCHAR(40) NOT NULL DEFAULT 'unknown'",
}
JOB_DEFINITION_COLUMNS = {
"job_kind": "VARCHAR(30) NOT NULL DEFAULT 'script'",
"configuration": "JSON DEFAULT '{}'::json",
}
SOFTWARE_JOB_COLUMNS = {
"action": "VARCHAR(30) NOT NULL DEFAULT 'run'",
"job_type": "VARCHAR(50) NOT NULL DEFAULT 'software_inventory'",
"priority": "INTEGER NOT NULL DEFAULT 100",
"result_data": "JSON DEFAULT '{}'::json",
"attempt_count": "INTEGER NOT NULL DEFAULT 0",
"max_attempts": "INTEGER NOT NULL DEFAULT 1",
"scheduled_at": "TIMESTAMP NULL",
"resolved_script": "TEXT NULL",
}
ASSET_COLUMNS = {
"mesh_last_seen": "TIMESTAMP NULL",
"mesh_online": "BOOLEAN NULL",
"mesh_online_state": "VARCHAR(20) NOT NULL DEFAULT 'unknown'",
"mesh_presence_updated_at": "TIMESTAMP NULL",
"mesh_last_online_at": "TIMESTAMP NULL",
"mesh_presence_source": "VARCHAR(40) NULL",
"mesh_sync_status": "VARCHAR(30) NULL",
"mesh_sync_message": "TEXT NULL",
"mesh_conflicts": "JSON DEFAULT '{}'::json",
"mesh_source_data": "JSON DEFAULT '{}'::json",
"mesh_hardware_data": "JSON DEFAULT '{}'::json",
"mesh_software_data": "JSON DEFAULT '[]'::json",
"mesh_inventory_updated_at": "TIMESTAMP NULL",
"storage_details": "TEXT NULL",
"gpu_name": "TEXT NULL",
"tpm_version": "VARCHAR(50) NULL",
"memory_details": "TEXT NULL",
"antivirus": "TEXT NULL",
"mesh_group": "VARCHAR(180) NULL",
"assigned_on": "VARCHAR(20) NULL",
"room_number": "VARCHAR(80) NULL",
"parent_asset_id": "INTEGER NULL REFERENCES assets(id) ON DELETE SET NULL",
"mesh_mtype": "INTEGER NULL",
}
def apply_lightweight_migrations() -> None:
inspector = inspect(engine)
if "assets" not in inspector.get_table_names():
return
existing = {column["name"] for column in inspector.get_columns("assets")}
with engine.begin() as connection:
for name, ddl in ASSET_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE assets ADD COLUMN "{name}" {ddl}'))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_sync_status ON assets (mesh_sync_status)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_online ON assets (mesh_online)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_assets_mesh_online_state ON assets (mesh_online_state)"))
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)"))
inspector = inspect(engine)
with engine.begin() as connection:
if "users" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("users")}
for name, ddl in USER_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE users ADD COLUMN "{name}" {ddl}'))
if "field_definitions" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("field_definitions")}
for name, ddl in FIELD_DEFINITION_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE field_definitions ADD COLUMN "{name}" {ddl}'))
connection.execute(text("""
UPDATE field_definitions
SET is_default = TRUE
WHERE field_name IN (
'asset_tag','name','manufacturer','model','serial_number',
'status','assigned_to','assigned_on','location','room_number','notes'
)
AND is_default = FALSE
"""))
if "category_fields" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("category_fields")}
for name, ddl in CATEGORY_FIELD_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE category_fields ADD COLUMN "{name}" {ddl}'))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_category_fields_field_definition_id ON category_fields (field_definition_id)"))
if "status_options" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("status_options")}
for name, ddl in STATUS_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE status_options ADD COLUMN "{name}" {ddl}'))
if "mesh_field_mappings" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("mesh_field_mappings")}
for name, ddl in MESH_MAPPING_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE mesh_field_mappings ADD COLUMN "{name}" {ddl}'))
if "installed_software" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("installed_software")}
for name, ddl in INSTALLED_SOFTWARE_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE installed_software ADD COLUMN "{name}" {ddl}'))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_platform ON installed_software (platform)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_summary ON installed_software (platform, name, publisher)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_summary_version ON installed_software (name, publisher, platform, version)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_installed_software_list ON installed_software (name, version, asset_id, id)"))
if "software_jobs" in inspector.get_table_names():
connection.execute(text("""
UPDATE installed_software AS software
SET platform = COALESCE(
NULLIF((
SELECT LOWER(software_jobs.platform)
FROM software_jobs
WHERE software_jobs.asset_id = software.asset_id
ORDER BY software_jobs.created_at DESC
LIMIT 1
), ''),
'unknown'
)
WHERE software.platform IS NULL OR software.platform = '' OR software.platform = 'unknown'
"""))
if "job_definitions" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("job_definitions")}
for name, ddl in JOB_DEFINITION_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE job_definitions ADD COLUMN "{name}" {ddl}'))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_job_definitions_job_kind ON job_definitions (job_kind)"))
connection.execute(text("UPDATE job_definitions SET job_kind = 'script' WHERE job_kind IS NULL OR job_kind = ''"))
if "software_jobs" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("software_jobs")}
for name, ddl in SOFTWARE_JOB_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE software_jobs ADD COLUMN "{name}" {ddl}'))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_action ON software_jobs (action)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_job_type ON software_jobs (job_type)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_priority ON software_jobs (priority)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_scheduled_at ON software_jobs (scheduled_at)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_recent ON software_jobs (created_at DESC, id DESC)"))
connection.execute(text("CREATE INDEX IF NOT EXISTS ix_software_jobs_timeout_scan ON software_jobs (callback_completed_at, callback_expires_at, status)"))
connection.execute(text("UPDATE software_jobs SET job_type = 'software_inventory' WHERE job_type IS NULL OR job_type = ''"))
connection.execute(text("UPDATE software_jobs SET priority = 100 WHERE priority IS NULL"))
connection.execute(text("UPDATE software_jobs SET attempt_count = 0 WHERE attempt_count IS NULL"))
connection.execute(text("UPDATE software_jobs SET max_attempts = 1 WHERE max_attempts IS NULL"))
if "chart_definitions" in inspector.get_table_names():
existing = {column["name"] for column in inspector.get_columns("chart_definitions")}
for name, ddl in CHART_COLUMNS.items():
if name not in existing:
connection.execute(text(f'ALTER TABLE chart_definitions ADD COLUMN "{name}" {ddl}'))
# Das vorhandene Standarddiagramm wird nach dem Update sofort sinnvoll zusammengefasst.
connection.execute(text("""
UPDATE chart_definitions
SET value_mode = 'automatic'
WHERE group_field = 'operating_system'
AND name = 'Betriebssysteme'
AND COALESCE(value_mode, 'raw') = 'raw'
"""))
connection.execute(text("""
UPDATE chart_definitions
SET is_system = TRUE
WHERE name IN ('Betriebssysteme', 'Geräte nach Kategorie und Status', 'Hersteller', 'Statusübersicht')
"""))
+478
View File
@@ -0,0 +1,478 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
image_path: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
assets: Mapped[list["Asset"]] = relationship(back_populates="category")
visible_fields: Mapped[list["CategoryField"]] = relationship(
back_populates="category", cascade="all, delete-orphan", order_by="CategoryField.sort_order"
)
class FieldDefinition(Base):
__tablename__ = "field_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
field_name: Mapped[str] = mapped_column(String(80), unique=True, index=True)
label: Mapped[str] = mapped_column(String(120))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
data_type: Mapped[str] = mapped_column(String(30), default="short_text")
readonly: Mapped[bool] = mapped_column(Boolean, default=False)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
translation_key: Mapped[str | None] = mapped_column(String(180), nullable=True)
searchable: Mapped[bool] = mapped_column(Boolean, default=True)
filterable: Mapped[bool] = mapped_column(Boolean, default=True)
chart_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
excel_import: Mapped[bool] = mapped_column(Boolean, default=True)
excel_export: Mapped[bool] = mapped_column(Boolean, default=True)
bulk_edit: Mapped[bool] = mapped_column(Boolean, default=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
list_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category_links: Mapped[list["CategoryField"]] = relationship(back_populates="definition", cascade="all, delete-orphan")
values: Mapped[list["AssetFieldValue"]] = relationship(back_populates="definition", cascade="all, delete-orphan")
class CategoryField(Base):
__tablename__ = "category_fields"
__table_args__ = (UniqueConstraint("category_id", "field_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"))
field_name: Mapped[str] = mapped_column(String(80))
label: Mapped[str] = mapped_column(String(100))
field_definition_id: Mapped[int | None] = mapped_column(ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=True, index=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
required: Mapped[bool] = mapped_column(Boolean, default=False)
show_in_list: Mapped[bool] = mapped_column(Boolean, default=False)
readonly: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
category: Mapped[Category] = relationship(back_populates="visible_fields")
definition: Mapped[FieldDefinition | None] = relationship(back_populates="category_links")
@property
def effective_label(self) -> str:
return self.definition.label if self.definition else self.label
@property
def effective_readonly(self) -> bool:
return bool(self.definition.readonly) if self.definition else bool(getattr(self, "readonly", False))
class Asset(Base):
__tablename__ = "assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), index=True)
asset_tag: Mapped[str | None] = mapped_column(String(100), unique=True, nullable=True, index=True)
name: Mapped[str] = mapped_column(String(150), index=True)
manufacturer: Mapped[str | None] = mapped_column(String(150), nullable=True)
model: Mapped[str | None] = mapped_column(String(150), nullable=True)
serial_number: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True)
status: Mapped[str | None] = mapped_column(String(60), nullable=True)
assigned_to: Mapped[str | None] = mapped_column(String(150), nullable=True)
assigned_on: Mapped[str | None] = mapped_column(String(20), nullable=True)
location: Mapped[str | None] = mapped_column(String(150), nullable=True)
room_number: Mapped[str | None] = mapped_column(String(80), nullable=True, index=True)
parent_asset_id: Mapped[int | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL"), nullable=True, index=True)
department: Mapped[str | None] = mapped_column(String(150), nullable=True)
purchase_date: Mapped[str | None] = mapped_column(String(20), nullable=True)
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)
hostname: Mapped[str | None] = mapped_column(String(150), nullable=True, index=True)
operating_system: Mapped[str | None] = mapped_column(String(150), nullable=True)
os_patch_level: Mapped[str | None] = mapped_column(String(150), nullable=True)
mac_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
cpu: Mapped[str | None] = mapped_column(String(200), nullable=True)
memory_gb: Mapped[str | None] = mapped_column(String(40), nullable=True)
storage_gb: Mapped[str | None] = mapped_column(String(40), nullable=True)
storage_details: Mapped[str | None] = mapped_column(Text, nullable=True)
gpu_name: Mapped[str | None] = mapped_column(Text, nullable=True)
tpm_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
memory_details: Mapped[str | None] = mapped_column(Text, nullable=True)
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_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)
mesh_last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
mesh_online: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
mesh_online_state: Mapped[str] = mapped_column(String(20), default="unknown", index=True)
mesh_presence_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
mesh_last_online_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
mesh_presence_source: Mapped[str | None] = mapped_column(String(40), nullable=True)
mesh_sync_status: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
mesh_sync_message: Mapped[str | None] = mapped_column(Text, nullable=True)
mesh_conflicts: Mapped[dict] = mapped_column(JSON, default=dict)
mesh_source_data: Mapped[dict] = mapped_column(JSON, default=dict)
mesh_hardware_data: Mapped[dict] = mapped_column(JSON, default=dict)
mesh_software_data: Mapped[list | dict] = mapped_column(JSON, default=list)
mesh_inventory_updated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
extra_data: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
category: Mapped[Category] = relationship(back_populates="assets")
parent_asset: Mapped["Asset | None"] = relationship("Asset", remote_side="Asset.id", back_populates="child_assets", foreign_keys=[parent_asset_id])
child_assets: Mapped[list["Asset"]] = relationship("Asset", back_populates="parent_asset", foreign_keys=[parent_asset_id])
class AssetFieldValue(Base):
__tablename__ = "asset_field_values"
__table_args__ = (UniqueConstraint("asset_id", "field_definition_id"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
field_definition_id: Mapped[int] = mapped_column(ForeignKey("field_definitions.id", ondelete="CASCADE"), index=True)
value_text: Mapped[str | None] = mapped_column(Text, nullable=True)
value_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
value_boolean: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
value_date: Mapped[str | None] = mapped_column(String(20), nullable=True)
value_datetime: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
value_json: Mapped[dict | list | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
asset: Mapped[Asset] = relationship()
definition: Mapped[FieldDefinition] = relationship(back_populates="values")
class MeshFieldMapping(Base):
__tablename__ = "mesh_field_mappings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_path: Mapped[str] = mapped_column(String(255), index=True)
target_field_name: Mapped[str] = mapped_column(String(80), index=True)
transform: Mapped[str] = mapped_column(String(40), default="raw")
multi_value_mode: Mapped[str] = mapped_column(String(30), default="first")
separator: Mapped[str] = mapped_column(String(20), default="\n")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
update_rule: Mapped[str] = mapped_column(String(30), default="fill_empty")
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
class AssetHistory(Base):
__tablename__ = "asset_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
changed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
source: Mapped[str] = mapped_column(String(50), default="manual")
changed_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
changes: Mapped[dict] = mapped_column(JSON, default=dict)
class StatusOption(Base):
__tablename__ = "status_options"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(60), unique=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
active: Mapped[bool] = mapped_column(Boolean, default=True)
use_for_issue: Mapped[bool] = mapped_column(Boolean, default=False)
use_for_return: Mapped[bool] = mapped_column(Boolean, default=False)
class SyncRun(Base):
__tablename__ = "sync_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
source: Mapped[str] = mapped_column(String(50), default="meshcentral")
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
status: Mapped[str] = mapped_column(String(30), default="running")
devices_found: Mapped[int] = mapped_column(Integer, default=0)
created_count: Mapped[int] = mapped_column(Integer, default=0)
updated_count: Mapped[int] = mapped_column(Integer, default=0)
conflict_count: Mapped[int] = mapped_column(Integer, default=0)
error_count: Mapped[int] = mapped_column(Integer, default=0)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
class ChartDefinition(Base):
__tablename__ = "chart_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(150), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
chart_type: Mapped[str] = mapped_column(String(30), default="bar")
group_field: Mapped[str] = mapped_column(String(80), default="category")
stack_field: Mapped[str | None] = mapped_column(String(80), nullable=True)
dashboard_visible: Mapped[bool] = mapped_column(Boolean, default=False)
show_empty: Mapped[bool] = mapped_column(Boolean, default=False)
max_items: Mapped[int] = mapped_column(Integer, default=12)
sort_mode: Mapped[str] = mapped_column(String(30), default="count_desc")
value_mode: Mapped[str] = mapped_column(String(30), default="raw")
value_mappings: Mapped[list] = mapped_column(JSON, default=list)
excluded_statuses: Mapped[list] = mapped_column(JSON, default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
created_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Language(Base):
__tablename__ = "languages"
code: Mapped[str] = mapped_column(String(10), primary_key=True)
name: Mapped[str] = mapped_column(String(80))
native_name: Mapped[str] = mapped_column(String(80))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
class Translation(Base):
__tablename__ = "translations"
__table_args__ = (UniqueConstraint("translation_key", "language_code"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
translation_key: Mapped[str] = mapped_column(String(180), index=True)
language_code: Mapped[str] = mapped_column(ForeignKey("languages.code", ondelete="CASCADE"), index=True)
text_value: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
display_name: Mapped[str | None] = mapped_column(String(180), nullable=True)
email: Mapped[str | None] = mapped_column(String(180), nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
auth_source: Mapped[str] = mapped_column(String(30), default="local")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
is_protected: Mapped[bool] = mapped_column(Boolean, default=False)
allowed_mesh_groups: Mapped[list] = mapped_column(JSON, default=list)
asset_access_scopes: Mapped[list] = mapped_column(JSON, default=list)
profile_department: Mapped[str | None] = mapped_column(String(150), nullable=True)
profile_location: Mapped[str | None] = mapped_column(String(150), nullable=True)
language_code: Mapped[str] = mapped_column(String(10), default="en")
persist_browser_state: Mapped[bool] = mapped_column(Boolean, default=False)
toast_position: Mapped[str] = mapped_column(String(30), default="top-right")
toast_duration_seconds: Mapped[int] = mapped_column(Integer, default=6)
theme_mode: Mapped[str] = mapped_column(String(20), default="light")
last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class JobDefinition(Base):
__tablename__ = "job_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
job_kind: Mapped[str] = mapped_column(String(30), default="script", index=True)
configuration: Mapped[dict] = mapped_column(JSON, default=dict)
system_key: Mapped[str | None] = mapped_column(String(120), unique=True, nullable=True, index=True)
platform: Mapped[str] = mapped_column(String(30), default="windows", index=True)
interpreter: Mapped[str] = mapped_column(String(30), default="powershell")
source_type: Mapped[str] = mapped_column(String(30), default="inline")
source_path: Mapped[str | None] = mapped_column(Text, nullable=True)
inline_script: Mapped[str | None] = mapped_column(Text, nullable=True)
arguments: Mapped[str | None] = mapped_column(Text, nullable=True)
timeout_seconds: Mapped[int] = mapped_column(Integer, default=300)
success_codes: Mapped[str] = mapped_column(String(120), default="0")
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
revision: Mapped[int] = mapped_column(Integer, default=1)
created_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
revisions: Mapped[list["JobDefinitionRevision"]] = relationship(back_populates="definition", cascade="all, delete-orphan", order_by="JobDefinitionRevision.revision.desc()")
parameters: Mapped[list["JobDefinitionParameter"]] = relationship(back_populates="definition", cascade="all, delete-orphan", order_by="JobDefinitionParameter.sort_order")
class JobDefinitionParameter(Base):
__tablename__ = "job_definition_parameters"
__table_args__ = (UniqueConstraint("definition_id", "parameter_key", name="uq_job_definition_parameter_key"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
definition_id: Mapped[int] = mapped_column(ForeignKey("job_definitions.id", ondelete="CASCADE"), index=True)
parameter_key: Mapped[str] = mapped_column(String(80))
label: Mapped[str] = mapped_column(String(140))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
data_type: Mapped[str] = mapped_column(String(30), default="text")
default_value: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
definition: Mapped["JobDefinition"] = relationship(back_populates="parameters")
class JobDefinitionAssetParameter(Base):
__tablename__ = "job_definition_asset_parameters"
__table_args__ = (UniqueConstraint("definition_parameter_id", "asset_id", name="uq_job_definition_asset_parameter"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
definition_parameter_id: Mapped[int] = mapped_column(ForeignKey("job_definition_parameters.id", ondelete="CASCADE"), index=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
value_text: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
parameter: Mapped["JobDefinitionParameter"] = relationship()
asset: Mapped["Asset"] = relationship()
class JobDefinitionRevision(Base):
__tablename__ = "job_definition_revisions"
__table_args__ = (UniqueConstraint("definition_id", "revision", name="uq_job_definition_revision"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
definition_id: Mapped[int] = mapped_column(ForeignKey("job_definitions.id", ondelete="CASCADE"), index=True)
revision: Mapped[int] = mapped_column(Integer)
snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
changed_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
definition: Mapped["JobDefinition"] = relationship(back_populates="revisions")
class SoftwarePackage(Base):
__tablename__ = "software_packages"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(180), unique=True, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
package_type: Mapped[str] = mapped_column(String(40), default="command")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
command_windows: Mapped[str | None] = mapped_column(Text, nullable=True)
command_linux: Mapped[str | None] = mapped_column(Text, nullable=True)
command_macos: Mapped[str | None] = mapped_column(Text, nullable=True)
command_android: Mapped[str | None] = mapped_column(Text, nullable=True)
callback_timeout_minutes: Mapped[int] = mapped_column(Integer, default=30)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
parameters: Mapped[list["SoftwarePackageParameter"]] = relationship(back_populates="package", cascade="all, delete-orphan", order_by="SoftwarePackageParameter.sort_order")
jobs: Mapped[list["SoftwareJob"]] = relationship(back_populates="package")
class SoftwarePackageParameter(Base):
__tablename__ = "software_package_parameters"
__table_args__ = (UniqueConstraint("package_id", "name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
package_id: Mapped[int] = mapped_column(ForeignKey("software_packages.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(80))
label: Mapped[str] = mapped_column(String(120))
data_type: Mapped[str] = mapped_column(String(30), default="text")
required: Mapped[bool] = mapped_column(Boolean, default=False)
secret: Mapped[bool] = mapped_column(Boolean, default=False)
default_value: Mapped[str | None] = mapped_column(Text, nullable=True)
validation_regex: Mapped[str | None] = mapped_column(String(255), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
package: Mapped[SoftwarePackage] = relationship(back_populates="parameters")
class SoftwareJob(Base):
__tablename__ = "software_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
package_id: Mapped[int] = mapped_column(ForeignKey("software_packages.id"), index=True)
status: Mapped[str] = mapped_column(String(30), default="created", index=True)
job_type: Mapped[str] = mapped_column(String(50), default="software_inventory", index=True)
action: Mapped[str] = mapped_column(String(30), default="run", index=True)
priority: Mapped[int] = mapped_column(Integer, default=100, index=True)
platform: Mapped[str] = mapped_column(String(30), default="unknown")
parameters: Mapped[dict] = mapped_column(JSON, default=dict)
result_data: Mapped[dict] = mapped_column(JSON, default=dict)
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
max_attempts: Mapped[int] = mapped_column(Integer, default=1)
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
callback_token_hash: Mapped[str] = mapped_column(String(64), index=True)
callback_expires_at: Mapped[datetime] = mapped_column(DateTime)
callback_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
command_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
resolved_script: Mapped[str | None] = mapped_column(Text, nullable=True)
meshctrl_stdout: Mapped[str | None] = mapped_column(Text, nullable=True)
meshctrl_stderr: Mapped[str | None] = mapped_column(Text, nullable=True)
exit_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
log_text: Mapped[str] = mapped_column(Text, default="")
created_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
asset: Mapped[Asset] = relationship()
package: Mapped[SoftwarePackage] = relationship(back_populates="jobs")
events: Mapped[list["JobEvent"]] = relationship(back_populates="job", cascade="all, delete-orphan", order_by="JobEvent.created_at")
class AssetJobState(Base):
__tablename__ = "asset_job_states"
__table_args__ = (
UniqueConstraint("asset_id", "state_key", name="uq_asset_job_state_asset_key"),
Index("ix_asset_job_states_key_status", "state_key", "last_status"),
Index("ix_asset_job_states_key_success", "state_key", "last_success_at"),
Index("ix_asset_job_states_asset_key", "asset_id", "state_key"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
state_key: Mapped[str] = mapped_column(String(120), index=True)
job_type: Mapped[str] = mapped_column(String(50), index=True)
job_definition_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
definition_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_success_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
last_job_id: Mapped[int | None] = mapped_column(ForeignKey("software_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
last_status: Mapped[str] = mapped_column(String(30), default="created", index=True)
last_status_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
last_created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
execution_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
asset: Mapped[Asset] = relationship()
last_job: Mapped[SoftwareJob | None] = relationship()
class JobEvent(Base):
__tablename__ = "job_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
job_id: Mapped[int] = mapped_column(ForeignKey("software_jobs.id", ondelete="CASCADE"), index=True)
attempt: Mapped[int] = mapped_column(Integer, default=0)
event_type: Mapped[str] = mapped_column(String(40), default="status", index=True)
status: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(150), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
job: Mapped["SoftwareJob"] = relationship(back_populates="events")
class InstalledSoftware(Base):
__tablename__ = "installed_software"
__table_args__ = (UniqueConstraint("asset_id", "name", "version", "publisher", "architecture", name="uq_installed_software_identity"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(300), index=True)
version: Mapped[str] = mapped_column(String(160), default="")
publisher: Mapped[str] = mapped_column(String(220), default="")
architecture: Mapped[str] = mapped_column(String(40), default="")
platform: Mapped[str] = mapped_column(String(40), default="unknown", index=True)
install_date: Mapped[str | None] = mapped_column(String(40), nullable=True)
source: Mapped[str] = mapped_column(String(50), default="meshcentral-inventory")
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
asset: Mapped[Asset] = relationship()
+162
View File
@@ -0,0 +1,162 @@
import logging
import os
import threading
from pathlib import Path
from datetime import datetime
from typing import Any
from .config import load_config
from .database import SessionLocal
from .meshcentral import _extract_devices, _parse_json_output, _run_meshctrl
from .models import Asset
from .presence_state import derive_presence_state, extract_last_address, extract_last_seen
logger = logging.getLogger("assetmanager.presence")
def _normalized_node_id(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
if text.startswith("node//"):
return text[6:]
if text.startswith("node/"):
return text.split("/")[-1]
return text
def _device_node_id(device: dict[str, Any]) -> str:
node = device.get("node") if isinstance(device.get("node"), dict) else {}
return str(
device.get("_id")
or device.get("nodeid")
or device.get("nodeId")
or device.get("id")
or node.get("_id")
or node.get("nodeid")
or ""
).strip()
def _device_online(device: dict[str, Any]) -> bool | None:
for key in ("online", "connected", "isOnline"):
if key not in device:
continue
value = device.get(key)
if isinstance(value, bool):
return value
text = str(value or "").strip().casefold()
if text in {"1", "true", "yes", "online", "connected"}:
return True
if text in {"0", "false", "no", "offline", "disconnected"}:
return False
# MeshCentral exposes a connection bit mask as `conn`.
if "conn" in device:
try:
return int(device.get("conn") or 0) > 0
except (TypeError, ValueError):
pass
return None
def _presence_config() -> dict[str, Any] | None:
config = load_config().get("meshcentral", {})
if not config.get("enabled") or not config.get("presence_enabled", True):
return None
if not config.get("url") or not config.get("username") or not config.get("password"):
return None
return config
def refresh_mesh_presence() -> dict[str, int]:
config = _presence_config()
if not config:
return {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
# Die Präsenzprüfung läuft standardmäßig alle 30 Sekunden. Ihre erfolgreiche
# ListDevices-Ausgabe wird nach dem Einlesen nicht als Zeitstempeldatei behalten.
# Bei einem Parsefehler speichern wir nur eine einzige überschreibbare Fehlerdatei.
result = _run_meshctrl(
config,
"ListDevices",
include_details=False,
retain_stdout=False,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "MeshCentral presence query failed").strip())
diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics"))
presence_error_path = diagnostic_dir / "meshctrl-presence-last-error.stdout.json"
try:
devices = _extract_devices(_parse_json_output(result.stdout))
except Exception:
diagnostic_dir.mkdir(parents=True, exist_ok=True)
presence_error_path.write_text(result.stdout or "", encoding="utf-8", errors="replace")
raise
else:
presence_error_path.unlink(missing_ok=True)
by_exact: dict[str, dict[str, Any]] = {}
by_normalized: dict[str, dict[str, Any]] = {}
for device in devices:
node_id = _device_node_id(device)
if not node_id:
continue
by_exact[node_id] = device
by_normalized[_normalized_node_id(node_id)] = device
now = datetime.utcnow()
counters = {"checked": 0, "online": 0, "offline": 0, "unknown": 0}
db = SessionLocal()
try:
assets = db.query(Asset).filter(Asset.mesh_node_id.isnot(None), Asset.mesh_node_id != "").all()
for asset in assets:
counters["checked"] += 1
node_id = str(asset.mesh_node_id or "").strip()
device = by_exact.get(node_id) or by_normalized.get(_normalized_node_id(node_id))
state_value = _device_online(device) if device else None
reported_last_seen = extract_last_seen(device)
if reported_last_seen and (asset.mesh_last_seen is None or reported_last_seen > asset.mesh_last_seen):
asset.mesh_last_seen = reported_last_seen
# The live query is authoritative for "online". An offline label is
# only meaningful once MeshCentral has supplied a historical
# connection time. Devices without such history remain "unknown".
effective_last_seen = asset.mesh_last_seen
derived_state = derive_presence_state(state_value, effective_last_seen)
asset.mesh_online_state = derived_state
asset.mesh_online = True if derived_state == "online" else (False if derived_state == "offline" else None)
if derived_state == "online":
asset.mesh_last_online_at = now
counters[derived_state] += 1
asset.mesh_presence_updated_at = now
asset.mesh_presence_source = "meshcentral"
logger.debug(
"MeshCentral presence asset=%s node_id=%s live=%s last_seen=%s last_address=%s derived_state=%s",
asset.name, node_id, state_value, effective_last_seen, extract_last_address(device), derived_state,
)
db.commit()
finally:
db.close()
logger.info(
"MeshCentral presence refreshed: checked=%s online=%s offline=%s unknown=%s",
counters["checked"],
counters["online"],
counters["offline"],
counters["unknown"],
)
return counters
def mesh_presence_loop(stop_event: threading.Event) -> None:
while not stop_event.is_set():
config = load_config().get("meshcentral", {})
interval = max(10, min(int(config.get("presence_interval_seconds", 30) or 30), 600))
try:
refresh_mesh_presence()
except Exception:
logger.exception("MeshCentral presence refresh failed")
if stop_event.wait(interval):
return
+114
View File
@@ -0,0 +1,114 @@
"""Shared MeshCentral presence and last-seen helpers.
The MeshCentral CLI uses slightly different key names and nesting depending on
whether ListDevices is called with --details and on the MeshCentral version.
This module keeps timestamp parsing and state derivation in one place.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
def _nested(data: Any, *path: str) -> Any:
current = data
for key in path:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def parse_mesh_datetime(value: Any) -> datetime | None:
"""Parse MeshCentral Unix timestamps (seconds/ms) and ISO strings as UTC-naive."""
if value is None or isinstance(value, bool):
return None
if isinstance(value, datetime):
if value.tzinfo:
return value.astimezone(timezone.utc).replace(tzinfo=None)
return value
if isinstance(value, dict):
for key in ("time", "timestamp", "value"):
parsed = parse_mesh_datetime(value.get(key))
if parsed:
return parsed
return None
if isinstance(value, (int, float)):
numeric = float(value)
if numeric <= 0:
return None
# MeshCentral commonly emits JavaScript timestamps in milliseconds.
if numeric > 100_000_000_000:
numeric /= 1000.0
try:
return datetime.fromtimestamp(numeric, tz=timezone.utc).replace(tzinfo=None)
except (OverflowError, OSError, ValueError):
return None
text = str(value).strip()
if not text or text in {"0", "-"}:
return None
try:
return parse_mesh_datetime(float(text))
except ValueError:
pass
try:
normalized = text[:-1] + "+00:00" if text.endswith("Z") else text
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
except ValueError:
return None
def extract_last_seen(device: dict[str, Any] | None) -> datetime | None:
"""Return the best historical connection timestamp exposed by MeshCentral."""
if not isinstance(device, dict):
return None
candidates = (
device.get("lastConnect"),
device.get("lastconnect"),
device.get("lastSeen"),
device.get("lastseen"),
device.get("lastOnline"),
device.get("lastonline"),
_nested(device, "node", "lastConnect"),
_nested(device, "node", "lastconnect"),
_nested(device, "node", "lastSeen"),
_nested(device, "node", "lastseen"),
)
for candidate in candidates:
parsed = parse_mesh_datetime(candidate)
if parsed:
return parsed
return None
def extract_last_address(device: dict[str, Any] | None) -> str | None:
if not isinstance(device, dict):
return None
candidates = (
_nested(device, "lastConnect", "addr"),
_nested(device, "lastconnect", "addr"),
device.get("lastaddr"),
device.get("lastAddress"),
_nested(device, "node", "lastaddr"),
_nested(device, "node", "lastAddress"),
_nested(device, "node", "ip"),
_nested(device, "node", "host"),
)
for value in candidates:
text = str(value or "").strip()
if text:
return text
return None
def derive_presence_state(live_online: bool | None, last_seen: datetime | None) -> str:
"""Derive UI state: live true=online; historical connection=offline; else unknown."""
if live_online is True:
return "online"
if last_seen is not None:
return "offline"
return "unknown"
+676
View File
@@ -0,0 +1,676 @@
from pathlib import Path
import base64
import hashlib
import os
import subprocess
import tempfile
import threading
import time
from datetime import datetime
from urllib.parse import quote
from fastapi import HTTPException
from .config import load_config
from .database import SessionLocal
from .models import Asset, SoftwareJob, JobEvent, JobDefinition
from .job_state import sync_asset_job_state
_DISPATCH_SLOT_LOCK = threading.Lock()
_NEXT_DISPATCH_MONOTONIC = 0.0
def _wait_for_dispatch_slot() -> tuple[float, float]:
"""Reserve the next global MeshCtrl dispatch slot.
Bulk operations currently create one lightweight worker thread per job.
Reserving slots here prevents all workers from opening database and
MeshCtrl connections at the same time while still allowing already
dispatched client scripts to run in parallel.
"""
global _NEXT_DISPATCH_MONOTONIC
settings = load_config().get("software", {})
try:
delay_seconds = float(settings.get("dispatch_delay_seconds", 2) or 0)
except (TypeError, ValueError):
delay_seconds = 2.0
delay_seconds = max(0.0, min(delay_seconds, 60.0))
if delay_seconds <= 0:
return 0.0, 0.0
with _DISPATCH_SLOT_LOCK:
now = time.monotonic()
reserved_at = max(now, _NEXT_DISPATCH_MONOTONIC)
wait_seconds = max(0.0, reserved_at - now)
_NEXT_DISPATCH_MONOTONIC = reserved_at + delay_seconds
if wait_seconds > 0:
time.sleep(wait_seconds)
return delay_seconds, wait_seconds
def token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def detect_platform(asset: Asset) -> str:
text = f"{asset.operating_system or ''} {asset.mesh_source_data or ''}".casefold()
if "windows" in text:
return "windows"
if "macos" in text or "mac os" in text or "darwin" in text:
return "macos"
if "android" in text:
return "android"
if any(x in text for x in ("linux", "ubuntu", "debian", "fedora", "centos", "red hat", "suse", "arch")):
return "linux"
return "unknown"
def powershell_payload(script: str) -> str:
"""Compatibility helper retained for older callers.
Since 0.5.5.0 scripts are uploaded as files and are no longer encoded into
the command line.
"""
return str(script or "")
def powershell_encoded(script: str) -> str:
return powershell_payload(script)
def windows_inventory(callback_url: str, job_id: int) -> str:
script = """
$ErrorActionPreference='Stop'
$log=Join-Path $env:ProgramData 'AssetManager\\commands.log'
New-Item -ItemType Directory -Force -Path (Split-Path $log)|Out-Null
$lines=New-Object System.Collections.Generic.List[string]
function L([string]$m){$x="$(Get-Date -Format o) job=JOBID $m";Add-Content -Path $log -Value $x -Encoding UTF8;$lines.Add($x)}
function Send-Callback([byte[]]$Payload){
$lastError=$null
for($attempt=1;$attempt -le 3;$attempt++){
try{
Invoke-RestMethod -Method Post -Uri 'CALLBACK' -ContentType 'application/json; charset=utf-8' -Body $Payload -TimeoutSec 60|Out-Null
if($attempt -gt 1){L ("callback accepted on attempt $attempt of 3")}
return
}catch{
$lastError=$_.Exception
L ("callback attempt $attempt of 3 failed: "+$_.Exception.Message)
if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}
}
}
throw ("callback failed after 3 attempts: "+$lastError.Message)
}
L 'software inventory started'
try {
$paths=@('HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')
$software=Get-ItemProperty $paths -ErrorAction SilentlyContinue|Where-Object {$_.DisplayName}|ForEach-Object {
[PSCustomObject]@{name=[string]$_.DisplayName;version=[string]$_.DisplayVersion;publisher=[string]$_.Publisher;install_date=[string]$_.InstallDate;architecture=$(if($_.PSPath -like '*WOW6432Node*'){'x86'}else{'x64'})}
}|Sort-Object name,version -Unique
L ("software inventory completed; entries="+$software.Count)
$body=@{status='success';exit_code=0;completed=$true;software=$software;log=($lines -join "`n")}|ConvertTo-Json -Depth 6 -Compress
$bodyBytes=[System.Text.Encoding]::UTF8.GetBytes($body)
L 'sending callback'
Send-Callback $bodyBytes
L 'callback accepted'
}catch{
L ("software inventory failed: "+$_.Exception.Message)
$body=@{status='failed';exit_code=1;completed=$true;message=$_.Exception.Message;log=($lines -join "`n")}|ConvertTo-Json -Depth 4 -Compress
$bodyBytes=[System.Text.Encoding]::UTF8.GetBytes($body)
try{
Send-Callback $bodyBytes
}catch{
L ("callback failed: "+$_.Exception.Message)
}
exit 1
}
""".replace("JOBID", str(job_id)).replace("CALLBACK", callback_url)
return script
def unix_python_payload(platform: str, callback_url: str, job_id: int) -> str:
if platform == "macos":
collection = """
raw=subprocess.check_output(["system_profiler","SPApplicationsDataType","-json"],text=True,errors="replace")
data=json.loads(raw)
for x in data.get("SPApplicationsDataType",[]):
items.append({"name":x.get("_name",""),"version":x.get("version",""),"publisher":str(x.get("signed_by","")),"architecture":x.get("arch_kind",""),"install_date":None})
"""
log_path = "/Library/Logs/AssetManager-commands.log"
else:
collection = """
if os.path.exists("/usr/bin/dpkg-query"):
out=subprocess.check_output(["dpkg-query","-W","-f=${binary:Package}\\t${Version}\\t${Maintainer}\\n"],text=True,errors="replace")
for line in out.splitlines():
p=line.split("\\t");items.append({"name":p[0],"version":p[1] if len(p)>1 else "","publisher":p[2] if len(p)>2 else "","architecture":"","install_date":None})
elif os.path.exists("/usr/bin/rpm"):
out=subprocess.check_output(["rpm","-qa","--qf","%{NAME}\\t%{VERSION}-%{RELEASE}\\t%{VENDOR}\\t%{ARCH}\\n"],text=True,errors="replace")
for line in out.splitlines():
p=line.split("\\t");items.append({"name":p[0],"version":p[1] if len(p)>1 else "","publisher":p[2] if len(p)>2 else "","architecture":p[3] if len(p)>3 else "","install_date":None})
elif os.path.exists("/sbin/apk") or os.path.exists("/usr/sbin/apk"):
out=subprocess.check_output(["apk","info","-v"],text=True,errors="replace")
for line in out.splitlines():items.append({"name":line,"version":"","publisher":"","architecture":"","install_date":None})
"""
log_path = "/var/log/assetmanager-commands.log"
py = f"""import json,subprocess,urllib.request,datetime,os,time
log={log_path!r}
lines=[]
def add(m):
x=datetime.datetime.now(datetime.timezone.utc).isoformat()+" job={job_id} "+m
lines.append(x)
try:
with open(log,"a",encoding="utf-8") as f:f.write(x+"\\n")
except Exception:pass
add("software inventory started")
items=[]
try:
{textwrap_indent(collection, 4)}
add("software inventory completed; entries="+str(len(items)))
payload={{"status":"success","exit_code":0,"completed":True,"software":items,"log":"\\n".join(lines)}}
except Exception as e:
add("software inventory failed: "+str(e))
payload={{"status":"failed","exit_code":1,"completed":True,"message":str(e),"log":"\\n".join(lines)}}
callback_data=json.dumps(payload).encode()
last_error=None
for attempt in range(1,4):
req=urllib.request.Request({callback_url!r},data=callback_data,headers={{"Content-Type":"application/json"}},method="POST")
try:
urllib.request.urlopen(req,timeout=60).read()
break
except Exception as exc:
last_error=exc
add("callback attempt "+str(attempt)+" of 3 failed: "+str(exc))
if attempt < 3: time.sleep(2*attempt)
else:
raise RuntimeError("callback failed after 3 attempts: "+str(last_error))
"""
return py
def textwrap_indent(value: str, spaces: int) -> str:
prefix = " " * spaces
return "\n".join(prefix + line if line.strip() else line for line in value.strip().splitlines())
def _replace_job_placeholders(script: str, asset: Asset, callback_url: str, job_id: int, parameters: dict | None = None) -> str:
values={
'AssetName':asset.name or '', 'Hostname':asset.hostname or '', 'IPAddress':asset.ip_address or '',
'SerialNumber':asset.serial_number or '', 'MeshNodeId':asset.mesh_node_id or '',
'Manufacturer':asset.manufacturer or '', 'Model':asset.model or '',
'Department':asset.department or '', 'Location':asset.location or '',
'CurrentUser':asset.assigned_to or '',
'CallbackUrl':callback_url, 'JobId':str(job_id),
}
for key,value in values.items(): script=script.replace('{{'+key+'}}',str(value))
for key,value in (parameters or {}).items():
script=script.replace('{{Param.'+str(key)+'}}',str(value)).replace('{{'+str(key)+'}}',str(value))
return script
def build_registry_user_script(configuration: dict | None) -> str:
"""Build PowerShell user code for a declarative Windows Registry job."""
config=configuration or {}
entries=config.get("registry_entries") if isinstance(config,dict) else []
backup=bool(config.get("registry_backup")) if isinstance(config,dict) else False
hive_map={
"HKLM":"Registry::HKEY_LOCAL_MACHINE",
"HKCU":"Registry::HKEY_CURRENT_USER",
"HKCR":"Registry::HKEY_CLASSES_ROOT",
"HKU":"Registry::HKEY_USERS",
"HKCC":"Registry::HKEY_CURRENT_CONFIG",
}
type_map={"REG_SZ":"String","REG_EXPAND_SZ":"ExpandString","REG_MULTI_SZ":"MultiString","REG_DWORD":"DWord","REG_QWORD":"QWord","REG_BINARY":"Binary"}
lines=["Write-JobLog 'Registry job started'", "$registryResults = @()"]
if backup:
lines += [
"$backupRoot = Join-Path $env:ProgramData ('AssetManager\\RegistryBackups\\Job-' + '{{JobId}}')",
"New-Item -ItemType Directory -Force -Path $backupRoot | Out-Null",
"Write-JobLog (\"Registry backup directory: $backupRoot\")",
]
for index,item in enumerate(entries if isinstance(entries,list) else [],1):
if not isinstance(item,dict): continue
action=str(item.get('action') or 'set')
hive=str(item.get('hive') or 'HKLM').upper()
path=str(item.get('path') or '').strip().strip('\\')
name=str(item.get('name') or '')
value_type=str(item.get('value_type') or 'REG_SZ').upper()
value=str(item.get('value') or '')
set_value=bool(item.get('set_value', True))
only_if_missing=bool(item.get('only_if_missing'))
permissions_enabled=bool(item.get('permissions_enabled'))
if hive not in hive_map or not path: continue
ps_path=hive_map[hive]+'\\'+path
reg_path=hive+'\\'+path
q=lambda x: "'"+x.replace("'","''")+"'"
lines.append(f"# Registry entry {index}")
lines.append(f"$registryPath = {q(ps_path)}")
lines.append(f"$registryValueName = {q(name)}")
if backup:
safe_name=str(index).zfill(3)+'-'+hive+'-'+path.replace('\\','_').replace(':','_')+'.reg'
lines += [
f"$backupFile = Join-Path $backupRoot {q(safe_name)}",
f"& reg.exe export {q(reg_path)} $backupFile /y 2>$null | Out-Null",
]
if action=='delete_key':
lines.append("if (Test-Path -LiteralPath $registryPath) { Remove-Item -LiteralPath $registryPath -Recurse -Force -ErrorAction Stop; Write-JobLog (\"Registry key deleted: $registryPath\"); $registryResults += @{path=$registryPath; action='delete_key'; status='changed'} } else { Write-JobLog (\"Registry key already absent: $registryPath\"); $registryResults += @{path=$registryPath; action='delete_key'; status='unchanged'} }")
continue
if action=='delete_value':
lines += [
"$existing = Get-ItemProperty -LiteralPath $registryPath -Name $registryValueName -ErrorAction SilentlyContinue",
"if ($null -ne $existing) { Remove-ItemProperty -LiteralPath $registryPath -Name $registryValueName -Force -ErrorAction Stop; Write-JobLog (\"Registry value deleted: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='delete_value'; status='changed'} } else { Write-JobLog (\"Registry value already absent: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='delete_value'; status='unchanged'} }"
]
elif action=='set':
lines.append("if (-not (Test-Path -LiteralPath $registryPath)) { New-Item -Path $registryPath -Force | Out-Null; Write-JobLog (\"Registry key created: $registryPath\") }")
if set_value:
property_type=type_map.get(value_type,'String')
lines.append(f"$registryRawValue = {q(value)}")
if value_type=='REG_MULTI_SZ':
lines.append("$registryTypedValue = @($registryRawValue -split \"`r?`n\")")
elif value_type=='REG_BINARY':
lines.append("$registryTypedValue = [byte[]](($registryRawValue -replace '[^0-9A-Fa-f]','' -split '(?<=\\G..)(?=.)') | Where-Object { $_ } | ForEach-Object { [Convert]::ToByte($_,16) })")
elif value_type=='REG_DWORD':
lines.append("$registryTypedValue = if ($registryRawValue -match '^0x') {[Convert]::ToUInt32($registryRawValue.Substring(2),16)} else {[Convert]::ToUInt32($registryRawValue)}")
elif value_type=='REG_QWORD':
lines.append("$registryTypedValue = if ($registryRawValue -match '^0x') {[Convert]::ToUInt64($registryRawValue.Substring(2),16)} else {[Convert]::ToUInt64($registryRawValue)}")
else:
lines.append("$registryTypedValue = $registryRawValue")
lines.append("$existingProperty = Get-ItemProperty -LiteralPath $registryPath -Name $registryValueName -ErrorAction SilentlyContinue")
if only_if_missing:
lines.append("if ($null -ne $existingProperty) { Write-JobLog (\"Registry value exists; skipped: $registryPath\\$registryValueName\"); $registryResults += @{path=$registryPath; name=$registryValueName; action='set'; status='skipped'} } else {")
indent=' '
else:
indent=''
lines += [
indent+f"New-ItemProperty -LiteralPath $registryPath -Name $registryValueName -PropertyType {property_type} -Value $registryTypedValue -Force -ErrorAction Stop | Out-Null",
indent+"Write-JobLog (\"Registry value set: $registryPath\\$registryValueName\")",
indent+"$registryResults += @{path=$registryPath; name=$registryValueName; action='set'; status='changed'; type='"+value_type+"'}",
]
if only_if_missing: lines.append("}")
else:
lines.append("Write-JobLog (\"Registry key ensured without changing a value: $registryPath\")")
lines.append("$registryResults += @{path=$registryPath; action='ensure_key'; status='ready'}")
if permissions_enabled:
principal=str(item.get('principal') or 'S-1-5-32-545').strip()
rights=str(item.get('rights') or 'FullControl').strip()
access_type='Deny' if str(item.get('access_type') or 'Allow').lower()=='deny' else 'Allow'
inherit_subkeys=bool(item.get('inherit_subkeys', True))
replace_existing=bool(item.get('replace_existing', True))
lines += [
"if (-not (Test-Path -LiteralPath $registryPath)) { New-Item -Path $registryPath -Force | Out-Null; Write-JobLog (\"Registry key created for permissions: $registryPath\") }",
f"$registryPrincipal = {q(principal)}",
f"$registryRightsName = {q(rights)}",
f"$registryAccessTypeName = {q(access_type)}",
"try { $registryIdentity = New-Object System.Security.Principal.SecurityIdentifier($registryPrincipal) } catch { $registryIdentity = $registryPrincipal }",
"try { $registryRights = [System.Enum]::Parse([System.Security.AccessControl.RegistryRights], $registryRightsName, $true) } catch { throw \"Invalid RegistryRights '$registryRightsName' for $registryPath\" }",
"$registryAccessType = [System.Enum]::Parse([System.Security.AccessControl.AccessControlType], $registryAccessTypeName, $true)",
"$registryInheritance = " + ("[System.Security.AccessControl.InheritanceFlags]::ContainerInherit" if inherit_subkeys else "[System.Security.AccessControl.InheritanceFlags]::None"),
"$registryPropagation = [System.Security.AccessControl.PropagationFlags]::None",
"Write-JobLog (\"Registry ACL target: $registryPath; exists=$([bool](Test-Path -Path $registryPath))\")",
"$registryAcl = Get-Acl -Path $registryPath -ErrorAction Stop",
"$registryRule = New-Object System.Security.AccessControl.RegistryAccessRule($registryIdentity, $registryRights, $registryInheritance, $registryPropagation, $registryAccessType)",
("$registryAcl.SetAccessRule($registryRule)" if replace_existing else "$registryAcl.AddAccessRule($registryRule) | Out-Null"),
"Set-Acl -Path $registryPath -AclObject $registryAcl -ErrorAction Stop",
"$registryAclAfter = Get-Acl -Path $registryPath -ErrorAction Stop",
"$registryMatchedRules = @($registryAclAfter.Access | Where-Object { try { $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $registryIdentity.Value } catch { $_.IdentityReference.Value -eq $registryPrincipal -or $_.IdentityReference.Value -like ('*\\' + $registryPrincipal) } })",
"Write-JobLog (\"Registry permission set: $registryPath; identity=$registryPrincipal; rights=$registryRightsName; type=$registryAccessTypeName; matching_rules=$($registryMatchedRules.Count)\")",
"$registryResults += @{path=$registryPath; action='set_permissions'; status='changed'; principal=$registryPrincipal; rights=$registryRightsName; access_type=$registryAccessTypeName; matching_rules=$registryMatchedRules.Count}",
]
lines += ["$JobResult['registry_entries'] = $registryResults", "Write-JobLog ('Registry job completed; entries=' + $registryResults.Count)"]
return "\n".join(lines)
def _runtime_wrap_script(interpreter: str, user_script: str) -> str:
# Job definitions contain user code only. The technical execution wrapper
# is always generated from the current interpreter template at runtime.
source=str(user_script or "")
root=Path(__file__).resolve().parent / "templates" / "jobs"
files={"powershell":"powershell_wrapper.ps1","python":"python_wrapper.py.txt","bash":"bash_wrapper.sh","cmd":"cmd_wrapper.cmd"}
markers={"powershell":(" # --- Eigene Befehle hier einfügen ---"," # --- Ende eigene Befehle ---"),"python":(" # --- Eigene Befehle hier einfügen ---"," # --- Ende eigene Befehle ---"),"bash":("# --- Eigene Befehle hier einfügen ---","# --- Ende eigene Befehle ---"),"cmd":("rem --- Eigene Befehle hier einfügen ---","rem --- Ende eigene Befehle ---")}
filename=files.get(interpreter)
if not filename: raise RuntimeError(f"Unbekannter Interpreter: {interpreter}")
wrapper=(root/filename).read_text(encoding="utf-8").replace("\r\n","\n")
start,end=markers[interpreter]; block=start+"\n\n"+end
indent={"powershell":" ","python":" ","bash":"","cmd":""}[interpreter]
commands="\n".join(indent+line if line.strip() else line for line in str(user_script or "").splitlines())
return wrapper.replace(block,start+"\n"+commands+"\n"+end,1)
def _definition_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]:
"""Return (resolved script/command, interpreter, upload_required)."""
if definition_id is not None:
definition=db.query(JobDefinition).filter(JobDefinition.id==definition_id,JobDefinition.enabled.is_(True)).first()
else:
definition=db.query(JobDefinition).filter(JobDefinition.system_key==f'software_inventory_{platform}',JobDefinition.enabled.is_(True)).first()
if not definition:
if definition_id is not None:
raise RuntimeError('Jobdefinition wurde nicht gefunden oder ist deaktiviert.')
return '', '', False
if definition.platform not in {platform, 'all'}:
raise RuntimeError(f'Jobdefinition passt nicht zur Plattform {platform}.')
effective_parameters=((db.get(SoftwareJob,job_id).parameters or {}).get('effective_parameters') or {}) if db.get(SoftwareJob,job_id) else {}
if definition.job_kind=='registry':
script=_runtime_wrap_script('powershell', build_registry_user_script(definition.configuration))
interpreter='powershell'
elif definition.source_type=='inline':
interpreter=definition.interpreter
script=_runtime_wrap_script(interpreter, definition.inline_script or '')
elif definition.source_type=='mounted':
interpreter=definition.interpreter
path=Path(definition.source_path or '')
if not path.is_file(): raise RuntimeError(f'Skriptdatei nicht gefunden: {path}')
script=_runtime_wrap_script(interpreter, path.read_text(encoding='utf-8',errors='replace'))
elif definition.source_type=='unc':
path=_replace_job_placeholders(definition.source_path or '',asset,callback_url,job_id,effective_parameters)
arguments=_replace_job_placeholders(definition.arguments or '',asset,callback_url,job_id,effective_parameters)
if definition.interpreter=='powershell': command=f'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{path}" {arguments}'.strip()
elif definition.interpreter=='cmd': command=f'cmd.exe /d /s /c ""{path}" {arguments}"'.strip()
elif definition.interpreter=='python': command=f'python3 "{path}" {arguments}'.strip()
else: command=f'bash "{path}" {arguments}'.strip()
return command, definition.interpreter, False
else:
raise RuntimeError(f'Unbekannte Skriptquelle: {definition.source_type}')
script=_replace_job_placeholders(script,asset,callback_url,job_id,effective_parameters)
return script, interpreter, True
def _job_execution_artifacts(db, platform: str, callback_url: str, job_id: int, asset: Asset, definition_id: int | None = None) -> tuple[str, str, bool]:
configured=_definition_execution_artifacts(db,platform,callback_url,job_id,asset,definition_id)
if configured[0]:
return configured
if platform == 'windows':
return windows_inventory(callback_url, job_id), 'powershell', True
if platform in {'linux','macos'}:
return unix_python_payload(platform, callback_url, job_id), 'python', True
raise HTTPException(400, 'Für dieses Betriebssystem ist kein sicherer MeshCtrl-Handler verfügbar.')
def _script_extension(interpreter: str) -> str:
return {'powershell':'.ps1','cmd':'.cmd','python':'.py','bash':'.sh'}.get(interpreter,'.txt')
def _remote_job_paths(platform: str, job_id: int, attempt: int, interpreter: str) -> tuple[str, str]:
suffix=f'{job_id}-{attempt}'
filename='run'+_script_extension(interpreter)
if platform == 'windows':
directory=f'C:\\ProgramData\\AssetManager\\Jobs\\{suffix}'
return directory, directory+'\\'+filename
directory=f'/var/lib/assetmanager/jobs/{suffix}'
return directory, directory+'/'+filename
def _meshctrl_base_args(cfg: dict, asset: Asset, password: str) -> list[str]:
args=['node',str(cfg.get('meshctrl_path') or '/opt/meshcentral/node_modules/meshcentral/meshctrl.js')]
args += ['--url',str(cfg.get('url') or ''),'--loginuser',str(cfg.get('username') or ''),'--loginpass',password]
tenant=str(cfg.get('tenant') or '').strip()
if tenant:
args += ['--domain',tenant]
return args
def _run_meshctrl(cfg: dict, asset: Asset, password: str, action_args: list[str], timeout: int) -> subprocess.CompletedProcess:
base=_meshctrl_base_args(cfg,asset,password)
args=base[:2]+action_args+base[2:]
return subprocess.run(args,capture_output=True,text=True,timeout=timeout,check=False)
def _prepare_remote_directory(
cfg: dict,
asset: Asset,
password: str,
platform: str,
remote_dir: str,
timeout: int,
remote_file: str | None = None,
) -> subprocess.CompletedProcess:
"""Create the job directory and remove a stale target file.
MeshCtrl's Upload action does not overwrite an existing file reliably on
all Windows agents. Failed jobs intentionally retain their directories, so
a repeated dispatch must explicitly remove a previous run.ps1 before the
upload starts.
"""
if platform == 'windows':
command=(
"$p='"+remote_dir.replace("'","''")+"';"
"New-Item -ItemType Directory -Force -Path $p|Out-Null;"
)
if remote_file:
command += "Remove-Item -LiteralPath '"+remote_file.replace("'","''")+"' -Force -ErrorAction SilentlyContinue;"
command += "& icacls.exe $p /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C|Out-Null"
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout)
command=f"mkdir -p '{remote_dir}'"
if remote_file:
command += f" && rm -f -- '{remote_file}'"
command += f" && chmod 700 '{remote_dir}'"
return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout)
def _upload_script(cfg: dict, asset: Asset, password: str, local_file: str, remote_dir: str, timeout: int) -> subprocess.CompletedProcess:
return _run_meshctrl(cfg,asset,password,['Upload','--id',asset.mesh_node_id,'--file',local_file,'--target',remote_dir],timeout)
def _uploaded_script_launch_spec(platform: str, interpreter: str, remote_file: str) -> tuple[str, str, list[str]]:
"""Return the exact remote command, shell label and MeshCtrl shell flags.
Windows PowerShell files are deliberately launched through MeshCtrl's
Windows Command Prompt mode (type 0). The command then starts powershell
itself with ExecutionPolicy Bypass. Adding MeshCtrl's --powershell flag
would switch to type 2 and cause the agent to execute the .ps1 inside the
PowerShell host, which is precisely what must be avoided here.
"""
if platform == 'windows' and interpreter == 'powershell':
# Important: do not add MeshCtrl's --powershell option here. Without
# that option MeshCtrl uses the normal Windows command prompt, which
# then starts PowerShell with the same command that was tested manually.
escaped_file = remote_file.replace('"', '""')
return (
f'powershell -ExecutionPolicy Bypass -File "{escaped_file}"',
'Windows Command Prompt',
[],
)
if platform == 'windows' and interpreter == 'cmd':
return f'cmd.exe /d /s /c ""{remote_file}""', 'Windows Command Prompt', []
if interpreter == 'python':
return f"python3 '{remote_file}'", 'System shell', []
return f"chmod 700 '{remote_file}' && bash '{remote_file}'", 'System shell', []
def _launch_uploaded_script(cfg: dict, asset: Asset, password: str, platform: str, interpreter: str, remote_file: str, timeout: int) -> subprocess.CompletedProcess:
command,_shell_label,meshctrl_shell_flags=_uploaded_script_launch_spec(platform,interpreter,remote_file)
action=['RunCommand','--id',asset.mesh_node_id,'--run',command]+meshctrl_shell_flags+['--reply']
if platform == 'windows' and interpreter == 'powershell' and '--powershell' in action:
raise RuntimeError('Interner Dispatcherfehler: Dateibasierte PowerShell-Jobs dürfen MeshCtrl nicht im PowerShell-Modus starten.')
return _run_meshctrl(cfg,asset,password,action,timeout)
def _cleanup_remote_directory(cfg: dict, asset: Asset, password: str, platform: str, remote_dir: str, timeout: int) -> subprocess.CompletedProcess:
if platform == 'windows':
command="Remove-Item -LiteralPath '"+remote_dir.replace("'","''")+"' -Recurse -Force -ErrorAction SilentlyContinue"
action=['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply']
else:
action=['RunCommand','--id',asset.mesh_node_id,'--run',f"rm -rf -- '{remote_dir}'",'--reply']
return _run_meshctrl(cfg,asset,password,action,timeout)
def _add_job_event(db, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None) -> None:
db.add(JobEvent(
job_id=job.id,
attempt=int(job.attempt_count or 0),
event_type=event_type,
status=status if status is not None else job.status,
message=(message or "")[:4000] or None,
))
def execute_job(job_id: int, token: str, callback_base: str) -> None:
# Reserve the dispatch slot before opening a database session. With large
# bulk selections this keeps waiting workers from occupying the connection
# pool while they wait for their configured MeshCtrl start interval.
dispatch_delay_seconds, dispatch_wait_seconds = _wait_for_dispatch_slot()
db = SessionLocal()
temp_path: str | None = None
temp_dir: str | None = None
try:
job = db.get(SoftwareJob, job_id)
asset = db.get(Asset, job.asset_id) if job else None
if not job or not asset or not asset.mesh_node_id:
if job:
job.status='failed'
job.message='Asset besitzt keine MeshCentral Node-ID.'
job.finished_at=datetime.utcnow()
sync_asset_job_state(db, job)
db.commit()
return
callback_url=f"{callback_base}/api/software-jobs/{job.id}/callback?token={quote(token)}"
definition_id=None
if job.job_type=='job_definition':
try: definition_id=int((job.parameters or {}).get('definition_id'))
except (TypeError,ValueError): raise RuntimeError('Jobdefinition fehlt oder ist ungültig.')
payload,interpreter,upload_required=_job_execution_artifacts(db,job.platform,callback_url,job.id,asset,definition_id)
job.attempt_count=(job.attempt_count or 0)+1
attempt=job.attempt_count
remote_dir,remote_file=_remote_job_paths(job.platform,job.id,attempt,interpreter)
job.resolved_script=payload
launch_command=None
launch_shell=None
if upload_required:
launch_command,launch_shell,_launch_flags=_uploaded_script_launch_spec(job.platform,interpreter,remote_file)
job.command_preview=f'UPLOAD -> {remote_file}\nSHELL -> {launch_shell}\nEXECUTE -> {launch_command}'
else:
job.command_preview=payload
job.status='sending'
_add_job_event(db,job,'sending','sending','Jobdatei wird über MeshCentral übertragen.')
sync_asset_job_state(db, job)
db.commit()
cfg=load_config().get('meshcentral',{})
password_env=str(cfg.get('password_env') or 'MESHCENTRAL_PASSWORD')
password=os.getenv(password_env,'')
if not password: raise RuntimeError(f'MeshCentral-Passwortvariable {password_env} ist nicht gesetzt.')
timeout=max(30,int(cfg.get('timeout_seconds') or 600))
job_parameters = job.parameters if isinstance(job.parameters, dict) else {}
diagnostics=[
'--- File dispatcher diagnostics ---',
f'Dispatch interval configured: {dispatch_delay_seconds:g} seconds',
f'Dispatch queue wait: {dispatch_wait_seconds:.3f} seconds',
f'Job type (technical): {job.job_type}',
f'Package (technical): {job.package.name if job.package else job.package_id}',
f'Creation mode: {job_parameters.get("_creation_mode") or "single"}',
f'Bulk batch id: {job_parameters.get("_bulk_batch_id") or "-"}',
f'Bulk position: {job_parameters.get("_bulk_position") or "-"}/{job_parameters.get("_bulk_total") or "-"}',
f'Mesh node id: {asset.mesh_node_id}',
f'Interpreter: {interpreter}',
f'Upload required: {upload_required}',
f'Resolved script characters: {len(payload)}',
f'Resolved script SHA-256: {hashlib.sha256(payload.encode("utf-8")).hexdigest()}',
]
if job.job_type == 'job_definition':
diagnostics += [
f'Definition id: {job_parameters.get("definition_id") or "-"}',
f'Definition name: {job_parameters.get("definition_name") or "-"}',
f'Definition kind: {job_parameters.get("definition_job_kind") or "-"}',
f'Definition revision: {job_parameters.get("definition_revision") or "-"}',
f'Definition interpreter: {job_parameters.get("definition_interpreter") or interpreter}',
f'Definition source type: {job_parameters.get("definition_source_type") or "-"}',
]
if upload_required:
suffix=_script_extension(interpreter)
temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-')
temp_path=str(Path(temp_dir)/('run'+suffix))
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
upload_results=[]
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
upload_results.append(uploaded)
diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}']
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
if not upload_ok:
# MeshCtrl can return code 0 together with "Upload error".
# Recreate the directory, remove any stale target and retry once.
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file)
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
upload_results.append(uploaded)
diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}']
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
if not upload_ok:
combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip())
raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}')
diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False']
started=datetime.utcnow()
result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)
finished=datetime.utcnow()
diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
else:
started=datetime.utcnow()
action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply']
if job.platform=='windows' and interpreter=='powershell': action += ['--powershell']
result=_run_meshctrl(cfg,asset,password,action,timeout)
finished=datetime.utcnow()
diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
now=datetime.utcnow()
# MeshCtrl itself returns code 0 even for some remote PowerShell parser
# errors. Only delete the remote files after a completed successful
# callback, never merely because the MeshCtrl process exited cleanly.
db.expire_all()
callback_state=db.get(SoftwareJob,job.id)
cleanup_result=None
callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success')
diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}']
if upload_required and callback_success:
cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120))
diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}']
elif upload_required:
diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.']
db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False)
db.commit()
terminal_states={'success','failed','partial','timeout','cancelled'}
pending=db.query(SoftwareJob).filter(SoftwareJob.id==job.id,SoftwareJob.callback_completed_at.is_(None),SoftwareJob.status.notin_(terminal_states))
if result.returncode==0:
message='Skriptdatei ausgeführt; Callback wird erwartet.'
if upload_required and not callback_success:
message+=' Jobdateien bleiben bis zu einem bestätigten erfolgreichen Callback erhalten.'
if upload_required and cleanup_result is not None and cleanup_result.returncode!=0:
message+=' Die lokale Jobdatei konnte nicht automatisch gelöscht werden.'
pending.update({SoftwareJob.status:'sent',SoftwareJob.message:message},synchronize_session=False)
else:
pending.update({SoftwareJob.status:'failed',SoftwareJob.message:f'MeshCtrl-Ausführungsfehler {result.returncode}; Jobdateien bleiben zur Diagnose erhalten.',SoftwareJob.finished_at:now},synchronize_session=False)
db.expire_all()
refreshed=db.get(SoftwareJob,job.id)
if refreshed:
_add_job_event(db,refreshed,'meshctrl_result',refreshed.status,refreshed.message)
sync_asset_job_state(db, refreshed)
db.commit()
except Exception as exc:
job=db.get(SoftwareJob,job_id)
if job:
job.status='failed'
job.message=str(exc)
job.finished_at=datetime.utcnow()
if 'diagnostics' in locals() and diagnostics:
job.meshctrl_stdout='\n'.join(diagnostics)[-30000:]
_add_job_event(db,job,'error','failed',str(exc))
sync_asset_job_state(db, job)
db.commit()
finally:
if temp_path:
try: os.unlink(temp_path)
except OSError: pass
if temp_dir:
try: os.rmdir(temp_dir)
except OSError: pass
db.close()
+13
View File
@@ -0,0 +1,13 @@
/* Registry job editor: action-specific fields */
.registry-value-fields,
.registry-permission-fields {
display: contents;
}
.registry-value-fields[hidden],
.registry-permission-fields[hidden] {
display: none !important;
}
/* Asset job-status exclusion filter: checkboxes stay left-aligned and vertical. */
.job-filter-status-fieldset{border:1px solid var(--border-color,#415166);border-radius:8px;padding:.85rem 1rem;margin:0}.job-filter-status-fieldset legend{padding:0 .35rem;font-weight:700}.job-filter-status-options{display:flex;flex-direction:column;align-items:flex-start;gap:.5rem;width:100%;margin-top:.65rem}.job-filter-status-options .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;max-width:none!important;text-align:left!important}.job-filter-status-options .checkbox-label input{flex:0 0 auto!important;margin:0!important}.job-filter-status-actions{justify-content:flex-start;margin-top:.75rem}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+238
View File
@@ -0,0 +1,238 @@
(() => {
function init() {
const table = document.getElementById('asset-table');
if (!table || table.dataset.columnManagerReady === '1') return;
table.dataset.columnManagerReady = '1';
const scroll = document.getElementById('asset-table-scroll');
const topScroll = document.getElementById('asset-table-scroll-top');
const topInner = topScroll?.firstElementChild;
const dialog = document.getElementById('column-settings-dialog');
const list = document.getElementById('column-settings-list');
const openButton = document.getElementById('column-settings-button');
const showAllButton = document.getElementById('show-all-columns');
const resetButton = document.getElementById('reset-columns');
const menu = document.getElementById('column-context-menu');
const hideMenuButton = document.getElementById('context-hide-column');
const openMenuButton = document.getElementById('context-open-columns');
const keyBase = table.dataset.storageKey || 'asset-columns-all';
const hiddenKey = `${keyBase}-hidden`;
const styleId = `asset-column-visibility-${keyBase.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
let contextField = null;
let resizeFrame = 0;
let lastContainerWidth = -1;
const headers = () =>
[...table.querySelectorAll('thead tr:first-child th[data-field]')];
const fieldLabel = field => {
const th = table.querySelector(
`thead tr:first-child th[data-field="${CSS.escape(field)}"]`
);
return th?.dataset.label || th?.textContent?.trim() || field;
};
const allFields = () => headers().map(th => th.dataset.field);
function getHidden() {
try {
const value = JSON.parse(window.AssetBrowserState?.get(hiddenKey) || '[]');
return Array.isArray(value) ? value : [];
} catch (_) {
return [];
}
}
function setHidden(fields) {
window.AssetBrowserState?.set(hiddenKey, JSON.stringify([...new Set(fields)]));
}
function columnWidth(header) {
const configured = Number.parseInt(header.dataset.columnWidth || '', 10);
return Number.isFinite(configured) && configured > 0 ? configured : 180;
}
function visibleTableWidth(hidden) {
const fieldWidth = headers().reduce(
(sum, header) =>
hidden.has(header.dataset.field) ? sum : sum + columnWidth(header),
0
);
const fixedWidth = Number.parseInt(table.dataset.fixedColumnWidth || '224', 10);
return Math.max(640, fieldWidth + fixedWidth);
}
function updateTopScrollbar(tableWidth) {
if (!scroll || !topScroll || !topInner) return;
topInner.style.width = `${tableWidth}px`;
topScroll.hidden = tableWidth <= scroll.clientWidth + 1;
}
function buildList() {
if (!list) return;
const hidden = new Set(getHidden());
const fragment = document.createDocumentFragment();
allFields().forEach(field => {
const label = document.createElement('label');
label.className = 'checkbox-option';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = !hidden.has(field);
input.addEventListener('change', () =>
input.checked ? showField(field) : hideField(field)
);
const span = document.createElement('span');
span.textContent = fieldLabel(field);
label.append(input, span);
fragment.appendChild(label);
});
list.replaceChildren(fragment);
}
function applyHidden() {
const hidden = new Set(getHidden());
let style = document.getElementById(styleId);
if (!style) {
style = document.createElement('style');
style.id = styleId;
document.head.appendChild(style);
}
style.textContent = [...hidden]
.map(field =>
`#asset-table [data-field="${CSS.escape(field)}"]{display:none!important}`
)
.join('\n');
const tableWidth = visibleTableWidth(hidden);
table.style.width = `${tableWidth}px`;
table.style.minWidth = `${tableWidth}px`;
table.dataset.calculatedWidth = String(tableWidth);
buildList();
requestAnimationFrame(() => updateTopScrollbar(tableWidth));
window.dispatchEvent(new Event('asset-table-columns-changed'));
}
function hideField(field) {
if (!field) return;
const hidden = getHidden();
const visibleFields = allFields().filter(item => !hidden.includes(item));
if (visibleFields.length <= 1 || hidden.includes(field)) return;
hidden.push(field);
setHidden(hidden);
applyHidden();
}
function showField(field) {
setHidden(getHidden().filter(item => item !== field));
applyHidden();
}
let syncing = false;
scroll?.addEventListener('scroll', () => {
if (syncing || !topScroll) return;
syncing = true;
topScroll.scrollLeft = scroll.scrollLeft;
syncing = false;
}, { passive: true });
topScroll?.addEventListener('scroll', () => {
if (syncing || !scroll) return;
syncing = true;
scroll.scrollLeft = topScroll.scrollLeft;
syncing = false;
}, { passive: true });
headers().forEach(th => {
th.addEventListener('contextmenu', event => {
event.preventDefault();
contextField = th.dataset.field;
if (!menu) return;
menu.hidden = false;
const maxX = window.innerWidth - menu.offsetWidth - 8;
const maxY = window.innerHeight - menu.offsetHeight - 8;
menu.style.left = `${Math.max(8, Math.min(event.clientX, maxX))}px`;
menu.style.top = `${Math.max(8, Math.min(event.clientY, maxY))}px`;
});
});
hideMenuButton?.addEventListener('click', () => {
hideField(contextField);
menu.hidden = true;
});
openMenuButton?.addEventListener('click', () => {
menu.hidden = true;
buildList();
dialog?.showModal();
});
document.addEventListener('click', event => {
if (menu && !menu.contains(event.target)) menu.hidden = true;
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && menu) menu.hidden = true;
});
openButton?.addEventListener('click', () => {
buildList();
dialog?.showModal();
});
showAllButton?.addEventListener('click', () => {
setHidden([]);
applyHidden();
});
resetButton?.addEventListener('click', () => {
setHidden([]);
window.AssetBrowserState?.remove?.(keyBase);
applyHidden();
window.location.reload();
});
function scheduleScrollbarUpdate() {
cancelAnimationFrame(resizeFrame);
resizeFrame = requestAnimationFrame(() => {
const tableWidth = Number.parseInt(table.dataset.calculatedWidth || '0', 10);
if (tableWidth) updateTopScrollbar(tableWidth);
});
}
window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true });
if ('ResizeObserver' in window && scroll) {
const resizeObserver = new ResizeObserver(entries => {
const width = Math.round(entries[0]?.contentRect?.width || 0);
if (width === lastContainerWidth) return;
lastContainerWidth = width;
scheduleScrollbarUpdate();
});
resizeObserver.observe(scroll);
}
window.addEventListener('asset-table-content-reloaded', event => {
if (!event.detail?.table || event.detail.table === table) {
applyHidden();
}
});
applyHidden();
window.dispatchEvent(new CustomEvent('asset-table-columns-ready', {
detail: { table }
}));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
+112
View File
@@ -0,0 +1,112 @@
(() => {
function initialize() {
const table = document.getElementById('asset-table');
const openButton = document.getElementById('duplicate-search-button');
const dialog = document.getElementById('duplicate-search-dialog');
const select = document.getElementById('duplicate-field-select');
const applyButton = document.getElementById('duplicate-apply-button');
const clearButton = document.getElementById('duplicate-clear-button');
const ignoreEmpty = document.getElementById('duplicate-ignore-empty');
const caseInsensitive = document.getElementById('duplicate-case-insensitive');
const summary = document.getElementById('duplicate-search-summary');
if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return;
table.dataset.duplicateSearchReady = '1';
const dataRows = () => [...table.tBodies[0].rows].filter(row => !row.querySelector('.empty-state'));
const normallyVisible = row => {
if (row.classList.contains('duplicate-hidden')) return false;
if (row.hidden || row.classList.contains('hidden')) return false;
const style = getComputedStyle(row);
return style.display !== 'none' && style.visibility !== 'hidden';
};
const availableFields = () => [...table.querySelectorAll('thead tr:first-child th[data-field]')]
.filter(th => th.style.display !== 'none')
.map(th => ({
field: th.dataset.field,
label: th.dataset.label || th.textContent.trim()
}));
function fillSelect() {
const previous = select.value;
select.innerHTML = '';
availableFields().forEach(item => {
const option = document.createElement('option');
option.value = item.field;
option.textContent = item.label;
select.appendChild(option);
});
if ([...select.options].some(option => option.value === previous)) {
select.value = previous;
}
}
function clearDuplicateFilter() {
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
summary.hidden = true;
window.dispatchEvent(new Event('asset-table-filter-changed'));
}
function normalize(value) {
let result = value.trim().replace(/\s+/g, ' ');
if (caseInsensitive.checked) result = result.toLocaleLowerCase();
return result;
}
function applyDuplicateFilter() {
// Remove only our previous result first. Normal column filters stay intact.
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
const field = select.value;
if (!field) return;
const candidates = dataRows().filter(normallyVisible);
const groups = new Map();
candidates.forEach(row => {
const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`);
const value = normalize(cell?.textContent || '');
if (ignoreEmpty.checked && !value) return;
const rows = groups.get(value) || [];
rows.push(row);
groups.set(value, rows);
});
const duplicateRows = new Set();
let duplicateGroups = 0;
groups.forEach(rows => {
if (rows.length > 1) {
duplicateGroups += 1;
rows.forEach(row => duplicateRows.add(row));
}
});
candidates.forEach(row => {
if (duplicateRows.has(row)) {
row.classList.add('duplicate-match');
} else {
row.classList.add('duplicate-hidden');
}
});
summary.textContent = `${duplicateRows.size} Datensätze in ${duplicateGroups} Dublettengruppen gefunden.`;
summary.hidden = false;
window.dispatchEvent(new Event('asset-table-filter-changed'));
dialog.close();
}
openButton.addEventListener('click', () => {
// Search always starts from the current normal filter result, not from
// a previous duplicate result.
clearDuplicateFilter();
fillSelect();
dialog.showModal();
});
applyButton.addEventListener('click', applyDuplicateFilter);
clearButton.addEventListener('click', clearDuplicateFilter);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
window.addEventListener('asset-table-content-reloaded', initialize);
})();
+41
View File
@@ -0,0 +1,41 @@
(() => {
const tabs = Array.from(document.querySelectorAll('[data-inventory-tab]'));
const panels = Array.from(document.querySelectorAll('[data-inventory-panel]'));
tabs.forEach((tab) => tab.addEventListener('click', () => {
const target = tab.dataset.inventoryTab;
tabs.forEach((item) => item.classList.toggle('active', item === tab));
panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.inventoryPanel === target));
}));
const filter = document.getElementById('software-inventory-filter');
const table = document.getElementById('software-inventory-table');
if (filter && table) {
filter.addEventListener('input', () => {
const needle = filter.value.trim().toLocaleLowerCase();
table.querySelectorAll('tbody tr').forEach((row) => {
row.hidden = needle && !row.textContent.toLocaleLowerCase().includes(needle);
});
});
}
})();
(() => {
const tree = document.querySelector('.inventory-tree');
if (!tree) return;
const state = window.AssetBrowserState;
const assetId = location.pathname.match(/\/assets\/(\d+)/)?.[1] || 'unknown';
const storageKey = `inventory-tree:${assetId}`;
let openPaths = new Set();
if (state) {
try { openPaths = new Set(JSON.parse(state.get(storageKey) || '[]')); } catch (_) {}
}
tree.querySelectorAll('details[data-inventory-tree-path]').forEach((node) => {
const path = node.dataset.inventoryTreePath;
node.open = openPaths.has(path);
node.addEventListener('toggle', () => {
if (!state) return;
if (node.open) openPaths.add(path); else openPaths.delete(path);
state.set(storageKey, JSON.stringify([...openPaths]));
});
});
})();
+61
View File
@@ -0,0 +1,61 @@
(() => {
const page = document.getElementById('asset-list-page');
const loader = document.getElementById('asset-page-loader');
const table = document.getElementById('asset-table');
if (!page || !table) return;
const required = [
'tableToolsReady',
'browserStateReady',
'columnOrderReady',
'columnManagerReady'
];
let revealed = false;
let fallbackTimer = 0;
function ready() {
return required.every(marker => table.dataset[marker] === '1');
}
function reveal() {
if (revealed) return;
revealed = true;
clearTimeout(fallbackTimer);
const finish = () => requestAnimationFrame(() => {
page.classList.remove('asset-list-initializing');
page.setAttribute('aria-busy', 'false');
document.documentElement.classList.remove('asset-page-booting');
loader?.setAttribute('hidden', '');
window.dispatchEvent(new CustomEvent('asset-page-ready', {
detail: { table }
}));
});
if (document.fonts?.ready) {
document.fonts.ready.then(finish, finish);
} else {
finish();
}
}
function check() {
if (ready()) reveal();
}
[
'asset-table-structure-ready',
'asset-table-state-ready',
'asset-table-column-order-ready',
'asset-table-columns-ready',
'asset-list-count-updated'
].forEach(name => window.addEventListener(name, check));
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', check, { once: true });
} else {
check();
}
fallbackTimer = window.setTimeout(reveal, 2500);
})();
+103
View File
@@ -0,0 +1,103 @@
(() => {
const prefix = '[AssetManager][Performance]';
const pageStart = performance.now();
const active = new Map();
const results = [];
const now = () => Math.round((performance.now() - pageStart) * 10) / 10;
const round = value => Math.round(value * 10) / 10;
function start(name, details = {}) {
active.set(name, performance.now());
console.debug(prefix, `+${now()} ms`, `START ${name}`, details);
}
function end(name, details = {}) {
const started = active.get(name);
const duration = started == null ? null : performance.now() - started;
active.delete(name);
const result = {
Modul: name,
Start_ms: started == null ? null : round(started - pageStart),
Dauer_ms: duration == null ? null : round(duration),
...details
};
results.push(result);
const logger = duration != null && duration >= 50 ? console.warn : console.debug;
logger(prefix, `+${now()} ms`, `ENDE ${name}`, result);
return duration;
}
function measure(name, callback, details = {}) {
const started = performance.now();
try {
return callback();
} finally {
const duration = performance.now() - started;
const result = {
Modul: name,
Start_ms: round(started - pageStart),
Dauer_ms: round(duration),
...details
};
results.push(result);
const logger = duration >= 50 ? console.warn : console.debug;
logger(prefix, `+${now()} ms`, name, result);
}
}
async function measureAsync(name, callback, details = {}) {
const started = performance.now();
try {
return await callback();
} finally {
const duration = performance.now() - started;
const result = {
Modul: name,
Start_ms: round(started - pageStart),
Dauer_ms: round(duration),
...details
};
results.push(result);
const logger = duration >= 50 ? console.warn : console.debug;
logger(prefix, `+${now()} ms`, name, result);
}
}
function report(name, duration, details = {}, level = null) {
const result = {
Modul: name,
Start_ms: details.Start_ms ?? null,
Dauer_ms: round(duration),
...details
};
delete result.Start_ms;
result.Start_ms = details.Start_ms ?? null;
results.push(result);
try {
const safeName = name.replace(/[^a-zA-Z0-9:_-]+/g, '-');
performance.mark(`${safeName}:end`);
performance.measure(safeName, {
start: Math.max(0, performance.now() - duration),
end: performance.now()
});
} catch (_) {}
const logger = level === 'warn' || (level == null && duration >= 50)
? console.warn
: console.debug;
logger(prefix, `+${now()} ms`, name, result);
return result;
}
function summary() {
const sorted = [...results].sort((a, b) => (b.Dauer_ms || 0) - (a.Dauer_ms || 0));
console.groupCollapsed(`${prefix} Zusammenfassung – langsamste Vorgänge`);
console.table(sorted.slice(0, 30));
console.groupEnd();
return sorted;
}
window.AssetPerf = { start, end, measure, measureAsync, report, summary, results };
})();
+113
View File
@@ -0,0 +1,113 @@
(() => {
const perf = window.AssetPerf;
perf?.start('browser-state:gesamtes Modul');
const persistent = document.body?.dataset.persistBrowserState === 'true';
const storage = persistent ? window.localStorage : window.sessionStorage;
const prefix = 'assetmanager:';
const api = {
persistent,
get(key) {
try { return storage.getItem(prefix + key); } catch (_) { return null; }
},
set(key, value) {
try { storage.setItem(prefix + key, value); } catch (_) {}
},
remove(key) {
try { storage.removeItem(prefix + key); } catch (_) {}
}
};
window.AssetBrowserState = api;
function tableKey(table, index) {
return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`;
}
function filterControls(table) {
return [...table.querySelectorAll('thead input, thead select')].filter(control => {
const type = (control.type || '').toLowerCase();
return !['checkbox', 'radio', 'button', 'submit'].includes(type);
});
}
function controlKey(control, index) {
const cell = control.closest('th');
return control.name || control.dataset.field || cell?.dataset.field || `filter-${index}`;
}
function restoreTable(table, index) {
if (table.dataset.browserStateReady === '1') return;
if (table.dataset.serverFilters === '1') {
table.dataset.browserStateReady = '1';
return;
}
const restoreStarted = performance.now();
const controls = filterControls(table);
if (!controls.length) return;
table.dataset.browserStateReady = '1';
const key = `${tableKey(table, index)}:filters`;
let values = {};
try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {}
controls.forEach((control, controlIndex) => {
const field = controlKey(control, controlIndex);
if (Object.prototype.hasOwnProperty.call(values, field)) {
// Restore without dispatching one input/change pair per column.
control.value = values[field];
}
const save = () => {
const current = {};
filterControls(table).forEach((item, itemIndex) => {
current[controlKey(item, itemIndex)] = item.value;
});
api.set(key, JSON.stringify(current));
};
control.addEventListener('input', save);
control.addEventListener('change', save);
});
// One filtering pass after every stored value is in place.
if (typeof table.assetApplyFilters === 'function') {
table.assetApplyFilters();
}
table.dataset.browserStateRestored = '1';
window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } }));
const duration = performance.now() - restoreStarted;
const logger = duration >= 50 ? console.warn : console.debug;
logger('[AssetManager][Performance]', 'browser-state:restoreTable', {
dauer_ms: Math.round(duration * 10) / 10,
filter: controls.length,
gespeicherte_werte: Object.keys(values).length
});
}
function initializeTables() {
document.querySelectorAll('table').forEach(restoreTable);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTables, { once: true });
} else {
initializeTables();
}
// Filter controls are added by table-tools.js. Initialize only on its explicit
// completion event instead of observing every DOM mutation in the document.
window.addEventListener('asset-table-structure-ready', initializeTables);
// A programmatic reset does not emit input/change events. Remove the
// corresponding persisted filter state explicitly so it cannot return after
// a page refresh.
window.addEventListener('asset-table-filters-cleared', event => {
const table = event.detail?.table;
if (!(table instanceof HTMLTableElement)) return;
const tables = [...document.querySelectorAll('table')];
const index = tables.indexOf(table);
const key = `${tableKey(table, index >= 0 ? index : 0)}:filters`;
api.remove(key);
});
perf?.end('browser-state:gesamtes Modul');
})();
+15
View File
@@ -0,0 +1,15 @@
(() => {
const dialog = document.getElementById('issue-dialog');
const form = document.getElementById('issue-form');
if (!dialog || !form) return;
const dateInput = form.querySelector('input[name="assigned_on"]');
if (dateInput && !dateInput.value) dateInput.value = new Date().toISOString().slice(0, 10);
document.querySelectorAll('[data-issue-action]').forEach(button => {
button.addEventListener('click', () => {
form.action = button.dataset.issueAction;
const target = document.getElementById('issue-return-to');
if (target) target.value = button.dataset.returnTo || 'detail';
dialog.showModal();
});
});
})();
+132
View File
@@ -0,0 +1,132 @@
(() => {
const perf = window.AssetPerf;
perf?.start('list-tools:gesamtes Modul');
const tables = new Map();
function isDataRow(row) {
if (!row || row.closest('thead')) return false;
if (row.classList.contains('column-filter-row') || row.classList.contains('filter-row')) return false;
if (row.querySelector('.empty-state')) return false;
return true;
}
function isVisible(row) {
if (row.hidden || row.classList.contains('hidden')) return false;
return true;
}
function countTarget(table, index) {
const toolbar = table.closest('main')?.querySelector('.toolbar');
if (!toolbar) return null;
let actions = toolbar.querySelector('.toolbar-actions');
if (!actions) {
actions = document.createElement('div');
actions.className = 'toolbar-actions';
toolbar.appendChild(actions);
}
let target = actions.querySelector(`[data-table-count-for="${index}"]`);
if (!target) {
target = document.createElement('span');
target.className = 'record-count';
target.dataset.tableCountFor = String(index);
actions.insertBefore(target, actions.firstChild);
}
return target;
}
function update(table, index) {
const updateStarted = performance.now();
const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow);
const visible = rows.filter(isVisible).length;
const target = countTarget(table, index);
if (target) {
const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen';
target.textContent = template
.replace('{visible}', String(visible))
.replace('{total}', String(rows.length));
target.title = document.body.dataset.recordCountTitle || '';
}
window.dispatchEvent(new CustomEvent('asset-list-count-updated', {
detail: { table, visible, total: rows.length }
}));
const duration = performance.now() - updateStarted;
const logger = duration >= 50 ? console.warn : console.debug;
logger('[AssetManager][Performance]', 'list-tools:update', {
dauer_ms: Math.round(duration * 10) / 10,
sichtbar: visible,
gesamt: rows.length
});
}
function schedule(table, index) {
const state = tables.get(table) || {};
if (state.frame) cancelAnimationFrame(state.frame);
state.frame = requestAnimationFrame(() => {
state.frame = null;
update(table, index);
});
tables.set(table, state);
}
function initialize() {
document.querySelectorAll('table.data-table').forEach((table, index) => {
if (table.dataset.listToolsReady === '1') {
schedule(table, index);
return;
}
table.dataset.listToolsReady = '1';
schedule(table, index);
table.addEventListener('input', () => setTimeout(() => schedule(table, index), 0));
table.addEventListener('change', () => setTimeout(() => schedule(table, index), 0));
const body = table.tBodies[0];
if (body) {
const observer = new MutationObserver(mutations => {
const started = performance.now();
schedule(table, index);
const duration = performance.now() - started;
console.debug('[AssetManager][Performance]', 'list-tools:MutationObserver', {
dauer_ms: Math.round(duration * 10) / 10,
mutationen: mutations.length
});
});
observer.observe(body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['hidden', 'style', 'class']
});
}
});
if (document.getElementById('asset-table')) {
document.body.classList.add('single-scroll-list');
} else {
document.body.classList.remove('single-scroll-list');
}
}
document.addEventListener('input', () => {
document.querySelectorAll('table.data-table').forEach((table, index) => {
setTimeout(() => schedule(table, index), 0);
});
});
document.addEventListener('change', () => {
document.querySelectorAll('table.data-table').forEach((table, index) => {
setTimeout(() => schedule(table, index), 0);
});
});
window.addEventListener('asset-table-filter-changed', initialize);
window.addEventListener('asset-table-content-reloaded', initialize);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
perf?.end('list-tools:gesamtes Modul');
})();
+74
View File
@@ -0,0 +1,74 @@
(() => {
'use strict';
const language = document.documentElement.lang || navigator.language || 'de-DE';
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
const formatter = new Intl.DateTimeFormat(language, {
dateStyle: 'short',
timeStyle: 'medium'
});
function parseUtc(value) {
if (!value) return null;
let normalized = String(value).trim();
if (!normalized) return null;
// Project database timestamps without an offset are UTC by convention.
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(normalized)) {
normalized = normalized.replace(' ', 'T') + 'Z';
}
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatUtc(value, fallback = '') {
const date = parseUtc(value);
return date ? formatter.format(date) : (fallback || value || '');
}
function localizeElement(element) {
const value = element.getAttribute('datetime') || element.dataset.utc;
if (!value) return;
const formatted = formatUtc(value, element.textContent);
element.textContent = formatted;
element.title = `${value} UTC · ${zone}`;
element.dataset.localTimeApplied = 'true';
const cell = element.closest('[data-sort-value]');
if (cell) cell.dataset.sortValue = value;
}
const isoPattern = /(?<!\d)(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)(?!\d)/g;
function localizeLogText(element) {
if (!element || element.dataset.localizingLog === 'true') return;
const original = element.textContent || '';
const converted = original.replace(isoPattern, match => formatUtc(match, match));
if (converted !== original) {
element.dataset.localizingLog = 'true';
element.textContent = converted;
delete element.dataset.localizingLog;
}
element.title = zone;
}
function scan(root = document) {
if (root.matches?.('time[data-utc], time[datetime].local-time')) localizeElement(root);
root.querySelectorAll?.('time[data-utc], time[datetime].local-time').forEach(localizeElement);
if (root.matches?.('[data-localize-log]')) localizeLogText(root);
root.querySelectorAll?.('[data-localize-log]').forEach(localizeLogText);
}
window.AssetManagerTime = {formatUtc, parseUtc, zone, scan};
scan(document);
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) scan(node);
});
const owner = mutation.target.nodeType === Node.ELEMENT_NODE
? mutation.target.closest?.('[data-localize-log]')
: mutation.target.parentElement?.closest?.('[data-localize-log]');
if (owner) localizeLogText(owner);
}
});
observer.observe(document.body, {childList: true, subtree: true, characterData: true});
})();
+58
View File
@@ -0,0 +1,58 @@
(() => {
const elements = [...document.querySelectorAll('[data-presence-asset]')];
if (!elements.length) return;
function formattedDate(value) {
return value ? (window.AssetManagerTime?.formatUtc(value, value) || value) : '–';
}
function updateElement(element, item) {
const state = ['online', 'offline'].includes(item.state) ? item.state : 'unknown';
element.classList.remove('presence-online', 'presence-offline', 'presence-unknown');
element.classList.add(`presence-${state}`);
element.dataset.filterValue = element.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] || state;
const label = element.querySelector('[data-presence-label]');
if (label) {
label.textContent =
element.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] || state;
}
const lastSeenText = formattedDate(item.last_seen_at);
const lastSeenPrefix = element.dataset.lastSeenLabel || 'Last seen';
element.title = `${label?.textContent || state} · ${lastSeenPrefix}: ${lastSeenText}`;
const meta = document.querySelector(`[data-presence-meta="${CSS.escape(String(item.asset_id))}"]`);
if (meta) {
const stateElement = meta.querySelector('[data-presence-meta-state]');
const updatedElement = meta.querySelector('[data-presence-updated]');
const lastOnlineElement = meta.querySelector('[data-presence-last-online]');
const lastSeenElement = meta.querySelector('[data-presence-last-seen]');
if (stateElement) stateElement.textContent = label?.textContent || state;
if (updatedElement) updatedElement.textContent = formattedDate(item.updated_at);
if (lastOnlineElement) lastOnlineElement.textContent = formattedDate(item.last_online_at);
if (lastSeenElement) lastSeenElement.textContent = formattedDate(item.last_seen_at);
}
}
async function refreshPresence() {
try {
const response = await fetch('/api/assets/presence', {cache: 'no-store'});
if (!response.ok) return;
const payload = await response.json();
const byId = new Map((payload.items || []).map(item => [String(item.asset_id), item]));
elements.forEach(element => {
const item = byId.get(String(element.dataset.presenceAsset));
if (item) updateElement(element, item);
});
document.querySelectorAll('table.data-table').forEach(table => {
table.assetRebuildFilterCache?.();
table.assetApplyFilters?.();
});
} catch (_) {
// Keep the last known state visible when the API is temporarily unavailable.
}
}
refreshPresence();
window.setInterval(refreshPresence, 5000);
})();
@@ -0,0 +1,44 @@
(()=>{
function init(root=document){
root.querySelectorAll('[data-software-selection]').forEach(container=>{
if(container.dataset.selectionReady==='1') return;
container.dataset.selectionReady='1';
const table=container.querySelector('table');
const form=container.querySelector('form[data-software-delete-form]');
const master=container.querySelector('[data-software-select-all]');
const button=container.querySelector('[data-software-delete-button]');
const countNode=container.querySelector('[data-software-selection-count]');
if(!table||!form) return;
const boxes=()=>[...table.querySelectorAll('tbody input[data-software-entry-checkbox]')];
const visibleBoxes=()=>boxes().filter(box=>!box.closest('tr')?.hidden);
const update=()=>{
const selected=boxes().filter(box=>box.checked);
if(button) button.disabled=selected.length===0;
if(countNode) countNode.textContent=String(selected.length);
if(master){
const visible=visibleBoxes();
const selectedVisible=visible.filter(box=>box.checked).length;
master.checked=visible.length>0&&selectedVisible===visible.length;
master.indeterminate=selectedVisible>0&&selectedVisible<visible.length;
master.disabled=visible.length===0;
}
};
master?.addEventListener('change',()=>{ visibleBoxes().forEach(box=>box.checked=master.checked); update(); });
table.addEventListener('change',event=>{ if(event.target.matches('[data-software-entry-checkbox]')) update(); });
form.addEventListener('submit',event=>{
const selected=boxes().filter(box=>box.checked);
if(!selected.length){ event.preventDefault(); return; }
form.querySelectorAll('input[data-generated-entry-id]').forEach(node=>node.remove());
selected.forEach(box=>{ const input=document.createElement('input'); input.type='hidden'; input.name='entry_ids'; input.value=box.value; input.dataset.generatedEntryId='1'; form.appendChild(input); });
const template=form.dataset.confirmTemplate||'Delete {count} selected software inventory entries?';
const warning=form.dataset.inventoryOnlyWarning||'';
const message=template.replace('{count}',String(selected.length))+(warning?'\n\n'+warning:'');
if(!window.confirm(message)) event.preventDefault();
});
window.addEventListener('asset-table-filter-changed',event=>{ if(event.detail?.table===table) update(); });
update();
});
}
document.addEventListener('DOMContentLoaded',()=>init());
window.AssetManagerSoftwareSelection={init};
})();
+74
View File
@@ -0,0 +1,74 @@
(() => {
function init() {
const table = document.querySelector('table[data-storage-key="software-jobs"]');
const master = document.getElementById('select-all-restartable-jobs');
const button = document.getElementById('restart-selected-jobs-button');
const form = document.getElementById('restart-selected-jobs-form');
const idContainer = document.getElementById('restart-selected-job-ids');
if (!table || !master || !button || !form || !idContainer) return;
const boxes = () => [...table.querySelectorAll('tbody .software-job-restart-select:not(:disabled)')];
const isVisible = box => {
const row = box.closest('tr');
return Boolean(row && !row.hidden && getComputedStyle(row).display !== 'none');
};
const visibleBoxes = () => boxes().filter(isVisible);
const selectedBoxes = () => boxes().filter(box => box.checked && isVisible(box));
function update() {
boxes().filter(box => !isVisible(box)).forEach(box => { box.checked = false; });
const visible = visibleBoxes();
const selected = selectedBoxes();
const count = selected.length;
const baseLabel = button.dataset.baseLabel || button.textContent.trim();
button.disabled = count === 0;
button.textContent = count > 0 ? `${baseLabel} (${count})` : baseLabel;
master.disabled = visible.length === 0;
master.checked = visible.length > 0 && count === visible.length;
master.indeterminate = count > 0 && count < visible.length;
}
master.addEventListener('change', () => {
visibleBoxes().forEach(box => { box.checked = master.checked; });
update();
});
table.addEventListener('change', event => {
if (event.target.matches('.software-job-restart-select')) update();
});
button.addEventListener('click', () => {
const selected = selectedBoxes();
if (!selected.length) return;
const template = form.dataset.confirmTemplate || 'Restart {count} selected jobs?';
if (!window.confirm(template.replace('{count}', String(selected.length)))) return;
idContainer.replaceChildren();
selected.forEach(box => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'job_ids';
input.value = box.value;
idContainer.appendChild(input);
});
form.requestSubmit();
});
const observer = new MutationObserver(update);
table.querySelectorAll('tbody tr').forEach(row => {
observer.observe(row, {attributes: true, attributeFilter: ['style', 'class', 'hidden']});
});
window.addEventListener('asset-table-filter-changed', event => {
if (!event.detail?.table || event.detail.table === table) update();
});
window.addEventListener('software-job-status-changed', update);
update();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, {once: true});
} else {
init();
}
})();
+103
View File
@@ -0,0 +1,103 @@
(() => {
const terminalStatuses = new Set(['success', 'failed', 'partial', 'timeout', 'cancelled']);
const dispatchingStatuses = new Set(['sending', 'running']);
const bulkBlockedStatuses = new Set(['success', 'sending', 'running']);
function init() {
const table = document.querySelector('table[data-storage-key="software-jobs"]');
if (!table) return;
const rows = () => [...table.querySelectorAll('tbody tr[data-job-id]')];
const activeRows = () => rows().filter(row => !terminalStatuses.has(row.dataset.jobStatus || 'created'));
function renderRestartCell(row, status) {
const cell = row.querySelector('[data-job-restart-cell]');
if (!cell) return;
const currentlyBlocked = Boolean(cell.querySelector('.muted'));
const mustBlock = dispatchingStatuses.has(status);
if (currentlyBlocked === mustBlock) return;
cell.replaceChildren();
if (mustBlock) {
const placeholder = document.createElement('span');
placeholder.className = 'muted';
placeholder.textContent = '—';
cell.appendChild(placeholder);
return;
}
const form = document.createElement('form');
form.method = 'post';
form.action = `/jobs/${row.dataset.jobId}/restart`;
form.className = 'inline-form';
form.addEventListener('submit', event => {
const message = table.dataset.restartConfirm || '';
if (message && !window.confirm(message)) event.preventDefault();
});
const button = document.createElement('button');
button.type = 'submit';
button.className = 'button button-secondary button-small';
button.textContent = table.dataset.restartLabel || '↻';
form.appendChild(button);
cell.appendChild(form);
}
function applyStatus(item) {
const row = table.querySelector(`tbody tr[data-job-id="${CSS.escape(String(item.id))}"]`);
if (!row) return;
const status = String(item.status || 'created');
const previous = row.dataset.jobStatus || '';
row.dataset.jobStatus = status;
const field = row.querySelector('[data-job-status-field]');
if (field) {
field.textContent = item.status_label || status;
field.className = `job-status job-status-${status}`;
}
const checkbox = row.querySelector('.software-job-restart-select');
if (checkbox) {
const hasNode = row.dataset.jobHasNode === '1';
checkbox.disabled = !hasNode || bulkBlockedStatuses.has(status);
if (checkbox.disabled) checkbox.checked = false;
}
renderRestartCell(row, status);
if (previous !== status) {
window.dispatchEvent(new CustomEvent('software-job-status-changed', {detail: {row, status}}));
}
}
let timer = null;
async function refresh() {
const active = activeRows();
if (!active.length) return;
const ids = active.map(row => Number(row.dataset.jobId)).filter(Number.isInteger);
try {
const response = await fetch('/api/software-jobs/statuses', {
method: 'POST',
cache: 'no-store',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
(payload.jobs || []).forEach(applyStatus);
if (activeRows().length) timer = window.setTimeout(refresh, 3000);
} catch (_) {
timer = window.setTimeout(refresh, 5000);
}
}
if (activeRows().length) timer = window.setTimeout(refresh, 1200);
window.addEventListener('beforeunload', () => {
if (timer) window.clearTimeout(timer);
}, {once: true});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, {once: true});
} else {
init();
}
})();
+503
View File
@@ -0,0 +1,503 @@
(() => {
const collator = new Intl.Collator(document.documentElement.lang || 'de', {
numeric: true,
sensitivity: 'base'
});
function controlValue(control) {
if (control.tagName === 'SELECT') {
const selected = control.options?.[control.selectedIndex];
return [
control.value || '',
selected?.textContent || ''
].join(' ');
}
if (control.type === 'checkbox' || control.type === 'radio') {
return control.checked
? [
control.value || '',
control.getAttribute('aria-label') || '',
control.closest('label')?.textContent || ''
].join(' ')
: '';
}
return [
control.value || '',
control.getAttribute('placeholder') || '',
control.getAttribute('aria-label') || ''
].join(' ');
}
function textOutsideControls(cell) {
const clone = cell.cloneNode(true);
clone.querySelectorAll('input, select, textarea, option').forEach(node => node.remove());
return (clone.textContent || '').trim();
}
function textValue(cell) {
if (!cell) return '';
const explicitFilterValue = cell.dataset.filterValue || '';
if (cell.dataset.sortValue !== undefined) {
return `${cell.dataset.sortValue} ${explicitFilterValue}`.trim();
}
const controls = [...cell.querySelectorAll('input, select, textarea')];
if (controls.length) {
const controlText = controls.map(controlValue).join(' ').trim();
const additionalText = textOutsideControls(cell);
return `${explicitFilterValue} ${controlText} ${additionalText}`.trim();
}
return `${explicitFilterValue} ${cell.textContent || ''}`.trim();
}
function parsedValue(value) {
const text = value.trim();
if (!text) return { type: 'empty', value: '' };
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) {
const timestamp = Date.parse(text.replace(' ', 'T'));
if (!Number.isNaN(timestamp)) return { type: 'date', value: timestamp };
}
if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(text)) {
const [day, month, year] = text.split('.').map(Number);
return { type: 'date', value: new Date(year, month - 1, day).getTime() };
}
if (/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(text)) {
return { type: 'ipv4', value: text.split('.').map(Number) };
}
const numeric = text.replace(/\s/g, '').replace(',', '.');
if (/^[-+]?\d+(?:\.\d+)?$/.test(numeric)) {
return { type: 'number', value: Number(numeric) };
}
return { type: 'text', value: text };
}
function compareValues(a, b) {
const left = parsedValue(a);
const right = parsedValue(b);
if (left.type === 'empty' && right.type !== 'empty') return 1;
if (right.type === 'empty' && left.type !== 'empty') return -1;
if (left.type === right.type && (left.type === 'number' || left.type === 'date')) {
return left.value - right.value;
}
if (left.type === 'ipv4' && right.type === 'ipv4') {
for (let index = 0; index < 4; index += 1) {
const difference = left.value[index] - right.value[index];
if (difference) return difference;
}
return 0;
}
return collator.compare(String(left.value), String(right.value));
}
function initTable(table) {
const thead = table.tHead;
const tbody = table.tBodies[0];
if (!thead || !tbody || !thead.rows.length) return;
if (table.dataset.tableToolsReady === '1') return;
const headerRow = thead.rows[0];
const isAssetTable = table.id === 'asset-table';
const usesServerFilters = table.dataset.serverFilters === '1';
let serverFilterTimer = null;
// Normal tables get stable synthetic keys. The asset table already has
// real data-field keys on its movable columns; selection and action
// columns must remain without a field key.
if (!isAssetTable) {
[...headerRow.cells].forEach((header, index) => {
if (!header.dataset.field) {
header.dataset.field = `__col_${index}`;
header.dataset.syntheticField = '1';
}
});
[...tbody.rows].forEach(row => {
[...row.cells].forEach((cell, index) => {
const header = headerRow.cells[index];
if (header && !cell.dataset.field) {
cell.dataset.field = header.dataset.field;
cell.dataset.syntheticField = '1';
}
});
});
}
const filterRow = document.createElement('tr');
filterRow.className = 'column-filter-row';
filterRow.dataset.tableFilterRow = '1';
const filterDefinitions = [];
function filterStatusTarget() {
const wrapper = table.closest(
'#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table'
);
return wrapper || table;
}
function ensureFilterStatus() {
let status = table._filterStatusElement;
if (status?.isConnected) return status;
status = document.createElement('div');
status.className = 'table-filter-status';
status.hidden = true;
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
const icon = document.createElement('span');
icon.className = 'table-filter-status-icon';
icon.textContent = '⌕';
icon.setAttribute('aria-hidden', 'true');
const label = document.createElement('strong');
label.className = 'table-filter-status-label';
const details = document.createElement('span');
details.className = 'table-filter-status-details';
const clearButton = document.createElement('button');
clearButton.type = 'button';
clearButton.className = 'button button-secondary table-filter-clear';
clearButton.textContent =
document.body.dataset.clearFiltersLabel || 'Filter zurücksetzen';
clearButton.addEventListener('click', () => {
filterDefinitions.forEach(definition => {
definition.input.value = '';
});
applyFilters();
if (usesServerFilters) submitServerFilters(true);
// Keep the persisted browser state in sync with the visible table.
// Programmatic value changes do not trigger input/change automatically.
window.dispatchEvent(new CustomEvent('asset-table-filters-cleared', {
detail: { table }
}));
filterDefinitions[0]?.input?.focus();
});
status.append(icon, label, details, clearButton);
const target = filterStatusTarget();
target.parentNode?.insertBefore(status, target);
table._filterStatusElement = status;
return status;
}
function updateFilterPresentation(active, visibleRows, totalRows) {
filterDefinitions.forEach(definition => {
const hasValue = Boolean(definition.input.value.trim());
definition.input.classList.toggle('filter-input-active', hasValue);
definition.input.closest('th')?.classList.toggle('filter-cell-active', hasValue);
});
const status = ensureFilterStatus();
const label = status.querySelector('.table-filter-status-label');
const details = status.querySelector('.table-filter-status-details');
const activeCount = active.length;
status.hidden = activeCount === 0;
status.classList.toggle('is-active', activeCount > 0);
if (activeCount > 0) {
const activeTemplate =
document.body.dataset.filtersActiveTemplate || '{count} Filter aktiv';
const statusTemplate =
document.body.dataset.filterStatusTemplate ||
'Gefiltert: {visible} von {total} Datensätzen';
label.textContent = activeTemplate.replace('{count}', String(activeCount));
details.textContent = statusTemplate
.replace('{visible}', String(visibleRows))
.replace('{total}', String(totalRows));
}
}
function cellForDefinition(row, definition) {
if (definition.field) {
return row.querySelector(`td[data-field="${CSS.escape(definition.field)}"]`);
}
return row.cells[definition.columnIndex] || null;
}
function rebuildMissingCellKeys() {
if (isAssetTable) return;
[...tbody.rows].forEach(row => {
[...row.cells].forEach((cell, index) => {
const header = headerRow.cells[index];
if (header && !cell.dataset.field) {
cell.dataset.field = header.dataset.field;
}
});
});
}
function numericFilterMatches(rawValue, rawTerm) {
const value = Number(String(rawValue).trim().replace(',', '.'));
if (!Number.isFinite(value)) return false;
const term = String(rawTerm || '').trim().toLocaleLowerCase();
if (!term) return true;
const normalize = input => input.replace(',', '.').trim();
let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/);
if (match) {
const expected = Number(normalize(match[2]));
if (match[1] === '<') return value < expected;
if (match[1] === '<=') return value <= expected;
if (match[1] === '>') return value > expected;
if (match[1] === '>=') return value >= expected;
return value === expected;
}
match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
if (!match) {
match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/);
}
if (match) {
const first = Number(normalize(match[1]));
const second = Number(normalize(match[2]));
const minimum = Math.min(first, second);
const maximum = Math.max(first, second);
return value >= minimum && value <= maximum;
}
const exact = Number(normalize(term));
return Number.isFinite(exact) ? value === exact : false;
}
function submitServerFilters(immediate = false) {
if (!usesServerFilters) return;
if (serverFilterTimer) window.clearTimeout(serverFilterTimer);
const navigate = () => {
const target = new URL(
table.dataset.serverFilterUrl || window.location.pathname,
window.location.origin
);
const current = new URL(window.location.href);
const limit = current.searchParams.get('job_limit');
if (limit) target.searchParams.set('job_limit', limit);
filterDefinitions.forEach(definition => {
if (!definition.serverName) return;
const value = definition.input.value.trim();
if (value) target.searchParams.set(`job_filter_${definition.serverName}`, value);
});
try {
window.sessionStorage.setItem(
'assetmanager:software-jobs:last-server-filter',
document.activeElement?.name || ''
);
} catch (_) {}
window.location.assign(target.toString());
};
if (immediate) navigate();
else serverFilterTimer = window.setTimeout(navigate, 700);
}
function applyFilters() {
rebuildMissingCellKeys();
const active = filterDefinitions
.map(definition => ({
definition,
term: definition.input?.value.trim().toLocaleLowerCase() || ''
}))
.filter(item => item.term);
if (usesServerFilters) {
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length;
updateFilterPresentation(active, tbody.rows.length, total);
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
detail: {
table,
visible: tbody.rows.length,
total,
activeFilters: active.length
}
}));
return;
}
let visibleRows = 0;
[...tbody.rows].forEach(row => {
const hidden = active.some(({ definition, term }) => {
const cell = cellForDefinition(row, definition);
const value = textValue(cell);
if (definition.numericFilter) {
return !numericFilterMatches(value, term);
}
return !value.toLocaleLowerCase().includes(term);
});
row.hidden = hidden;
if (!hidden) visibleRows += 1;
});
updateFilterPresentation(active, visibleRows, tbody.rows.length);
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
detail: {
table,
visible: visibleRows,
total: tbody.rows.length,
activeFilters: active.length
}
}));
}
[...headerRow.cells].forEach((header, columnIndex) => {
const field = header.dataset.field || '';
const serverName = header.dataset.serverFilterName || '';
if (!header.dataset.noSort) {
header.classList.add('sortable-column');
header.title = header.dataset.sortTitle || 'Zum Sortieren anklicken';
header.addEventListener('click', event => {
if (event.target.closest('input,button,a,select,textarea,label')) return;
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
[...headerRow.cells].forEach(item => {
delete item.dataset.sortDirection;
item.classList.remove('sort-asc', 'sort-desc');
});
header.dataset.sortDirection = direction;
header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const rows = [...tbody.rows];
rows.sort((left, right) => {
const definition = { field, columnIndex };
const result = compareValues(
textValue(cellForDefinition(left, definition)),
textValue(cellForDefinition(right, definition))
);
return direction === 'asc' ? result : -result;
});
const fragment = document.createDocumentFragment();
rows.forEach(row => fragment.appendChild(row));
tbody.appendChild(fragment);
applyFilters();
});
}
const filterCell = document.createElement('th');
if (field) filterCell.dataset.field = field;
filterCell.dataset.columnIndex = String(columnIndex);
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
const input = document.createElement('input');
input.type = 'search';
input.placeholder = header.dataset.filterPlaceholder || 'Filtern …';
input.setAttribute('aria-label', `Filter ${textValue(header)}`);
input.autocomplete = 'off';
if (usesServerFilters) {
input.name = `job_filter_${serverName}`;
input.value = header.dataset.serverFilterValue || '';
input.addEventListener('input', () => {
applyFilters();
submitServerFilters(false);
});
input.addEventListener('search', () => {
applyFilters();
submitServerFilters(false);
});
input.addEventListener('keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault();
submitServerFilters(true);
});
} else {
input.addEventListener('input', applyFilters);
input.addEventListener('search', applyFilters);
}
filterCell.appendChild(input);
filterDefinitions.push({
field,
columnIndex,
input,
serverName,
numericFilter: header.dataset.numericFilter === '1'
});
}
filterRow.appendChild(filterCell);
});
thead.appendChild(filterRow);
// Editable cells are always read live, so changed values immediately
// participate in filtering without a stale cache.
tbody.addEventListener('input', event => {
if (event.target.matches('input,select,textarea')) applyFilters();
});
tbody.addEventListener('change', event => {
if (event.target.matches('input,select,textarea')) applyFilters();
});
table.assetApplyFilters = applyFilters;
table.assetRebuildFilterCache = rebuildMissingCellKeys;
table.dataset.tableToolsReady = '1';
ensureFilterStatus();
// Covers browser-restored values after reload/back navigation.
requestAnimationFrame(() => {
applyFilters();
if (usesServerFilters) {
try {
const lastName = window.sessionStorage.getItem('assetmanager:software-jobs:last-server-filter');
const lastInput = lastName
? filterDefinitions.find(definition => definition.input.name === lastName)?.input
: null;
if (lastInput) {
lastInput.focus();
lastInput.setSelectionRange(lastInput.value.length, lastInput.value.length);
}
window.sessionStorage.removeItem('assetmanager:software-jobs:last-server-filter');
} catch (_) {}
}
});
window.dispatchEvent(new CustomEvent('asset-table-structure-ready', {
detail: { table }
}));
}
document.querySelectorAll('table.data-table').forEach(initTable);
window.addEventListener('asset-table-content-reloaded', event => {
const table = event.detail?.table;
if (table?.assetRebuildFilterCache) {
table.assetRebuildFilterCache();
table.assetApplyFilters?.();
}
});
})();
+53
View File
@@ -0,0 +1,53 @@
(() => {
const container = document.getElementById('toast-container');
if (!container) return;
const body = document.body;
const allowedPositions = new Set([
'top-left', 'top-center', 'top-right',
'bottom-left', 'bottom-center', 'bottom-right'
]);
const position = allowedPositions.has(body.dataset.toastPosition)
? body.dataset.toastPosition
: 'top-right';
const seconds = Math.max(1, Math.min(120, Number.parseInt(body.dataset.toastDuration || '6', 10) || 6));
container.classList.add(`toast-position-${position}`);
const params = new URLSearchParams(window.location.search);
const entries = [
['toast_error', 'error'],
['toast_warning', 'warning'],
['toast_success', 'success']
];
function showToast(message, level) {
if (!message) return;
const toast = document.createElement('div');
toast.className = `toast toast-${level}`;
const symbol = level === 'success' ? '✓' : level === 'warning' ? '⚠' : '✕';
toast.innerHTML = `<span class="toast-symbol">${symbol}</span><span class="toast-message"></span><button type="button" class="toast-close" aria-label="Schließen">×</button>`;
toast.querySelector('.toast-message').textContent = message;
toast.querySelector('.toast-close').addEventListener('click', () => toast.remove());
container.appendChild(toast);
window.setTimeout(() => {
toast.classList.add('toast-hide');
window.setTimeout(() => toast.remove(), 350);
}, seconds * 1000);
}
let changed = false;
entries.forEach(([key, level]) => {
const values = params.getAll(key);
values.forEach(value => showToast(value, level));
if (values.length) {
params.delete(key);
changed = true;
}
});
if (changed) {
const query = params.toString();
const cleanUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
window.history.replaceState({}, document.title, cleanUrl);
}
})();
View File
+34
View File
@@ -0,0 +1,34 @@
<section class="chart-card">
<div class="chart-card-head">
<div><h3>{{ item.chart.name }}</h3>{% if item.chart.description %}<p>{{ item.chart.description }}</p>{% endif %}</div>
<span class="chart-total">{{ item.total }} Geräte</span>
</div>
{% if not item['items'] %}
<div class="chart-empty">Keine passenden Daten vorhanden.</div>
{% elif item.chart.chart_type in ['donut', 'pie'] %}
<div class="donut-layout">
<div class="donut-chart {% if item.chart.chart_type == 'pie' %}pie-chart{% endif %}" style="background: conic-gradient({{ item.donut_gradient }})"><span>{{ item.total }}</span></div>
<div class="chart-legend">
{% for label, value in item['items'] %}
<div><i style="--legend-color:var(--chart-color-{{ loop.index0 % 12 }})"></i><span>{{ label }}</span><strong>{{ value }}</strong></div>
{% endfor %}
</div>
</div>
{% elif item.chart.chart_type == 'stacked_bar' %}
<div class="stack-legend">{% for stack in item.stack_values %}<span><i style="--legend-color:var(--chart-color-{{ loop.index0 % 12 }})"></i>{{ stack }}</span>{% endfor %}</div>
<div class="bar-chart stacked-chart">
{% for label, values in item['items'] %}
{% set row_total = values.values()|sum %}
<div class="bar-row"><div class="bar-label" title="{{ label }}">{{ label }}</div><div class="bar-track">
{% for stack in item.stack_values %}{% set count = values.get(stack, 0) %}{% if count %}<div class="bar-segment" title="{{ stack }}: {{ count }}" style="width:{{ (count / item.maximum * 100)|round(2) }}%;--segment-color:var(--chart-color-{{ loop.index0 % 12 }})"><span>{{ count }}</span></div>{% endif %}{% endfor %}
</div><strong class="bar-value">{{ row_total }}</strong></div>
{% endfor %}
</div>
{% else %}
<div class="bar-chart">
{% for label, value in item['items'] %}
<div class="bar-row"><div class="bar-label" title="{{ label }}">{{ label }}</div><div class="bar-track"><div class="bar-segment" style="width:{{ (value / item.maximum * 100)|round(2) }}%;--segment-color:var(--chart-color-{{ loop.index0 % 12 }})"><span>{{ value }}</span></div></div><strong class="bar-value">{{ value }}</strong></div>
{% endfor %}
</div>
{% endif %}
</section>
+302
View File
@@ -0,0 +1,302 @@
{% extends "base.html" %}
{% macro inventory_label(label) -%}
{%- set text = label|string -%}
{{- t(text, text) if text.startswith('inventory.') else text -}}
{%- endmacro %}
{% macro inventory_icon(label) -%}
{%- set key = (label|string)|lower -%}
{%- if 'system_overview' in key or 'window' in key or key in ['computer', 'system', 'hardware'] -%}🖥️
{%- elif 'processor' in key or 'cpu' in key -%}🧠
{%- elif 'memory' in key or 'ram' in key -%}🧩
{%- elif 'storage' in key or 'drive' in key or 'disk' in key or 'volume' in key or 'partition' in key -%}💾
{%- elif 'graphic' in key or 'gpu' in key or 'video' in key or 'display' in key -%}🎨
{%- elif 'monitor' in key or 'screen' in key -%}🖥️
{%- elif 'network' in key or 'ethernet' in key or 'wifi' in key or 'adapter' in key or 'wlan' in key -%}🌐
{%- elif 'battery' in key -%}🔋
{%- elif 'bios' in key or 'uefi' in key or 'firmware' in key -%}⚙️
{%- elif 'board' in key or 'motherboard' in key or 'mainboard' in key -%}🔌
{%- elif 'audio' in key or 'sound' in key -%}🔊
{%- elif 'usb' in key -%}🔗
{%- elif 'printer' in key -%}🖨️
{%- elif 'software' in key or 'application' in key -%}📦
{%- elif 'os_' in key or 'operating' in key -%}🪟
{%- elif 'antivirus' in key or 'security' in key or 'tpm' in key -%}🛡️
{%- elif 'mesh' in key or 'agent' in key or 'connection' in key -%}🔗
{%- else -%}📁
{%- endif -%}
{%- endmacro %}
{% macro inventory_node(label, value, level=0, path='root') %}
{% set node_path = path ~ '/' ~ (label|string)|replace(' ', '_')|replace('/', '_') %}
{% if value is mapping %}
<details class="inventory-node" data-inventory-tree-path="{{ node_path|e }}">
<summary><span class="inventory-section-icon" aria-hidden="true">{{ inventory_icon(label) }}</span><span class="inventory-branch">{{ inventory_label(label) }}</span><span class="inventory-count">{{ value|length }}</span></summary>
<div class="inventory-children">{% for key, child in value.items() %}{{ inventory_node(key, child, level + 1, node_path) }}{% endfor %}</div>
</details>
{% elif value is sequence and value is not string %}
<details class="inventory-node" data-inventory-tree-path="{{ node_path|e }}">
<summary><span class="inventory-section-icon" aria-hidden="true">{{ inventory_icon(label) }}</span><span class="inventory-branch">{{ inventory_label(label) }}</span><span class="inventory-count">{{ value|length }}</span></summary>
<div class="inventory-children">
{% for child in value %}
{% if child is mapping %}{% set child_label = child.get('inventory.property.name') or child.get('Name') or child.get('name') or child.get('Caption') or child.get('caption') or child.get('DeviceID') or child.get('Tag') or (t('inventory.entry', 'Entry') ~ ' ' ~ loop.index) %}{% else %}{% set child_label = t('inventory.entry', 'Entry') ~ ' ' ~ loop.index %}{% endif %}
{{ inventory_node(child_label, child, level + 1, node_path) }}
{% endfor %}
</div>
</details>
{% else %}
<div class="inventory-property"><span>{{ inventory_label(label) }}</span><strong>{% if value == 'inventory.value.unknown' %}{{ t('inventory.value.unknown', 'Unknown') }}{% elif value is sameas true %}{{ t('common.yes') }}{% elif value is sameas false %}{{ t('common.no') }}{% else %}{{ value if value is not none and value != '' else '–' }}{% endif %}</strong></div>
{% endif %}
{% endmacro %}
{% block content %}
<div class="toolbar">
<h1>{{ asset.name }}</h1>
<div class="toolbar-actions">
{% if asset.mesh_node_id %}
<span
class="presence-badge presence-{{ asset.mesh_online_state or 'unknown' }}"
data-presence-asset="{{ asset.id }}"
data-label-online="{{ t('presence.online') }}"
data-label-offline="{{ t('presence.offline') }}"
data-label-unknown="{{ t('presence.unknown') }}"
data-last-seen-label="{{ t('presence.last_seen') }}"
title="{{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }} · {{ t('presence.last_seen') }}: {% if asset.mesh_last_seen %}{{ asset.mesh_last_seen }}{% else %}–{% endif %}"
><span class="presence-dot" aria-hidden="true"></span><span data-presence-label>{{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }}</span></span>
{% endif %}
{% if is_admin %}
{% if issue_status_name and asset.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{asset.id}}/return"><button class="button button-return">↩ {{ t('assets.return') }}</button></form>
{% else %}<button type="button" class="button button-issue" data-issue-action="/assets/{{asset.id}}/issue" data-return-to="detail">↗ {{ t('assets.issue') }}</button>{% endif %}
<a class="button button-duplicate" href="/assets/{{asset.id}}/duplicate">⧉ {{ t('assets.duplicate') }}</a>
<form class="inline-form" method="post" action="/assets/{{ asset.id }}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=asset.name)|tojson }});"><button type="submit" class="button button-danger">🗑 {{ t('common.delete') }}</button></form>
<a class="button" href="/assets/{{ asset.id }}/edit">✎ {{ t('common.edit') }}</a>
{% endif %}
<a class="button button-secondary" href="/assets/{{ asset.id }}/history">🕘 {{ t('assets.history') }} ({{ history_count }})</a>
</div>
</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>
<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 %}
</dl>
</div>
{% if is_admin %}
<section class="panel">
<div class="inventory-header">
<div>
<h2>{{ t('mesh.link.section_title', 'MeshCentral link') }}</h2>
<p class="muted">{{ t('mesh.link.section_help', 'The node ID can be changed manually without deleting the asset. Other asset fields are updated only by the next MeshCentral synchronization.') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/assets/{{ asset.id }}/mesh-link">
✎ {% if asset.mesh_node_id %}{{ t('mesh.link.change', 'Change link') }}{% else %}{{ t('mesh.link.initialize', 'Initialize link') }}{% endif %}
</a>
{% if asset.mesh_node_id %}
<form class="inline-form" method="post" action="/assets/{{ asset.id }}/mesh-unlink" onsubmit="return confirm({{ t('mesh.link.unlink_confirm', 'Remove the MeshCentral link from this asset? The asset itself and its stored data remain unchanged.')|tojson }});">
<button type="submit" class="button button-danger">⛓ {{ t('mesh.link.unlink', 'Remove link') }}</button>
</form>
{% endif %}
</div>
</div>
<div class="inventory-meta">
<span><strong>{{ t('field.mesh_node_id', 'MeshCentral node ID') }}:</strong> <code>{{ asset.mesh_node_id or '–' }}</code></span>
{% if asset.mesh_sync_status %}<span><strong>{{ t('assets.status', 'Status') }}:</strong> <span class="sync-status status-{{ asset.mesh_sync_status }}">{{ t('mesh.sync_status.' ~ asset.mesh_sync_status, asset.mesh_sync_status) }}</span></span>{% endif %}
</div>
</section>
{% endif %}
{% if is_admin and asset.mesh_node_id %}
<section class="panel">
<div class="inventory-header">
<div>
<h2>{{ t('jobs.run_definition', 'Job ausführen') }}</h2>
<p class="muted">{{ t('jobs.run_definition_help', 'Es werden nur aktive Jobdefinitionen angezeigt, die zur erkannten Plattform dieses Assets passen.') }}</p>
</div>
{% if job_definitions %}
<form method="post" action="/assets/{{ asset.id }}/jobs/run" class="inline-form" onsubmit="return confirm({{ t('jobs.single.confirm', 'Den ausgewählten Job jetzt auf diesem Asset starten?')|tojson }});">
<select name="definition_id" required aria-label="{{ t('jobs.select_definition', 'Jobdefinition auswählen')|e }}">
<option value="">{{ t('jobs.select_definition', 'Jobdefinition auswählen') }}</option>
{% for definition in job_definitions %}<option value="{{ definition.id }}">{{ definition.name }}</option>{% endfor %}
</select>
<button type="submit" class="button">▶ {{ t('jobs.start', 'Job starten') }}</button>
</form>
{% else %}
<span class="muted">{{ t('jobs.no_compatible_definitions', 'Für diese Plattform ist keine aktive Jobdefinition verfügbar.') }}</span>
{% endif %}
</div>
</section>
{% endif %}
{% if is_admin %}
<section class="panel">
<h2>{{ t('jobparams.asset_title') }}</h2>
<p class="muted">{{ t('jobparams.asset_help') }}</p>
{% if job_definitions %}
<div class="job-definition-asset-list">
{% for definition in job_definitions %}
<details class="job-parameter-asset">
<summary>
<span><strong>{{ definition.name }}</strong>{% if definition.description %}<small>{{ definition.description }}</small>{% endif %}</span>
<span class="muted">{{ t('jobparams.parameter_count', count=definition.parameters|length) }}</span>
</summary>
{% if definition.parameters %}
<form method="post" action="/assets/{{ asset.id }}/job-parameters/{{ definition.id }}" class="form-grid">
{% for parameter in definition.parameters %}
{% set override = job_parameter_overrides.get(parameter.id) %}
<div class="form-wide job-asset-param">
<label class="checkbox-label"><input type="checkbox" name="override_{{ parameter.id }}" {% if override is not none %}checked{% endif %}> {{ t('jobparams.override') }}</label>
<label>{{ parameter.label }} <code>{{ parameter.parameter_key }}</code>
<input name="value_{{ parameter.id }}" value="{{ override if override is not none else parameter.default_value or '' }}">
{% if parameter.description %}<small>{{ parameter.description }}</small>{% endif %}
<small>{{ t('jobparams.default_value') }}: {{ parameter.default_value or '–' }} · {{ t('jobparams.source') }}: {{ t('jobparams.source.asset') if override is not none else t('jobparams.source.default') }}</small>
</label>
</div>
{% endfor %}
<div class="form-actions form-wide"><button class="button button-save" type="submit">{{ t('common.save') }}</button></div>
</form>
{% else %}
<p class="muted job-parameter-empty">{{ t('jobparams.none_defined') }}</p>
{% endif %}
</details>
{% endfor %}
</div>
{% else %}
<p class="muted">{{ t('jobs.no_compatible_definitions') }}</p>
{% endif %}
</section>
{% endif %}
{% if asset.mesh_node_id %}
<section class="panel inventory-panel">
<div class="inventory-header">
<div>
<h2>{{ t('inventory.title') }}</h2>
<p class="muted">{{ t('inventory.subtitle') }}</p>
</div>
<a class="button button-secondary" href="/assets/{{ asset.id }}/mesh-inventory.json">{{ t('inventory.download_json') }}</a>
</div>
<div class="inventory-meta">
<span><strong>{{ t('mesh.node_id') }}:</strong> <code>{{ asset.mesh_node_id }}</code></span>
<span><strong>{{ t('inventory.snapshot') }}:</strong> {% if asset.mesh_inventory_updated_at or asset.mesh_last_sync %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_inventory_updated_at or asset.mesh_last_sync) }}" data-utc="{{ utc_iso(asset.mesh_inventory_updated_at or asset.mesh_last_sync) }}">{{ asset.mesh_inventory_updated_at or asset.mesh_last_sync }}</time>{% else %}{{ '–' }}{% endif %}</span>
<span><strong>{{ t('assets.status') }}:</strong> <span class="sync-status status-{{ asset.mesh_sync_status }}">{% if asset.mesh_sync_status %}{{ t('mesh.sync_status.' ~ asset.mesh_sync_status, asset.mesh_sync_status) }}{% else %}{{ t('common.unknown') }}{% endif %}</span></span>
<span data-presence-meta="{{ asset.id }}">
<strong>{{ t('presence.title') }}:</strong>
<span data-presence-meta-state>{{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }}</span>
· {{ t('presence.updated') }}:
<span data-presence-updated>{% if asset.mesh_presence_updated_at %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_presence_updated_at) }}" data-utc="{{ utc_iso(asset.mesh_presence_updated_at) }}">{{ asset.mesh_presence_updated_at }}</time>{% else %}{{ '–' }}{% endif %}</span>
· {{ t('presence.last_seen') }}:
<span data-presence-last-seen>{% if asset.mesh_last_seen %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_last_seen) }}" data-utc="{{ utc_iso(asset.mesh_last_seen) }}">{{ asset.mesh_last_seen }}</time>{% else %}{{ '–' }}{% endif %}</span>
· {{ t('presence.last_online') }}:
<span data-presence-last-online>{% if asset.mesh_last_online_at %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_last_online_at) }}" data-utc="{{ utc_iso(asset.mesh_last_online_at) }}">{{ asset.mesh_last_online_at }}</time>{% else %}{{ '–' }}{% endif %}</span>
</span>
</div>
<div class="inventory-tabs" role="tablist">
<button type="button" class="inventory-tab active" data-inventory-tab="hardware">{{ t('inventory.tab.hardware') }}</button>
<button type="button" class="inventory-tab" data-inventory-tab="software">{{ t('inventory.tab.software') }}{% if installed_software %} ({{ installed_software|length }}){% endif %}</button>
<button type="button" class="inventory-tab" data-inventory-tab="raw">{{ t('inventory.tab.raw_json') }}</button>
<button type="button" class="inventory-tab" data-inventory-tab="sync">{{ t('inventory.tab.sync') }}</button>
</div>
<div class="inventory-tab-content active" data-inventory-panel="hardware">
{% if hardware_inventory %}
<div class="inventory-tree">{% for key, value in hardware_inventory.items() %}{{ inventory_node(key, value) }}{% endfor %}</div>
{% else %}<div class="empty-state">{{ t('inventory.no_hardware') }}</div>{% endif %}
</div>
<div class="inventory-tab-content" data-inventory-panel="software">
<div class="asset-software-toolbar">
<div>
<h3>{{ t('software.installed') }}</h3>
{% if installed_software %}
<p class="muted">{{ t('software.inventory_count', '{count} software entries stored.', count=installed_software|length) }}</p>
{% else %}
<p class="muted">{{ t('software.inventory_not_initialized') }}</p>
{% endif %}
</div>
{% if is_admin %}
<form method="post" action="/assets/{{ asset.id }}/software-inventory" class="inline-form">
<input type="hidden" name="return_to" value="asset-detail">
<button type="submit" class="button">
{% if installed_software %}{{ t('software.update_inventory') }}{% else %}{{ t('software.initialize_inventory') }}{% endif %}
</button>
</form>
{% endif %}
</div>
{% if latest_software_job %}
<div class="software-inventory-meta">
<span><strong>{{ t('software.last_job') }}:</strong> <a href="/software/jobs/{{ latest_software_job.id }}">#{{ latest_software_job.id }}</a></span>
<span><strong>{{ t('software.status') }}:</strong> <span class="job-status job-status-{{ latest_software_job.status }}">{{ t('software.status.' ~ latest_software_job.status, latest_software_job.status) }}</span></span>
<span><strong>{{ t('software.last_inventory') }}:</strong> {% if latest_software_job.finished_at or latest_software_job.created_at %}<time class="local-time" datetime="{{ utc_iso(latest_software_job.finished_at or latest_software_job.created_at) }}" data-utc="{{ utc_iso(latest_software_job.finished_at or latest_software_job.created_at) }}">{{ latest_software_job.finished_at or latest_software_job.created_at }}</time>{% else %}{{ '–' }}{% endif %}</span>
</div>
{% endif %}
{% if installed_software %}
<div data-software-selection>
{% if is_admin %}<form method="post" action="/software/inventory/delete" data-software-delete-form data-confirm-template="{{ t('software.delete.confirm')|e }}" data-inventory-only-warning="{{ t('software.delete.inventory_only')|e }}" class="software-delete-toolbar">
<input type="hidden" name="redirect_to" value="/assets/{{ asset.id }}?inventory_tab=software">
<span>{{ t('software.selected_count') }}: <strong data-software-selection-count>0</strong></span>
<button type="submit" class="button button-danger" data-software-delete-button disabled>🗑 {{ t('software.delete.selected') }}</button>
</form>{% endif %}
<div class="table-scroll management-table-scroll">
<table class="data-table" id="asset-installed-software-table" data-storage-key="asset-installed-software-{{ asset.id }}">
<thead><tr>
{% if is_admin %}<th data-no-sort="1"><input type="checkbox" data-software-select-all aria-label="{{ t('software.select_all_visible')|e }}"></th>{% endif %}
<th>{{ t('common.name') }}</th>
<th>{{ t('about.version') }}</th>
<th>{{ t('software.publisher') }}</th>
<th>{{ t('software.architecture') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('software.install_date') }}</th>
<th>{{ t('software.last_seen') }}</th>
</tr></thead>
<tbody>
{% for entry in installed_software %}
<tr>
{% if is_admin %}<td><input type="checkbox" value="{{ entry.id }}" data-software-entry-checkbox aria-label="{{ t('software.select_entry', name=entry.name)|e }}"></td>{% endif %}
<td>{{ entry.name }}</td>
<td>{{ entry.version or '' }}</td>
<td>{{ entry.publisher or '' }}</td>
<td>{{ entry.architecture or '' }}</td>
<td>{{ t('software.platform.' ~ (entry.platform or 'unknown'), entry.platform or 'unknown') }}</td>
<td data-sort-value="{{ entry.install_date or '' }}">{{ entry.install_date or '' }}</td>
<td data-sort-value="{{ entry.last_seen_at.isoformat() if entry.last_seen_at else '' }}">{% if entry.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(entry.last_seen_at) }}" data-utc="{{ utc_iso(entry.last_seen_at) }}">{{ entry.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state software-empty-inventory">
<strong>{{ t('software.inventory_not_initialized') }}</strong>
<span>{{ t('software.inventory_initialize_help') }}</span>
</div>
{% endif %}
</div>
<div class="inventory-tab-content" data-inventory-panel="raw">
{% if raw_inventory %}<pre class="json-viewer">{{ raw_inventory|tojson(indent=2) }}</pre>{% else %}<div class="empty-state">{{ t('inventory.no_raw_data') }}</div>{% endif %}
</div>
<div class="inventory-tab-content" data-inventory-panel="sync">
<dl class="config-list"><dt>{{ t('mesh.last_sync') }}</dt><dd>{{ asset.mesh_last_sync or '–' }}</dd><dt>{{ t('mesh.message') }}</dt><dd>{{ asset.mesh_sync_message or '–' }}</dd></dl>
{% if asset.mesh_conflicts %}<h3>{{ t('mesh.field_conflicts') }}</h3><table class="data-table"><thead><tr><th>{{ t('common.field') }}</th><th>{{ t('mesh.local_value') }}</th><th>MeshCentral</th><th>{{ t('mesh.rule') }}</th></tr></thead><tbody>{% for field, conflict in asset.mesh_conflicts.items() %}<tr><td>{{ field }}</td><td>{{ conflict.local }}</td><td>{{ conflict.meshcentral }}</td><td>{{ conflict.rule }}</td></tr>{% endfor %}</tbody></table>{% endif %}
</div>
</section>
{% endif %}
<script>
(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('inventory_tab') !== 'software') return;
const button = document.querySelector('[data-inventory-tab="software"]');
button?.click();
})();
</script>
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ today }}" required></label><div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit" class="button button-issue">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}
<script src="/static/js/issue-dialog.js"></script>
<script src="/static/js/asset-inventory.js"></script>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends 'base.html' %}{% block content %}
<h1>{% if duplicate_mode %}{{t('assets.duplicate')}}{% elif asset %}{{t('common.edit')}} {{t('app.assets')}}{% else %}{{t('assets.create')}}{% endif %}</h1>
{% if duplicate_mode %}<p class="notice">The original asset data was copied. Asset tag and MeshCentral node ID were cleared.</p>{% endif %}
<form method="post" action="{% if duplicate_mode or not asset %}/assets/new{% else %}/assets/{{asset.id}}/edit{% endif %}" enctype="multipart/form-data" data-category-form>
<label>{{t('assets.category')}}<select name="category_id" onchange="location.href='{% if duplicate_mode %}/assets/new{% elif asset %}/assets/{{asset.id}}/edit{% else %}/assets/new{% endif %}?category_id='+this.value">{% for c in categories %}<option value="{{c.id}}" {% if selected and c.id==selected.id %}selected{% endif %}>{{c.name}}</option>{% endfor %}</select></label>
{% if selected %}{% for f in selected.visible_fields if f.active %}{% set d=f.definition %}{% set custom=d and not d.is_system %}{% set value=custom_values.get(f.field_name) if custom else (asset|attr(f.field_name) if asset else '') %}
<label>{{t(d.translation_key, d.label) if d else f.label}}{% if f.required %} *{% endif %}{% if d and d.description %}<small class="muted">{{d.description}}</small>{% endif %}
{% if f.field_name == 'parent_asset_id' %}<select name="parent_asset_id" {% if f.required %}required{% endif %}><option value="">–</option>{% for candidate in parent_candidates %}<option value="{{candidate.id}}" {% if asset and asset.parent_asset_id==candidate.id %}selected{% endif %}>{{candidate.name}}{% if candidate.asset_tag %} ({{candidate.asset_tag}}){% endif %}</option>{% endfor %}</select>
{% elif f.field_name == 'mesh_node_id' and asset and not duplicate_mode %}
<div class="mesh-node-id-editor">
<input id="mesh-node-id-input" name="mesh_node_id" value="{{value or ''}}" autocomplete="off" spellcheck="false" class="mesh-node-id-input">
<div class="mesh-node-id-actions">
<a class="button button-secondary" href="/assets/{{asset.id}}/mesh-link">{{ t('mesh.link.select_existing', 'MeshCentral-Gerät auswählen') }}</a>
<button type="button" class="button button-secondary" id="mesh-node-id-clear">{{ t('mesh.link.clear_field', 'Node-ID leeren') }}</button>
</div>
<small class="muted">{{ t('mesh.link.edit_help', 'Die Node-ID kann direkt bearbeitet werden. Ein leeres Feld löst die Verknüpfung beim Speichern. Andere Assetdaten werden erst beim nächsten MeshCentral-Sync aktualisiert.') }}</small>
</div>
{% elif (d and d.readonly) or field_readonly(f) %}<input value="{{value or ''}}" readonly aria-readonly="true">
{% elif d and d.data_type == 'long_text' %}<textarea name="{{f.field_name}}" {% if f.required %}required{% endif %}>{{value or ''}}</textarea>
{% elif d and d.data_type == 'boolean' %}<select name="{{f.field_name}}"><option value="">–</option><option value="true" {% if value in [true,'true','1'] %}selected{% endif %}>{{t('common.yes')}}</option><option value="false" {% if value in [false,'false','0'] %}selected{% endif %}>{{t('common.no')}}</option></select>
{% elif f.field_name == 'status' or (d and d.data_type == 'status') %}<select name="{{f.field_name}}" {% if f.required %}required{% endif %}><option value="">–</option>{% for option in status_options %}<option value="{{option.name}}" {% if value==option.name %}selected{% endif %}>{{option.name}}</option>{% endfor %}</select>
{% elif d and d.data_type in ['date','datetime'] %}<input type="{{'datetime-local' if d.data_type=='datetime' else 'date'}}" name="{{f.field_name}}" value="{{html_field_value(value, d.data_type)}}" {% if f.required %}required{% endif %}>
{% 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>
{% 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;
clearButton.addEventListener('click', () => {
input.value = '';
input.focus();
input.dispatchEvent(new Event('input', { bubbles: true }));
});
})();
</script>
{% endif %}
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<h1>Historie: {{ asset.name }}</h1>
<div class="toolbar-actions">
<a class="button button-secondary" href="/assets/{{ asset.id }}/history/export.xlsx">Excel exportieren</a>
<a class="button" href="/assets/{{ asset.id }}">Zurück zum Asset</a>
</div>
</div>
<p class="table-hint">Jede geänderte Feldbezeichnung wird mit altem und neuem Wert gespeichert.</p>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>Zeitpunkt</th><th>Quelle</th><th>Benutzer</th><th>Feld</th><th>Alter Wert</th><th>Neuer Wert</th></tr></thead>
<tbody>
{% for entry in entries %}
{% for label, change in entry.changes.items() %}
<tr><td>{{ entry.changed_at }}</td><td>{{ entry.source }}</td><td>{{ entry.changed_by or '' }}</td><td>{{ label }}</td><td class="multiline">{{ change.old }}</td><td class="multiline">{{ change.new }}</td></tr>
{% endfor %}
{% else %}<tr><td colspan="6" class="empty-state">Noch keine Änderungen protokolliert.</td></tr>{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('mesh.link.title', 'Change MeshCentral link') }}</h1>
<p class="muted">{{ t('mesh.link.page_help', 'Select an unassigned MeshCentral device. The node ID is changed immediately; other asset fields are updated only during the next synchronization.') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/assets/{{ asset.id }}">← {{ t('common.back', 'Back') }}</a>
</div>
</div>
<section class="panel">
<h2>{{ t('mesh.link.asset_comparison', 'Asset and current link') }}</h2>
<div class="inventory-meta">
<span><strong>{{ t('common.name', 'Name') }}:</strong> {{ asset.name }}</span>
<span><strong>{{ t('field.manufacturer', 'Manufacturer') }}:</strong> {{ asset.manufacturer or '–' }}</span>
<span><strong>{{ t('field.serial_number', 'Serial number') }}:</strong> {{ asset.serial_number or '–' }}</span>
<span><strong>{{ t('field.mesh_node_id', 'MeshCentral node ID') }}:</strong> <code>{{ asset.mesh_node_id or '–' }}</code></span>
</div>
{% if current_device %}
<p class="muted">
{{ t('mesh.link.current_device', 'Current MeshCentral device') }}:
<strong>{{ current_device.name }}</strong>
{% if current_device.manufacturer %} · {{ current_device.manufacturer }}{% endif %}
{% if current_device.serial_number %} · {{ current_device.serial_number }}{% endif %}
</p>
{% elif asset.mesh_node_id %}
<p class="notice warning">{{ t('mesh.link.current_missing', 'The currently stored node ID was not found in MeshCentral. You can replace it with one of the available devices below.') }}</p>
{% endif %}
</section>
<section class="panel">
<h2>{{ t('mesh.link.available_devices', 'Available MeshCentral devices') }}</h2>
<p class="muted">{{ t('mesh.link.available_help', 'Only node IDs that are not assigned to another asset are shown.') }}</p>
{% if available_devices %}
<form method="post" action="/assets/{{ asset.id }}/mesh-link" onsubmit="return confirm({{ t('mesh.link.change_confirm', 'Assign the selected MeshCentral device to this asset?')|tojson }});">
<div class="table-scroll management-table-scroll">
<table class="data-table" data-storage-key="mesh-link-devices">
<thead>
<tr>
<th data-no-sort="1">{{ t('common.select', 'Select') }}</th>
<th>{{ t('common.name', 'Name') }}</th>
<th>{{ t('field.manufacturer', 'Manufacturer') }}</th>
<th>{{ t('field.serial_number', 'Serial number') }}</th>
<th>{{ t('field.mesh_group', 'MeshCentral group') }}</th>
<th>{{ t('field.operating_system', 'Operating system') }}</th>
<th>{{ t('field.mesh_node_id', 'MeshCentral node ID') }}</th>
</tr>
</thead>
<tbody>
{% for device in available_devices %}
<tr>
<td><input type="radio" name="node_id" value="{{ device.node_id|e }}" required aria-label="{{ t('mesh.link.select_device', 'Select device')|e }} {{ device.name|e }}"></td>
<td><strong>{{ device.name or '–' }}</strong></td>
<td>{{ device.manufacturer or '–' }}</td>
<td>{{ device.serial_number or '–' }}</td>
<td>{{ device.group or '–' }}</td>
<td>{{ device.operating_system or '–' }}</td>
<td><code>{{ device.node_id }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<button type="submit" class="button button-save">{{ t('mesh.link.save', 'Save link') }}</button>
<a class="button button-secondary" href="/assets/{{ asset.id }}">{{ t('common.cancel', 'Cancel') }}</a>
</div>
</form>
{% else %}
<div class="empty-state">{{ t('mesh.link.no_available_devices', 'No unassigned MeshCentral devices are currently available.') }}</div>
{% endif %}
</section>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('software.asset_title', 'Software: {name}').replace('{name}', asset.name) }}</h1><div class="toolbar-actions">{% if is_admin and asset.mesh_node_id %}<form method="post" action="/assets/{{ asset.id }}/software-inventory" class="inline-form"><button type="submit">{% if software %}{{ t('software.update_inventory') }}{% else %}{{ t('software.initialize_inventory') }}{% endif %}</button></form>{% endif %}<a class="button button-secondary" href="/assets/{{ asset.id }}">{{ t('software.back_to_asset') }}</a></div></div>
<section class="panel">
<div class="software-inventory-heading">
<div>
<h2>{{ t('software.installed') }}</h2>
<p class="muted">{% if software %}{{ t('software.inventory_count', '{count} software entries stored.', count=software|length) }}{% else %}{{ t('software.inventory_not_initialized') }}{% endif %}</p>
</div>
<span class="software-inventory-count">{{ software|length }}</span>
</div><div class="table-scroll"><table class="data-table" data-storage-key="asset-installed-software-{{ asset.id }}"><thead><tr><th>{{ t('common.name') }}</th><th>{{ t('about.version') }}</th><th>{{ t('software.publisher') }}</th><th>{{ t('software.architecture') }}</th><th>{{ t('software.install_date') }}</th><th>{{ t('software.last_seen') }}</th></tr></thead><tbody>{% for s in software %}<tr><td>{{ s.name }}</td><td>{{ s.version }}</td><td>{{ s.publisher }}</td><td>{{ s.architecture }}</td><td>{{ s.install_date or '' }}</td><td>{% if s.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(s.last_seen_at) }}" data-utc="{{ utc_iso(s.last_seen_at) }}">{{ s.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td></tr>{% else %}<tr><td colspan="6"><div class="software-empty-inventory"><strong>{{ t('software.inventory_not_initialized') }}</strong><span>{{ t('software.inventory_initialize_help') }}</span></div></td></tr>{% endfor %}</tbody></table></div></section>
<section class="panel"><h2>{{ t('software.jobs') }}</h2><div class="table-scroll"><table class="data-table" data-storage-key="asset-software-jobs-{{ asset.id }}"><thead><tr><th>{{ t('software.time') }}</th><th>{{ t('jobs.type') }}</th><th>{{ t('jobs.action') }}</th><th>{{ t('jobs.priority') }}</th><th>{{ t('software.status') }}</th><th>{{ t('software.message') }}</th><th>{{ t('software.log') }}</th><th>{{ t('jobs.actions') }}</th></tr></thead><tbody>{% for j in jobs %}<tr><td>{% if j.created_at %}<time class="local-time" datetime="{{ utc_iso(j.created_at) }}" data-utc="{{ utc_iso(j.created_at) }}">{{ j.created_at }}</time>{% else %}{{ '' }}{% endif %}</td><td>{{ t(j.display_job_type_key, j.display_job_type_fallback) }}</td><td>{{ t('jobs.action.' ~ j.action, j.action) }}</td><td>{{ j.priority }}</td><td><span class="job-status job-status-{{ j.status }}">{{ t('software.status.' ~ j.status, j.status) }}</span></td><td>{{ j.message or '' }}</td><td><a href="/jobs/{{ j.id }}">{{ t('software.show') }}</a></td><td>{% if is_admin and j.status not in ['sending', 'running'] %}<form method="post" action="/jobs/{{ j.id }}/restart" class="inline-form" onsubmit="return confirm({{ t('jobs.restart.confirm')|tojson }});"><button type="submit" class="button button-secondary button-small">↻ {{ t('jobs.restart') }}</button></form>{% else %}<span class="muted">—</span>{% endif %}</td></tr>{% else %}<tr><td colspan="8">{{ t('software.no_jobs') }}</td></tr>{% endfor %}</tbody></table></div></section>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% macro category_icon(category_name) %}
{% set category = (category_name or '')|lower %}
<span class="tree-category-icon" title="{{ category_name }}" aria-label="{{ category_name }}">
{% if 'server' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="3" width="16" height="5" rx="1"/><rect x="4" y="10" width="16" height="5" rx="1"/><rect x="4" y="17" width="16" height="4" rx="1"/><circle cx="7" cy="5.5" r=".8"/><circle cx="7" cy="12.5" r=".8"/><circle cx="7" cy="19" r=".8"/></svg>
{% elif 'netz' in category or 'switch' in category or 'router' in category or 'firewall' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 11h2m2 0h2m2 0h2M6 14h12"/></svg>
{% elif 'druck' in category or 'printer' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 8V3h10v5M6 17H4V9h16v8h-2M7 14h10v7H7z"/><circle cx="17" cy="11" r=".8"/></svg>
{% elif 'monitor' in category or 'display' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M8 21h8M12 16v5"/></svg>
{% elif 'mobil' in category or 'telefon' in category or 'smartphone' in category or 'tablet' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M10 5h4M11 19h2"/></svg>
{% elif 'computer' in category or 'pc' in category or 'notebook' in category or 'laptop' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M7 21h10M12 16v5"/></svg>
{% else %}
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l8 4v12l-8 4-8-4V6zM4 6l8 4 8-4M12 10v12"/></svg>
{% endif %}
</span>
{% endmacro %}
{% macro render_node(node, by_parent) %}
<li class="tree-item">
<article class="tree-card">
{{ category_icon(node.category.name) }}
<div class="tree-card-body">
<a class="tree-name" href="/assets/{{ node.id }}" title="{{ node.name }}">{{ node.name }}</a>
<div class="tree-meta">{{ node.ip_address or t('assets.no_ip') }}</div>
<div class="tree-meta">{{ node.location or t('assets.no_location') }}{% if node.room_number %} · {{ t('assets.room') }} {{ node.room_number }}{% endif %}</div>
<div class="tree-category">{{ node.category.name }}</div>
</div>
</article>
{% set children = by_parent.get(node.id, []) %}
{% if children %}<ul>{% for child in children %}{{ render_node(child, by_parent) }}{% endfor %}</ul>{% endif %}
</li>
{% endmacro %}
{% block content %}
<div class="toolbar"><h1>{{ t('app.asset_tree') }}</h1></div>
<form class="tree-filters panel" method="get" action="/asset-tree">
<div class="tree-filter-controls">
<label class="tree-filter-field">{{ t('tree.root_node') }}
<select name="root_id">
<option value="">{{ t('tree.all_root_assets') }}</option>
{% for asset in all_roots %}<option value="{{ asset.id }}" {% if selected_root_id == asset.id %}selected{% endif %}>{{ asset.name }}</option>{% endfor %}
</select>
</label>
<label class="tree-filter-field">{{ t('tree.name_contains') }}
<input name="name_filter" value="{{ name_filter }}" placeholder="z. B. Dell">
</label>
<label class="tree-toggle-option">
<input type="checkbox" name="hide_without_parent" value="1" {% if hide_without_parent %}checked{% endif %}>
<span class="tree-toggle-track" aria-hidden="true"><span class="tree-toggle-thumb"></span></span>
<span class="tree-toggle-text">{{ t('tree.hide_standalone') }}</span>
</label>
<div class="tree-filter-actions">
<button class="button" type="submit">{{ t('common.filter') }}</button>
<a class="button button-secondary" href="/asset-tree">{{ t('common.reset') }}</a>
</div>
</div>
<fieldset class="tree-category-filter">
<legend>{{ t('app.categories') }}</legend>
<div class="tree-category-options">
{% for category in categories %}
<label class="tree-category-option">
<input type="checkbox" name="category_id" value="{{ category.id }}" {% if category.id in selected_category_ids %}checked{% endif %}>
<span class="tree-category-check" aria-hidden="true"></span>
<span class="tree-category-name">{{ category.name }}</span>
</label>
{% endfor %}
</div>
<small>{{ t('tree.no_category_selection') }}</small>
</fieldset>
</form>
{% if roots %}<div class="tree-scroll"><ul class="asset-tree">{% for root in roots %}{{ render_node(root, by_parent) }}{% endfor %}</ul></div>{% else %}<p class="notice">{{ t('tree.no_matches') }}</p>{% endif %}
{% endblock %}
+512
View File
@@ -0,0 +1,512 @@
{% extends 'base.html' %}
{% block content %}
<div class="asset-page-loader" id="asset-page-loader" role="status" aria-live="polite">
<div class="asset-page-loader-card">
<span class="asset-page-spinner" aria-hidden="true"></span>
<strong>{{ t('assets.loading', 'Assets werden geladen …') }}</strong>
<span class="muted">{{ t('assets.loading_hint', 'Tabellenansicht und gespeicherte Einstellungen werden vorbereitet.') }}</span>
</div>
</div>
<div class="asset-list-page asset-list-initializing" id="asset-list-page" aria-busy="true">
<div class="toolbar">
<h1>{{ t('app.assets', 'Assets') }}{% if selected %}: {{ selected.name }}{% endif %}</h1>
<div class="toolbar-actions">
{% if is_admin and assets %}
<button type="button" class="button button-secondary" id="bulk-edit-button" data-base-label="✏ {{ t('assets.bulk_edit', 'Daten ändern')|e }}" disabled>✏ {{ t('assets.bulk_edit', 'Daten ändern') }}</button>
<button type="button" class="button button-danger" id="bulk-delete-button" data-base-label="🗑 {{ t('assets.delete_selected', 'Markierte löschen')|e }}" disabled>🗑 {{ t('assets.delete_selected', 'Markierte löschen') }}</button>
<button type="button" class="button button-secondary" id="merge-duplicates-button" disabled>⧉ {{ t('duplicates.merge', 'Duplikate zusammenführen') }}</button>
<div class="bulk-job-controls" aria-label="{{ t('jobs.bulk.controls_label')|e }}">
<label class="visually-hidden" for="bulk-job-type">{{ t('jobs.type') }}</label>
<select id="bulk-job-type" class="bulk-job-type" disabled>
<option value="">{{ t('jobs.select_type') }}</option>
<option value="software_inventory" data-platform="all">{{ t('jobs.type.software_inventory') }}</option>
{% for definition in job_definitions %}<option value="definition:{{ definition.id }}" data-platform="{{ definition.platform }}">{{ definition.name }}</option>{% endfor %}
</select>
<button type="button" class="button button-secondary" id="bulk-job-start-button" disabled>▶ {{ t('jobs.start') }}</button>
</div>
{% endif %}
<button type="button" class="button button-secondary" id="duplicate-search-button">⧉ {{ t('duplicates.search', 'Duplikate suchen') }}</button>
{% if is_admin %}<button type="button" class="button{% if not job_filter %} button-secondary{% endif %}" id="job-filter-button">⌁ {{ t('assets.job_filter.button', 'Jobstatus filtern') }}{% if job_filter %} ({{ total_assets }}){% endif %}</button>{% endif %}
<button type="button" class="button button-secondary" id="column-settings-button">▦ {{ t('assets.columns', 'Spalten') }}</button>
<button type="button" class="button button-secondary" id="reload-assets-button" data-loading-label="⏳ {{ t('common.loading', 'Lade...')|e }}">🔄 {{ t('assets.reload', 'Inhalt neu laden') }}</button>
<a class="button button-secondary" href="/settings/import-export">⇅ {{ t('settings.import_export', 'Import / Export') }}</a>
{% if is_admin %}
<a class="button" href="/assets/new{% if selected %}?category_id={{ selected.id }}{% endif %}">➕ {{ t('assets.create', 'Asset anlegen') }}</a>{% endif %}
</div>
</div>
<div class="filters"><a href="/assets">{{ t('common.all', 'Alle') }}</a>{% for c in categories %}<a href="/assets?category_id={{ c.id }}">{{ c.name }}</a>{% endfor %}</div>
{% if job_filter %}
<div class="notice job-filter-summary">
<strong>{{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}:</strong>
{% for spec in job_filter.specs %}{% if spec.label_key %}{{ t(spec.label_key, spec.label) }}{% else %}{{ spec.label }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
· {{ t('assets.job_filter.excluded_summary', 'Ausgeschlossen') }}:
{% if job_filter.excluded_statuses %}
{% for status in job_filter.excluded_statuses %}{{ t('software.status.' ~ status, status) }}{% if not loop.last %}, {% endif %}{% endfor %}
{% else %}
{{ t('assets.job_filter.excluded_none', 'keine Status') }}
{% endif %}
{% if job_filter.revision_mode == 'current' %}· {{ t('assets.job_filter.current_revision', 'nur aktuelle Revision') }}{% endif %}
<span class="muted">· {{ t('assets.total', '{count} Assets').format(count=total_assets) }}</span>
<a class="button button-secondary button-small" href="/assets{% if selected %}?category_id={{ selected.id }}{% endif %}">{{ t('assets.job_filter.clear', 'Jobfilter entfernen') }}</a>
{% if is_admin and assets and job_filter.startable_specs %}
{% if job_filter.startable_specs|length > 1 %}
<label class="job-filter-start-choice">{{ t('assets.job_filter.start_job', 'Auszuführender Job') }}
<select id="start-filtered-job-selection">
{% for spec in job_filter.startable_specs %}<option value="{{ spec.selection }}">{% if spec.label_key %}{{ t(spec.label_key, spec.label) }}{% else %}{{ spec.label }}{% endif %}</option>{% endfor %}
</select>
</label>
{% endif %}
<button type="button" class="button button-small" id="start-filtered-job-button">▶ {{ t('assets.job_filter.start', 'Job für gefilterte Assets starten') }} ({{ total_assets }})</button>
{% endif %}
</div>
{% endif %}
{% if selected %}<p class="table-hint">{{ t('assets.column_order_hint') }}</p>{% endif %}
{% if is_admin %}
<form id="bulk-edit-form" class="hidden-utility-form" method="post" action="/assets/bulk-edit">{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}<span id="bulk-edit-ids"></span></form>
<form id="bulk-delete-form" class="hidden-utility-form" method="post" action="/assets/bulk-delete" onsubmit="return confirm({{ t('assets.bulk_delete_confirm')|tojson }});">{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}<span id="bulk-delete-ids"></span></form>
<form id="merge-duplicates-form" class="hidden-utility-form" method="post" action="/assets/merge"><span id="merge-duplicate-ids"></span></form>
<form id="bulk-job-form" class="hidden-utility-form" method="post" action="/jobs/bulk-create">
<input type="hidden" name="job_type" id="bulk-job-type-value">
<input type="hidden" name="definition_id" id="bulk-job-definition-value">
<span id="bulk-job-ids"></span>
</form>
{% if job_filter %}
<form id="start-filtered-job-form" class="hidden-utility-form" method="post" action="/assets/job-filter/start">
{% for selection in job_filter.selections %}<input type="hidden" name="job_filter" value="{{ selection }}">{% endfor %}
<input type="hidden" name="job_filter_start" id="start-filtered-job-value" value="{{ job_filter.startable_specs[0].selection if job_filter.startable_specs else job_filter.selections[0] }}">
<input type="hidden" name="job_filter_revision" value="{{ job_filter.revision_mode }}">
{% for status in job_filter.excluded_statuses %}<input type="hidden" name="job_filter_exclude" value="{{ status }}">{% endfor %}
{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}
</form>
{% endif %}
{% endif %}
<div class="table-scrollbar-top" id="asset-table-scroll-top" aria-hidden="true"><div></div></div>
<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>
{% 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 %}
{% if not selected %}<th draggable="true" data-field="category" data-column-width="180" data-label="{{ t('common.category', 'Kategorie')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('common.category', 'Kategorie') }}</th>{% endif %}
{% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}<th draggable="true" data-field="{{ f.field_name }}" data-column-width="{{ list_width }}" data-label="{{ f.label|e }}" style="width:{{ list_width }}px;min-width:{{ list_width }}px;max-width:{{ list_width }}px">{{ f.label }}</th>{% endfor %}
{% if job_filter %}
<th data-field="job_filter_status" data-label="{{ t('assets.job_filter.status', 'Jobstatus')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('assets.job_filter.status', 'Jobstatus') }}</th>
<th data-field="job_filter_last_execution" data-label="{{ t('assets.job_filter.last_execution', 'Letzte Ausführung')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}</th>
<th data-field="job_filter_last_success" data-label="{{ t('assets.job_filter.last_success', 'Letzter Erfolg')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}</th>
<th data-field="job_filter_revision" data-label="{{ t('assets.job_filter.revision', 'Revision')|e }}" style="width:100px;min-width:100px;max-width:100px">{{ t('assets.job_filter.revision', 'Revision') }}</th>
{% endif %}
<th data-field="mesh_presence" data-label="{{ t('presence.title')|e }}" style="width:125px;min-width:125px;max-width:125px">{{ t('presence.title') }}</th>
<th data-no-sort="1" data-no-filter="1" class="actions-column">{{ t('common.actions', 'Aktionen') }}</th>
</tr></thead>
<tbody>
{% for a in assets %}<tr data-platform="{{ asset_platforms.get(a.id, 'unknown') }}">
{% 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 %}
{% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}<td data-field="{{ f.field_name }}" class="multiline" style="width:{{ list_width }}px;min-width:{{ list_width }}px;max-width:{{ list_width }}px">{% if f.field_name == 'name' %}<a href="/assets/{{ a.id }}">{{ a.name }}</a>{% else %}{{ a|attr(f.field_name) or '' }}{% endif %}</td>{% endfor %}
{% if job_filter %}{% set job_state = job_state_by_asset.get(a.id) %}
<td data-field="job_filter_status" style="width:180px;min-width:180px;max-width:180px"><span class="job-status job-status-{{ job_state.status }}">{{ t('software.status.' ~ job_state.status, t('assets.job_filter.never_run', 'Noch nie ausgeführt') if job_state.status == 'never_run' else job_state.status) }}</span>{% if job_filter.specs|length > 1 and job_state.job_label %}<small class="muted job-filter-source">{{ job_state.job_label }}</small>{% endif %}</td>
<td data-field="job_filter_last_execution" style="width:175px;min-width:175px;max-width:175px">{% if job_state.last_execution %}<time class="local-time" datetime="{{ utc_iso(job_state.last_execution) }}" data-utc="{{ utc_iso(job_state.last_execution) }}">{{ job_state.last_execution }}</time>{% else %}<span class="muted">—</span>{% endif %}</td>
<td data-field="job_filter_last_success" style="width:175px;min-width:175px;max-width:175px">{% if job_state.last_success %}<time class="local-time" datetime="{{ utc_iso(job_state.last_success) }}" data-utc="{{ utc_iso(job_state.last_success) }}">{{ job_state.last_success }}</time>{% else %}<span class="muted">—</span>{% endif %}</td>
<td data-field="job_filter_revision" style="width:100px;min-width:100px;max-width:100px">{% if job_state.definition_revision %}{{ job_state.definition_revision }}{% else %}<span class="muted">—</span>{% endif %}</td>
{% endif %}
<td data-field="mesh_presence" style="width:125px;min-width:125px;max-width:125px">
{% if a.mesh_node_id %}
<span
class="presence-badge presence-{{ a.mesh_online_state or 'unknown' }}"
data-presence-asset="{{ a.id }}"
data-label-online="{{ t('presence.online') }}"
data-label-offline="{{ t('presence.offline') }}"
data-label-unknown="{{ t('presence.unknown') }}"
data-last-seen-label="{{ t('presence.last_seen') }}"
title="{{ t('presence.' ~ (a.mesh_online_state or 'unknown')) }} · {{ t('presence.last_seen') }}: {% if a.mesh_last_seen %}{{ a.mesh_last_seen }}{% else %}–{% endif %}"
><span class="presence-dot" aria-hidden="true"></span><span data-presence-label>{{ t('presence.' ~ (a.mesh_online_state or 'unknown')) }}</span></span>
{% else %}
<span class="muted">–</span>
{% endif %}
</td>
<td class="row-actions">{% if is_admin %}
{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="list"><button class="icon-button button-return" title="{{ t('assets.return', 'Zurücknehmen') }}">↩</button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="list" title="{{ t('assets.issue', 'Ausgeben') }}">↗</button>{% endif %}
<a class="icon-button button-edit" href="/assets/{{a.id}}/edit" title="{{ t('common.edit', 'Bearbeiten') }}" aria-label="{{ t('common.edit', 'Bearbeiten') }}">&#9998;</a>
<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('common.duplicate', 'Duplizieren') }}">⧉</a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm').format(name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete', 'Löschen') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only', 'Nur lesen') }}</span>{% endif %}</td>
</tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none', 'Keine Assets vorhanden.') }}</td></tr>{% endfor %}
</tbody>
</table></div>
<p class="muted asset-total">{{ t('assets.total', '{count} Assets').format(count=total_assets) }}</p>
{% if is_admin %}
<dialog id="job-filter-dialog" class="duplicate-search-dialog">
<form method="get" action="/assets" id="job-filter-form">
<div class="dialog-heading">
<h2>{{ t('assets.job_filter.title', 'Assets nach Jobstatus filtern') }}</h2>
<button type="button" onclick="this.closest('dialog').close()" class="icon-button button-secondary" aria-label="{{ t('common.close', 'Schließen') }}">×</button>
</div>
<p class="muted">{{ t('assets.job_filter.help', 'Die Jobinformationen werden nur bei aktivem Filter geladen. Die normale Assetliste bleibt unverändert schnell.') }}</p>
{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}
<fieldset class="job-filter-status-fieldset">
<legend>{{ t('assets.job_filter.jobs', 'Jobs') }}</legend>
<p class="muted">{{ t('assets.job_filter.jobs_help', 'Mehrere Jobs werden als alternative Ausführungen zusammengefasst. Maßgeblich ist der zuletzt ausgeführte ausgewählte Job.') }}</p>
<div class="job-filter-job-options">
<label class="checkbox-label">
<input type="checkbox" class="checkbox job-filter-job-checkbox" name="job_filter" value="software_inventory"{% if job_filter and 'software_inventory' in job_filter.selections %} checked{% endif %}>
{{ t('jobs.type.software_inventory', 'Softwareinventur') }}
</label>
{% for definition in job_filter_definitions %}
<label class="checkbox-label">
<input type="checkbox" class="checkbox job-filter-job-checkbox" name="job_filter" value="definition:{{ definition.id }}" data-revision="{{ definition.revision }}"{% if job_filter and 'definition:' ~ definition.id in job_filter.selections %} checked{% endif %}>
{{ definition.name }}
</label>
{% endfor %}
</div>
</fieldset>
<fieldset class="job-filter-status-fieldset">
<legend>{{ t('assets.job_filter.excluded_statuses', 'Auszuschließende Status') }}</legend>
<p class="muted">{{ t('assets.job_filter.excluded_help', 'Markierte Status werden nicht in der Assetliste angezeigt. Ohne Markierung werden alle Assets angezeigt.') }}</p>
<div class="job-filter-status-options">
{% for status in job_filter_statuses %}
<label class="checkbox-label">
<input type="checkbox" class="checkbox job-filter-status-checkbox" name="job_filter_exclude" value="{{ status }}"{% if job_filter and status in job_filter.excluded_statuses %} checked{% endif %}>
{{ t('software.status.' ~ status, status) }}
</label>
{% endfor %}
</div>
<div class="form-actions job-filter-status-actions">
<button type="button" class="button button-secondary button-small" id="job-filter-exclude-all">{{ t('assets.job_filter.exclude_all', 'Alle markieren') }}</button>
<button type="button" class="button button-secondary button-small" id="job-filter-exclude-none">{{ t('assets.job_filter.exclude_none', 'Auswahl aufheben') }}</button>
</div>
</fieldset>
<label class="checkbox-label" id="job-filter-revision-label">
<input type="checkbox" class="checkbox" id="job-filter-current-revision"{% if job_filter and job_filter.revision_mode == 'current' %} checked{% endif %}>
{{ t('assets.job_filter.current_revision', 'nur aktuelle Revision') }}
<input type="hidden" name="job_filter_revision" id="job-filter-revision" value="{{ job_filter.revision_mode if job_filter else 'all' }}">
</label>
<div class="form-actions">
<a class="button button-secondary" href="/assets{% if selected %}?category_id={{ selected.id }}{% endif %}">{{ t('assets.job_filter.clear', 'Jobfilter entfernen') }}</a>
<button type="submit" class="button">{{ t('assets.job_filter.apply', 'Filter anwenden') }}</button>
</div>
</form>
</dialog>
{% endif %}
<dialog id="duplicate-search-dialog" class="duplicate-search-dialog">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ t('duplicates.search_title') }}</h2>
<button type="submit" class="icon-button button-secondary" aria-label="{{ t('common.close', 'Schließen') }}">×</button>
</div>
<p class="muted">{{ t('duplicates.visible_only_help') }}</p>
<label>{{ t('common.field') }}
<select id="duplicate-field-select"></select>
</label>
<label class="checkbox-label">
<input type="checkbox" class="checkbox" id="duplicate-ignore-empty" checked>
{{ t('duplicates.ignore_empty') }}
</label>
<label class="checkbox-label">
<input type="checkbox" class="checkbox" id="duplicate-case-insensitive" checked>
{{ t('duplicates.ignore_case') }}
</label>
<div id="duplicate-search-summary" class="notice" hidden></div>
<div class="form-actions">
<button type="button" class="button button-secondary" id="duplicate-clear-button">{{ t('duplicates.clear_filter') }}</button>
<button type="button" class="button" id="duplicate-apply-button">{{ t('duplicates.show') }}</button>
<button type="submit" class="button button-secondary">{{ t('common.close', 'Schließen') }}</button>
</div>
</form>
</dialog>
<dialog id="column-settings-dialog" class="column-settings-dialog">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ t('assets.columns', 'Spalten') }}</h2>
<button type="submit" class="icon-button button-secondary" aria-label="{{ t('common.close', 'Schließen') }}">×</button>
</div>
<p class="muted">{{ t('assets.columns_hint', 'Spalten ein- oder ausblenden. Die Auswahl wird in diesem Browser gespeichert.') }}</p>
<div id="column-settings-list" class="column-settings-list"></div>
<div class="form-actions">
<button type="button" class="button button-secondary" id="show-all-columns">{{ t('assets.show_all_columns', 'Alle einblenden') }}</button>
<button type="button" class="button button-secondary" id="reset-columns">{{ t('assets.reset_columns', 'Zurücksetzen') }}</button>
<button type="submit" class="button">{{ t('common.close', 'Schließen') }}</button>
</div>
</form>
</dialog>
<div id="column-context-menu" class="column-context-menu" hidden>
<button type="button" id="context-hide-column">{{ t('assets.hide_column', 'Spalte ausblenden') }}</button>
<button type="button" id="context-open-columns">{{ t('assets.manage_columns', 'Spalten verwalten…') }}</button>
</div>
{% if is_admin %}
<script>
(() => {
const dialog = document.getElementById('job-filter-dialog');
const openButton = document.getElementById('job-filter-button');
const revisionLabel = document.getElementById('job-filter-revision-label');
const revisionCheckbox = document.getElementById('job-filter-current-revision');
const revisionValue = document.getElementById('job-filter-revision');
const form = document.getElementById('job-filter-form');
const statusCheckboxes = () => [...document.querySelectorAll('.job-filter-status-checkbox')];
const jobCheckboxes = () => [...document.querySelectorAll('.job-filter-job-checkbox')];
const refreshFields = () => {
const hasDefinition = jobCheckboxes().some(box => box.checked && String(box.value).startsWith('definition:'));
if (revisionLabel) revisionLabel.hidden = !hasDefinition;
if (revisionValue) revisionValue.value = revisionCheckbox?.checked && hasDefinition ? 'current' : 'all';
};
openButton?.addEventListener('click', () => { refreshFields(); dialog?.showModal(); });
jobCheckboxes().forEach(box => box.addEventListener('change', refreshFields));
revisionCheckbox?.addEventListener('change', refreshFields);
form?.addEventListener('submit', event => {
refreshFields();
if (!jobCheckboxes().some(box => box.checked)) {
event.preventDefault();
window.alert({{ t('assets.job_filter.select_at_least_one', 'Bitte mindestens einen Job auswählen.')|tojson }});
}
});
document.getElementById('job-filter-exclude-all')?.addEventListener('click', () => statusCheckboxes().forEach(box => { box.checked = true; }));
document.getElementById('job-filter-exclude-none')?.addEventListener('click', () => statusCheckboxes().forEach(box => { box.checked = false; }));
refreshFields();
const startButton = document.getElementById('start-filtered-job-button');
const startForm = document.getElementById('start-filtered-job-form');
const startSelection = document.getElementById('start-filtered-job-selection');
const startValue = document.getElementById('start-filtered-job-value');
startButton?.addEventListener('click', () => {
if (startValue && startSelection) startValue.value = startSelection.value;
const template = {{ t('assets.job_filter.start_confirm', 'Den ausgewählten Job für alle {count} gefilterten Assets erstellen und starten?')|tojson }};
const message = template.replace('{count}', {{ total_assets|tojson }});
if (window.confirm(message)) startForm?.requestSubmit();
});
})();
</script>
{% endif %}
<script>
(() => {
const table = document.getElementById('asset-table');
if (!table) return;
const key = table.dataset.storageKey;
const movableHeaders = () => [...table.querySelectorAll('thead tr:first-child th[data-field]')];
const fields = () => movableHeaders().map(th => th.dataset.field);
function applyOrder(order) {
if (!Array.isArray(order)) return;
const current = fields();
const valid = order.filter(f => current.includes(f));
current.forEach(f => { if (!valid.includes(f)) valid.push(f); });
const headerRow = table.tHead.rows[0];
const headerActions = headerRow.lastElementChild;
const headerFragment = document.createDocumentFragment();
valid.forEach(field => {
const header = headerRow.querySelector(`th[data-field="${CSS.escape(field)}"]`);
if (header) headerFragment.appendChild(header);
});
headerRow.insertBefore(headerFragment, headerActions);
[...table.tBodies[0].rows].forEach(row => {
const actions = row.lastElementChild;
const fragment = document.createDocumentFragment();
valid.forEach(field => {
const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`);
if (cell) fragment.appendChild(cell);
});
row.insertBefore(fragment, actions);
});
}
try { applyOrder(JSON.parse(window.AssetBrowserState?.get(key) || 'null')); } catch (_) {}
table.dataset.columnOrderReady = '1';
window.dispatchEvent(new Event('asset-table-column-order-ready'));
let dragged = null;
movableHeaders().forEach(th => {
th.addEventListener('dragstart', () => { dragged = th.dataset.field; th.classList.add('dragging'); });
th.addEventListener('dragend', () => { th.classList.remove('dragging'); dragged = null; });
th.addEventListener('dragover', e => e.preventDefault());
th.addEventListener('drop', e => {
e.preventDefault();
const target = th.dataset.field;
if (!dragged || dragged === target) return;
const order = fields();
order.splice(order.indexOf(dragged), 1);
order.splice(order.indexOf(target), 0, dragged);
applyOrder(order);
window.AssetBrowserState?.set(key, JSON.stringify(order));
window.dispatchEvent(new Event('asset-table-columns-changed'));
});
});
})();
</script>
{% if is_admin %}
<script>
(() => {
const table = document.getElementById('asset-table');
if (!table) return;
const boxes = () => [...table.querySelectorAll('.asset-select')];
const all = document.getElementById('select-all-assets');
const editButton = document.getElementById('bulk-edit-button');
const deleteButton = document.getElementById('bulk-delete-button');
const mergeButton = document.getElementById('merge-duplicates-button');
const jobTypeSelect = document.getElementById('bulk-job-type');
const jobStartButton = document.getElementById('bulk-job-start-button');
const isVisible = box => {
const row = box.closest('tr');
if (!row || row.hidden || row.classList.contains('hidden')) return false;
return true;
};
const visibleBoxes = () => boxes().filter(isVisible);
const selected = () => visibleBoxes().filter(box => box.checked).map(box => box.value);
function selectedPlatforms() {
return [...new Set(visibleBoxes().filter(box => box.checked).map(box => box.closest('tr')?.dataset.platform || 'unknown'))];
}
function filterJobDefinitions() {
if (!jobTypeSelect) return;
const platforms = selectedPlatforms();
const singlePlatform = platforms.length === 1 && platforms[0] !== 'unknown' ? platforms[0] : null;
[...jobTypeSelect.options].forEach((option, index) => {
if (index === 0) { option.hidden = false; option.disabled = false; return; }
const platform = option.dataset.platform || 'all';
const compatible = !singlePlatform || platform === 'all' || platform === singlePlatform;
option.hidden = !compatible;
option.disabled = !compatible;
});
if (jobTypeSelect.selectedOptions[0]?.disabled) jobTypeSelect.value = '';
}
function refresh() {
// Hidden rows must never remain part of a bulk operation after filtering.
boxes().filter(box => !isVisible(box)).forEach(box => { box.checked = false; });
const visible = visibleBoxes();
const count = selected().length;
if (editButton) { editButton.disabled = count === 0; editButton.textContent = count ? `✏ {{ t('assets.bulk_edit', 'Daten ändern') }} (${count})` : '✏ {{ t('assets.bulk_edit', 'Daten ändern') }}'; }
if (deleteButton) { deleteButton.disabled = count === 0; deleteButton.textContent = count ? `🗑 {{ t('assets.delete_selected', 'Markierte löschen') }} (${count})` : '🗑 {{ t('assets.delete_selected', 'Markierte löschen') }}'; }
if (mergeButton) {
mergeButton.disabled = count < 2;
mergeButton.textContent = count >= 2 ? `⧉ {{ t('duplicates.merge') }} (${count})` : '⧉ {{ t('duplicates.merge') }}';
}
if (jobTypeSelect) { jobTypeSelect.disabled = count === 0; filterJobDefinitions(); }
if (jobStartButton) {
jobStartButton.disabled = count === 0 || !jobTypeSelect?.value;
jobStartButton.textContent = count
? `▶ {{ t('jobs.start') }} (${count})`
: '▶ {{ t('jobs.start') }}';
}
if (all) { all.checked = visible.length > 0 && count === visible.length; all.indeterminate = count > 0 && count < visible.length; }
}
function submit(formId, containerId) {
const form = document.getElementById(formId);
const container = document.getElementById(containerId);
container.innerHTML = '';
selected().forEach(id => { const input = document.createElement('input'); input.type='hidden'; input.name='asset_ids'; input.value=id; container.appendChild(input); });
form.requestSubmit();
}
table.addEventListener('change', event => {
if (event.target.matches('.asset-select')) refresh();
});
all?.addEventListener('change', () => { visibleBoxes().forEach(box => box.checked = all.checked); boxes().filter(box => !isVisible(box)).forEach(box => box.checked = false); refresh(); });
editButton?.addEventListener('click', () => submit('bulk-edit-form', 'bulk-edit-ids'));
deleteButton?.addEventListener('click', () => submit('bulk-delete-form', 'bulk-delete-ids'));
mergeButton?.addEventListener('click', () => submit('merge-duplicates-form', 'merge-duplicate-ids'));
jobTypeSelect?.addEventListener('change', refresh);
jobStartButton?.addEventListener('click', () => {
const count = selected().length;
const jobType = jobTypeSelect?.value || '';
if (!count || !jobType) return;
const label = jobTypeSelect?.selectedOptions[0]?.textContent?.trim() || jobType;
const template = {{ t('jobs.bulk.confirm', 'Für {count} markierte Assets jeweils den Job „{job}“ erstellen und starten?')|tojson }};
const message = template.replace('{count}', String(count)).replace('{job}', label);
if (!window.confirm(message)) return;
const jobTypeValue = document.getElementById('bulk-job-type-value');
const definitionValue = document.getElementById('bulk-job-definition-value');
if (jobType.startsWith('definition:')) {
if (jobTypeValue) jobTypeValue.value = 'job_definition';
if (definitionValue) definitionValue.value = jobType.split(':', 2)[1];
} else {
if (jobTypeValue) jobTypeValue.value = jobType;
if (definitionValue) definitionValue.value = '';
}
submit('bulk-job-form', 'bulk-job-ids');
});
// Table filters may hide rows by style, class or hidden attribute. Observe all
// such changes and keep the selection restricted to currently displayed rows.
const observer = new MutationObserver(refresh);
const observeRows = () => table.querySelectorAll('tbody tr').forEach(row => observer.observe(row, {attributes:true, attributeFilter:['style','class','hidden']}));
observeRows();
window.addEventListener('asset-table-content-reloaded', () => {
all.checked = false;
all.indeterminate = false;
observeRows();
refresh();
});
table.addEventListener('input', () => setTimeout(refresh, 0));
table.addEventListener('change', () => setTimeout(refresh, 0));
window.addEventListener('asset-table-filter-changed', refresh);
refresh();
})();
</script>
{% endif %}
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ now().strftime('%Y-%m-%d') if now is defined else '' }}" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script>
<script>
(() => {
const btn = document.getElementById('reload-assets-button');
if (!btn) return;
btn.addEventListener('click', async () => {
const tbody = document.querySelector('#asset-table tbody');
if (!tbody) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = btn.dataset.loadingLabel || '⏳ {{ t('common.loading') }}';
try {
const response = await fetch(window.location.href, {
headers: {'X-Requested-With': 'XMLHttpRequest'}
});
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newBody = doc.querySelector('#asset-table tbody');
const count = doc.querySelector('.asset-total');
if (newBody) {
const table = document.getElementById('asset-table');
if (table) {
const orderedFields = [...table.tHead.rows[0].cells]
.map(cell => cell.dataset.field)
.filter(Boolean);
[...newBody.rows].forEach(row => {
const actions = row.lastElementChild;
const fragment = document.createDocumentFragment();
orderedFields.forEach(field => {
const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`);
if (cell) fragment.appendChild(cell);
});
if (actions) row.insertBefore(fragment, actions);
});
}
// Keep the existing tbody element. Table filters, sorting and delegated
// event handlers are bound to it and would otherwise still reference the
// detached old body after an AJAX reload.
tbody.replaceChildren(...newBody.childNodes);
table?.assetRebuildFilterCache?.();
window.dispatchEvent(new CustomEvent('asset-table-content-reloaded', {
detail: { table }
}));
window.dispatchEvent(new Event('asset-table-columns-changed'));
}
if (count) {
const current = document.querySelector('.asset-total');
if (current) current.replaceWith(count);
}
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
})();
</script>
</div>
<script>requestAnimationFrame(()=>requestAnimationFrame(()=>document.getElementById('asset-list-page')?.classList.remove('asset-list-initializing')));</script>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar"><div><h1>Daten mehrerer Assets ändern</h1><p class="muted">Die Änderung wird auf {{ assets|length }} markierte Assets angewendet.</p></div><a class="button button-secondary" href="/assets{% if category_id %}?category_id={{ category_id }}{% endif %}">Abbrechen</a></div>
<section class="panel bulk-edit-panel">
<form method="post" action="/assets/bulk-edit/apply" id="bulk-edit-apply-form">
{% for id in asset_ids %}<input type="hidden" name="asset_ids" value="{{ id }}">{% endfor %}
{% if category_id %}<input type="hidden" name="category_id" value="{{ category_id }}">{% endif %}
<div class="form-grid">
<label>Feld
<select name="field_name" id="bulk-field" required>
<option value="">Bitte auswählen</option>
{% for field in fields %}<option value="{{ field.field_name }}">{{ field.label }}</option>{% endfor %}
</select>
</label>
<label>Neuer Wert
<input name="new_value" id="bulk-value" autocomplete="off">
<select id="bulk-status-value" class="hidden-control">
<option value="">Leer / nicht gesetzt</option>
{% for status in status_options %}<option value="{{ status.name }}">{{ status.name }}</option>{% endfor %}
</select>
<select id="bulk-parent-value" class="hidden-control">
<option value="">Kein übergeordnetes Gerät</option>
{% for parent in parent_candidates %}<option value="{{ parent.id }}">{{ parent.name }}{% if parent.asset_tag %} – {{ parent.asset_tag }}{% endif %} ({{ parent.category.name }})</option>{% endfor %}
</select>
<select id="bulk-category-value" class="hidden-control">
<option value="">Bitte Kategorie auswählen</option>
{% for category in categories %}<option value="{{ category.id }}">{{ category.name }}</option>{% endfor %}
</select>
</label>
</div>
<p class="muted">Ein leerer Wert entfernt den bisherigen Wert. Inventarnummer, Seriennummer und MeshCentral Node-ID sind aus Sicherheitsgründen von der Massenänderung ausgeschlossen.</p>
<details><summary>Ausgewählte Assets anzeigen</summary><ul class="compact-list">{% for asset in assets %}<li>{{ asset.name }} <span class="muted">({{ asset.category.name }})</span></li>{% endfor %}</ul></details>
<div class="form-actions"><a class="button button-secondary" href="/assets{% if category_id %}?category_id={{ category_id }}{% endif %}">Abbrechen</a><button type="submit">💾 Für {{ assets|length }} Assets speichern</button></div>
</form>
</section>
<script>
(() => {
const field = document.getElementById('bulk-field');
const input = document.getElementById('bulk-value');
const status = document.getElementById('bulk-status-value');
const parent = document.getElementById('bulk-parent-value');
const category = document.getElementById('bulk-category-value');
const controls = [input, status, parent, category];
field.addEventListener('change', () => {
const selected = field.value;
const active = selected === 'status' ? status : selected === 'parent_asset_id' ? parent : selected === 'category_id' ? category : input;
controls.forEach(control => {
control.classList.toggle('hidden-control', control !== active);
if (control === active) control.name = 'new_value'; else control.removeAttribute('name');
});
const dateFields = ['assigned_on','purchase_date','warranty_until'];
input.type = dateFields.includes(selected) ? 'date' : 'text';
});
})();
</script>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block content %}
<div class="toolbar">
<h1>{{ t('assets.import_title') }}</h1>
<div class="toolbar-actions"><a class="button button-secondary" href="/assets">← Zur Asset-Liste</a></div>
</div>
<section class="panel">
<form method="post" action="/assets/import" enctype="multipart/form-data">
<label>Kategorie
<select name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if selected and selected.id == category.id %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
</select>
</label>
{% if selected %}
<p><a class="button button-secondary" href="/assets/import/template.xlsx?category_id={{ selected.id }}">{{ t('assets.import_template') }}</a></p>
{% endif %}
<label>Excel-Datei
<input type="file" name="file" accept=".xlsx" required>
</label>
<label class="checkbox-label">
<input class="checkbox" type="checkbox" name="import_only_active_fields" {% if import_only_active_fields %}checked{% endif %}>
Import only active fields
</label>
<p class="muted">
Standardmäßig werden alle bekannten und für den Excel-Import freigegebenen Felder übernommen,
auch wenn sie in der jeweiligen Kategorie nicht aktiv oder sichtbar sind.
Aktiviere diese Option nur, wenn der Import auf die aktiven Felder der Kategorie begrenzt werden soll.
</p>
<div class="form-actions">
<button type="submit" class="button button-secondary" name="import_mode" value="dry_run">🧪 Test-Import</button>
<button type="submit" class="button button-save" name="import_mode" value="import">{{ t('assets.import_start') }}</button>
</div>
<p class="muted">Der Test-Import prüft dieselbe Datei mit derselben Logik, führt aber am Ende einen vollständigen Datenbank-Rollback durch.</p>
</form>
</section>
{% if result %}
<section class="panel">
<h2>{% if result.dry_run %}Ergebnis des Test-Imports{% else %}Importergebnis{% endif %}</h2>
{% if result.dry_run %}
<div class="notice"><strong>Testlauf:</strong> Es wurden keine Daten gespeichert. Die Zähler und Zeilenergebnisse zeigen, was ein echter Import ausführen würde.</div>
{% endif %}
<dl class="config-list">
<dt>Neu angelegt</dt><dd>{{ result.created }}</dd>
<dt>Aktualisiert</dt><dd>{{ result.updated }}</dd>
<dt>Unverändert</dt><dd>{{ result.skipped }}</dd>
</dl>
{% if result.unknown_headers %}
<h3>Nicht erkannte Spalten</h3>
<p>{{ result.unknown_headers|join(', ') }}</p>
{% endif %}
{% if result.row_results %}
<h3>Ergebnis je Excel-Zeile</h3>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>Excel-Zeile</th><th>Asset-ID</th><th>Aktion</th><th>Begründung</th></tr></thead>
<tbody>
{% for row in result.row_results %}
<tr>
<td>{{ row.row }}</td>
<td>{{ row.asset_id }}</td>
<td>{{ row.action }}</td>
<td>{{ row.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if result.errors %}
<h3>Fehlerübersicht</h3>
<ul>{% for error in result.errors %}<li>{{ error }}</li>{% endfor %}</ul>
{% endif %}
</section>
{% endif %}
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('duplicates.merge') }}</h1>
<p class="muted">{{ t('duplicates.order_help') }}</p>
</div>
<a class="button button-secondary" href="/assets">{{ t('common.cancel') }}</a>
</div>
<form method="post" action="/assets/merge/preview">
<div class="panel notice">
<strong>{{ t('common.important') }}:</strong> {{ t('duplicates.order_notice') }}
</div>
<div class="table-scroll">
<table class="data-table merge-order-table">
<thead>
<tr>
<th>{{ t('common.order') }}</th><th>{{ t('assets.asset_id') }}</th><th>{{ t('common.category') }}</th><th>{{ t('field.name') }}</th>
<th>{{ t('field.model') }}</th><th>{{ t('field.serial_number') }}</th><th>{{ t('field.hostname') }}</th><th>{{ t('field.notes') }}</th><th>{{ t('field.mesh_node_id') }}</th>
</tr>
</thead>
<tbody id="merge-order-body">
{% for asset in assets %}
<tr draggable="true">
<td class="merge-drag-handle">☰</td>
<td>{{ asset.id }}<input type="hidden" name="asset_ids" value="{{ asset.id }}"></td>
<td>{{ asset.category.name }}</td>
<td>{{ asset.name }}</td>
<td>{{ asset.model or '' }}</td>
<td>{{ asset.serial_number or '' }}</td>
<td>{{ asset.hostname or '' }}</td>
<td class="multiline">{{ asset.notes or '' }}</td>
<td class="multiline">{{ asset.mesh_node_id or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<a class="button button-secondary" href="/assets">{{ t('common.cancel') }}</a>
<button type="submit">{{ t('duplicates.preview') }}</button>
</div>
</form>
<script>
(() => {
const body = document.getElementById('merge-order-body');
let dragged = null;
body.querySelectorAll('tr').forEach(row => {
row.addEventListener('dragstart', () => { dragged = row; row.classList.add('dragging'); });
row.addEventListener('dragend', () => { row.classList.remove('dragging'); dragged = null; });
row.addEventListener('dragover', event => {
event.preventDefault();
if (!dragged || dragged === row) return;
const rect = row.getBoundingClientRect();
body.insertBefore(dragged, event.clientY > rect.top + rect.height / 2 ? row.nextSibling : row);
});
});
})();
</script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('duplicates.preview_title') }}</h1>
<p class="muted">{{ t('duplicates.preview_help').format(id=assets[0].id) }}</p>
</div>
</div>
<div class="panel">
<h2>{{ t('duplicates.result') }}</h2>
<table class="data-table">
<thead><tr><th>{{ t('common.field') }}</th><th>{{ t('duplicates.result') }}</th><th>{{ t('duplicates.source') }}</th></tr></thead>
<tbody>
{% for item in preview %}
<tr><td>{{ item.label }}</td><td class="multiline">{{ item.value }}</td><td>Asset {{ item.source_asset_id }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel">
<h2>{{ t('common.order') }}</h2>
<ol>
{% for asset in assets %}
<li><strong>Asset {{ asset.id }}</strong> – {{ asset.name }}{% if loop.first %} ({{ t('duplicates.survives') }}){% else %} ({{ t('duplicates.will_delete') }}){% endif %}</li>
{% endfor %}
</ol>
</div>
<form method="post" action="/assets/merge/apply" onsubmit="return confirm({{ t('duplicates.confirm_apply')|tojson }});">
{% for asset in assets %}<input type="hidden" name="asset_ids" value="{{ asset.id }}">{% endfor %}
<div class="form-actions">
<button type="button" class="button button-secondary" onclick="history.back()">{{ t('duplicates.change_order') }}</button>
<button type="submit" class="button button-danger">{{ t('duplicates.merge_save') }}</button>
</div>
</form>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
{% set cfg = application_config() %}{% set appinfo = application_info() %}{% set general = cfg.get('general', {}) %}{% set colors = general.get('colors', {}) %}{% set auth = cfg.get('authentication', {}) %}{% set session_user = request.session.get('user') if request else None %}{% set current_language = (session_user.language_code if session_user else request.session.get('language_code', general.get('default_language', 'en'))) %}{% set theme_mode = session_user.theme_mode if session_user and session_user.theme_mode in ['light','dark'] else general.get('default_theme', 'light') %}
<!doctype html><html lang="{{ current_language }}" data-theme="{{ theme_mode }}"{% if request and request.url.path == '/assets' %} class="asset-page-booting"{% endif %}><head>
<style id="system-theme-colors">
:root{
--am-primary:{{ colors.get('primary_color', '#0878bd') }};
--am-primary-hover:{{ colors.get('primary_hover_color', '#075f94') }};
--am-header:{{ colors.get('header_color', '#063c66') }};
--am-navigation:{{ colors.get('navigation_color', '#ffffff') }};
--am-page-bg:{{ colors.get('page_background', '#f2f4f6') }};
--am-surface:{{ colors.get('surface_color', '#ffffff') }};
--am-text:{{ colors.get('text_color', '#1f2933') }};
--am-border:{{ colors.get('border_color', '#d7dde3') }};
--am-success:{{ colors.get('success_color', '#198754') }};
--am-warning:{{ colors.get('warning_color', '#d97706') }};
--am-danger:{{ colors.get('danger_color', '#b42318') }};
}
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
<header class="main-header">
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company">– {{ general.get('company_name') }}</span>{% endif %}</a>
<div class="header-actions">
{% if session_user %}
<form method="post" action="/profile/theme" class="header-theme-form" id="header-theme-form">
<input type="hidden" name="theme_mode" value="{{ 'light' if theme_mode == 'dark' else 'dark' }}">
<input type="hidden" name="next_url" value="{{ request.url.path }}">
<button type="submit" class="header-theme-toggle" title="{{ t('appearance.switch_to_light') if theme_mode == 'dark' else t('appearance.switch_to_dark') }}" aria-label="{{ t('appearance.switch_to_light') if theme_mode == 'dark' else t('appearance.switch_to_dark') }}">
<span class="theme-icon-light" aria-hidden="true">☀</span>
<span class="theme-icon-dark" aria-hidden="true">☾</span>
</button>
</form>
{% endif %}
<button type="button" class="header-help-button" id="about-dialog-button" title="{{ t('about.open') }}" aria-label="{{ t('about.open') }}">?</button>
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
<details class="user-menu"><summary>👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾</summary><div class="user-menu-panel">{% if session_user %}<a href="/profile">{{ t('app.profile') }}</a><a href="/logout">{{ t('app.logout') }}</a>{% else %}{% if auth.get('mode', 'none') == 'none' %}<span class="muted">Authentication disabled</span>{% endif %}<a href="/login">{{ t('app.login') }}</a>{% endif %}</div></details></div>
</header>
<dialog id="about-dialog" class="about-dialog">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ general.get('title', 'AssetManager') }}</h2>
<button type="submit" class="icon-button button-secondary" aria-label="{{ t('common.close') }}">×</button>
</div>
{% set fixed_manufacturer_logo = manufacturer_logo_path() %}
{% if fixed_manufacturer_logo %}<div class="about-manufacturer-logo"><img src="{{ fixed_manufacturer_logo }}?v={{ application_version() }}" alt=""></div>{% endif %}
<dl class="about-details">
<dt>{{ t('about.version') }}</dt><dd>{{ application_version() }}</dd>
{% if appinfo.author %}<dt>{{ t('about.author') }}</dt><dd>{{ appinfo.author }}</dd>{% endif %}
{% if appinfo.organization %}<dt>{{ t('about.organization') }}</dt><dd>{{ appinfo.organization }}</dd>{% endif %}
{% if appinfo.contact %}<dt>{{ t('about.contact') }}</dt><dd>{{ appinfo.contact }}</dd>{% endif %}
{% if appinfo.license %}<dt>{{ t('about.license') }}</dt><dd>{{ appinfo.license }}</dd>{% endif %}
{% if appinfo.website %}<dt>{{ t('about.website') }}</dt><dd><a href="{{ appinfo.website }}" target="_blank" rel="noopener">{{ appinfo.website }}</a></dd>{% endif %}
{% if appinfo.repository %}<dt>{{ t('about.repository') }}</dt><dd><a href="{{ appinfo.repository }}" target="_blank" rel="noopener">{{ appinfo.repository }}</a></dd>{% endif %}
{% if appinfo.copyright %}<dt>{{ t('about.copyright') }}</dt><dd>{{ appinfo.copyright }}</dd>{% endif %}
</dl>
<div class="form-actions"><button type="submit">{{ t('common.close') }}</button></div>
</form>
</dialog>
<script>
(() => {
const button = document.getElementById('about-dialog-button');
const dialog = document.getElementById('about-dialog');
button?.addEventListener('click', () => dialog?.showModal());
})();
</script>
<div class="app-shell" id="app-shell">
<aside class="side-nav" id="side-nav" aria-label="{{ t('navigation.main', 'Hauptnavigation') }}">
<button type="button" class="side-nav-toggle" id="side-nav-toggle" title="{{ t('navigation.toggle', 'Navigation ein-/ausklappen') }}" aria-label="{{ t('navigation.toggle', 'Navigation ein-/ausklappen') }}">☰</button>
<a href="/" title="{{ t('app.dashboard') }}"><span class="nav-icon">⌂</span><span class="nav-label">{{ t('app.dashboard') }}</span></a>
<a href="/assets" title="{{ t('app.assets') }}"><span class="nav-icon">▦</span><span class="nav-label">{{ t('app.assets') }}</span></a>
<a href="/asset-tree" title="{{ t('app.asset_tree') }}"><span class="nav-icon">⌘</span><span class="nav-label">{{ t('app.asset_tree') }}</span></a>
<a href="/charts" title="{{ t('app.charts') }}"><span class="nav-icon">▥</span><span class="nav-label">{{ t('app.charts') }}</span></a>
{% if session_user and session_user.is_admin %}
<a href="/software" title="{{ t('app.software') }}"><span class="nav-icon">▣</span><span class="nav-label">{{ t('app.software') }}</span></a>
<a href="/job-definitions" title="{{ t('jobdefs.title') }}"><span class="nav-icon">⌘</span><span class="nav-label">{{ t('jobdefs.title') }}</span></a>
<div class="side-nav-settings">
<a href="/settings" class="settings-trigger" title="{{ t('app.settings') }}"><span class="nav-icon">⚙</span><span class="nav-label">{{ t('app.settings') }}</span><span class="nav-caret">›</span></a>
<div class="settings-hover-menu" role="menu">
<a href="/settings/appearance">{{ t('settings.appearance', 'Darstellung') }}</a>
<a href="/settings/fields">{{ t('settings.fields', 'Felder') }}</a>
<a href="/settings/import-export">{{ t('settings.import_export', 'Import / Export') }}</a>
<a href="/categories">{{ t('app.categories', 'Kategorien') }}</a>
<a href="/settings/authentication">{{ t('settings.ldap', 'LDAP') }}</a>
<a href="/sync/meshcentral">{{ t('settings.meshcentral', 'MeshCentral') }}</a>
<a href="/settings/software">{{ t('software.settings.menu') }}</a>
<a href="/settings/backup">{{ t('settings.backup', 'Sicherung') }}</a>
<a href="/status-options">{{ t('app.status_values', 'Statuswerte') }}</a>
<a href="/settings/system">{{ t('settings.system_information', 'Systeminformationen') }}</a>
<a href="/settings/translations">{{ t('settings.translations', 'Übersetzungen') }}</a>
<a href="/users">{{ t('users.management', 'Benutzerverwaltung') }}</a>
</div>
</div>
{% endif %}
</aside>
<main class="app-content">{% block content %}{% endblock %}</main>
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js"></script><script src="/static/js/asset-columns.js"></script><script src="/static/js/asset-duplicates.js"></script><script src="/static/js/toasts.js"></script><script>
(() => {
document.addEventListener('keydown', event => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
const openDialog = [...document.querySelectorAll('dialog[open]')].pop();
const scope = openDialog || document;
const buttons = [...scope.querySelectorAll('form button[type="submit"], form button:not([type]), form input[type="submit"]')];
const saveButton = buttons.find(button => !button.disabled && (
button.matches('.button-save,[data-save-shortcut]') ||
/speichern|save/i.test(button.textContent || button.value || '')
));
if (!saveButton) return;
event.preventDefault();
saveButton.click();
});
})();
</script><script>
(() => {
const shell = document.getElementById('app-shell');
const toggle = document.getElementById('side-nav-toggle');
if (!shell || !toggle) return;
const key = 'assetmanager.sidebar.collapsed';
if (document.documentElement.classList.contains('sidebar-collapsed-preset')) shell.classList.add('sidebar-collapsed');
shell.classList.add('sidebar-ready');
toggle.addEventListener('click', () => {
shell.classList.toggle('sidebar-collapsed');
const collapsed = shell.classList.contains('sidebar-collapsed');
localStorage.setItem(key, collapsed ? 'true' : 'false');
document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed);
});
})();
</script><script src="/static/js/asset-page-boot.js"></script><script src="/static/js/presence.js"></script><script src="/static/js/software-inventory-selection.js?v={{ app_version }}"></script>
</body></html>
+296
View File
@@ -0,0 +1,296 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<h1>{{ t('app.categories') }}</h1>
<div class="toolbar-actions">
<button type="button" class="button button-secondary" id="category-bulk-edit-button"
data-base-label="✏ {{ t('categories.bulk.edit', 'Feld ändern')|e }}" disabled>
✏ {{ t('categories.bulk.edit', 'Feld ändern') }}
</button>
<button type="button" class="button button-secondary" id="category-fields-button"
data-base-label="⚙ {{ t('categories.fields.manage', 'Felder verwalten')|e }}" disabled>
⚙ {{ t('categories.fields.manage', 'Felder verwalten') }}
</button>
<button type="submit" class="button button-secondary" form="category-bulk-form" id="activate-defaults-button"
data-base-label="✓ {{ t('categories.activate_default_fields', 'Standardfelder aktivieren')|e }}" disabled>
✓ {{ t('categories.activate_default_fields', 'Standardfelder aktivieren') }}
</button>
<a class="button" href="/categories/new">{{ t('categories.create') }}</a>
</div>
</div>
<form id="category-bulk-form" method="post" action="/categories/activate-default-fields"></form>
<dialog id="category-bulk-edit-dialog">
<form id="category-bulk-edit-form" method="post" action="/categories/bulk-edit" enctype="multipart/form-data">
<div class="dialog-heading">
<h2>{{ t('categories.bulk.dialog_title', 'Markierte Kategorien ändern') }}</h2>
<button type="button" class="icon-button" data-dialog-close aria-label="{{ t('common.cancel') }}">×</button>
</div>
<p id="category-bulk-selected-count" class="muted"></p>
<span id="category-bulk-edit-ids"></span>
<label>{{ t('categories.bulk.field', 'Feld') }}
<select name="field_name" id="category-bulk-field" required>
<option value="description">{{ t('common.description') }}</option>
<option value="name" id="category-name-option">{{ t('common.name') }}</option>
<option value="image">{{ t('categories.bulk.image', 'Kategoriebild') }}</option>
</select>
</label>
<p id="category-name-hint" class="muted">{{ t('categories.bulk.name_hint', 'Der Name kann nur geändert werden, wenn genau eine Kategorie markiert ist.') }}</p>
<label id="category-text-value-wrap">{{ t('categories.bulk.value', 'Neuer Inhalt') }}
<textarea name="field_value" id="category-bulk-value" rows="4"></textarea>
</label>
<label id="category-image-value-wrap" hidden>{{ t('categories.bulk.image', 'Kategoriebild') }}
<input type="file" name="image" accept="image/*">
<span class="muted">{{ t('categories.bulk.image_hint', 'Dasselbe Bild wird allen markierten Kategorien zugewiesen.') }}</span>
</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="clear_value" id="category-bulk-clear"> {{ t('categories.bulk.clear', 'Ausgewähltes Feld leeren') }}</label>
<div class="form-actions">
<button type="button" class="button button-secondary" data-dialog-close>{{ t('common.cancel') }}</button>
<button type="submit">💾 {{ t('common.save') }}</button>
</div>
</form>
</dialog>
<dialog id="category-fields-dialog" class="wide-dialog">
<form id="category-fields-form" method="post" action="/categories/bulk-fields">
<div class="dialog-heading">
<h2>{{ t('categories.fields.manage', 'Felder verwalten') }}</h2>
<button type="button" class="icon-button" data-fields-dialog-close aria-label="{{ t('common.cancel') }}">×</button>
</div>
<p id="category-fields-selected-count" class="muted"></p>
<span id="category-fields-ids"></span>
<div class="dialog-tabs" role="tablist" aria-label="{{ t('categories.fields.manage', 'Felder verwalten') }}">
<button type="button" class="button button-secondary category-fields-tab is-active" data-operation="add">{{ t('categories.fields.add', 'Hinzufügen') }}</button>
<button type="button" class="button button-secondary category-fields-tab" data-operation="remove">{{ t('categories.fields.remove', 'Entfernen') }}</button>
<button type="button" class="button button-secondary category-fields-tab" data-operation="settings">{{ t('categories.fields.settings', 'Einstellungen ändern') }}</button>
<button type="button" class="button button-secondary category-fields-tab" data-operation="copy">{{ t('categories.fields.copy', 'Übernehmen') }}</button>
</div>
<input type="hidden" name="operation" id="category-fields-operation" value="add">
<section class="category-fields-panel" data-panel="add">
<p class="muted">{{ t('categories.fields.add_help', 'Die ausgewählten Felder werden in allen markierten Kategorien aktiviert. Bereits vorhandene Zuordnungen bleiben erhalten.') }}</p>
<div class="field-choice-grid">
{% for definition in definitions %}
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="field_ids" value="{{ definition.id }}"> {{ t(definition.translation_key, definition.label) }}</label>
{% endfor %}
</div>
</section>
<section class="category-fields-panel" data-panel="remove" hidden>
<p class="muted">{{ t('categories.fields.remove_help', 'Die ausgewählten Felder werden in allen markierten Kategorien deaktiviert. Vorhandene Assetdaten bleiben erhalten. Das Namensfeld kann nicht entfernt werden.') }}</p>
<div class="field-choice-grid">
{% for definition in definitions if definition.field_name != 'name' %}
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="field_ids" value="{{ definition.id }}" disabled> {{ t(definition.translation_key, definition.label) }}</label>
{% endfor %}
</div>
</section>
<section class="category-fields-panel" data-panel="settings" hidden>
<p class="muted">{{ t('categories.fields.settings_help', 'Die Einstellungen werden nur für Kategorien geändert, in denen das Feld aktiv ist.') }}</p>
<label>{{ t('categories.bulk.field', 'Feld') }}
<select name="field_definition_id">
<option value="">{{ t('common.select', 'Auswählen') }}</option>
{% for definition in definitions %}<option value="{{ definition.id }}">{{ t(definition.translation_key, definition.label) }}</option>{% endfor %}
</select>
</label>
<div class="settings-bulk-grid">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="apply_required"> {{ t('categories.fields.change_required', 'Pflichtfeld-Einstellung ändern') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="required_value"> {{ t('categories.required') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="apply_show_in_list"> {{ t('categories.fields.change_list', 'Listenanzeige ändern') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="show_in_list_value"> {{ t('categories.in_list') }}</label>
</div>
</section>
<section class="category-fields-panel" data-panel="copy" hidden>
<p class="muted">{{ t('categories.fields.copy_help', 'Die komplette Feldzuordnung und deren Einstellungen werden aus einer vorhandenen Kategorie auf alle markierten Kategorien übernommen. Nicht in der Vorlage aktive Felder werden in den Zielkategorien deaktiviert; Assetdaten bleiben erhalten.') }}</p>
<label>{{ t('categories.fields.source', 'Vorlagenkategorie') }}
<select name="source_category_id">
<option value="">{{ t('common.select', 'Auswählen') }}</option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<div class="notice warning">{{ t('categories.fields.copy_warning', 'Die Feldkonfiguration der markierten Kategorien wird an die Vorlage angeglichen. Kategorienamen, Beschreibungen, Bilder und Assets werden nicht verändert.') }}</div>
</section>
<div class="form-actions">
<button type="button" class="button button-secondary" data-fields-dialog-close>{{ t('common.cancel') }}</button>
<button type="submit">💾 {{ t('common.save') }}</button>
</div>
</form>
</dialog>
<div class="table-scroll management-table-scroll">
<table class="data-table categories-table">
<thead>
<tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1">
<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>
</tr>
</thead>
<tbody>
{% for c in categories %}
{% set count = asset_counts.get(c.id, 0) %}
{% set active_fields = c.visible_fields|selectattr('active')|list %}
{% set list_fields = active_fields|selectattr('show_in_list')|list %}
<tr>
<td class="selection-column">
<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">
{% 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">
<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>
<button type="submit" class="button button-danger" form="delete-category-{{ c.id }}"
{% if count > 0 %}disabled title="{{ t('categories.in_use', 'Kategorie wird noch verwendet') }}"{% else %}title="{{ t('common.delete') }}"{% endif %}>
{{ t('common.delete') }}
</button>
</div>
<form id="duplicate-category-{{ c.id }}" method="post" action="/categories/{{ c.id }}/duplicate"></form>
<form id="delete-category-{{ c.id }}" method="post" action="/categories/{{ c.id }}/delete"
onsubmit="return confirm('{{ t('categories.confirm_delete', 'Kategorie wirklich löschen? Das ist nur möglich, wenn sie von keinem Asset verwendet wird.')|e }}');"></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(() => {
const boxes = [...document.querySelectorAll('.category-select')];
const all = document.getElementById('select-all-categories');
const defaultsButton = document.getElementById('activate-defaults-button');
const editButton = document.getElementById('category-bulk-edit-button');
const fieldsButton = document.getElementById('category-fields-button');
const fieldsDialog = document.getElementById('category-fields-dialog');
const fieldsIds = document.getElementById('category-fields-ids');
const fieldsSelectedCount = document.getElementById('category-fields-selected-count');
const fieldsOperation = document.getElementById('category-fields-operation');
const dialog = document.getElementById('category-bulk-edit-dialog');
const editIds = document.getElementById('category-bulk-edit-ids');
const selectedCount = document.getElementById('category-bulk-selected-count');
const fieldSelect = document.getElementById('category-bulk-field');
const nameOption = document.getElementById('category-name-option');
const nameHint = document.getElementById('category-name-hint');
const textWrap = document.getElementById('category-text-value-wrap');
const imageWrap = document.getElementById('category-image-value-wrap');
const clearBox = document.getElementById('category-bulk-clear');
const visibleBoxes = () => boxes.filter(box => !box.closest('tr')?.hidden && getComputedStyle(box.closest('tr')).display !== 'none');
const selectedBoxes = () => boxes.filter(box => box.checked);
function refresh() {
const selected = selectedBoxes();
const count = selected.length;
const visible = visibleBoxes();
const visibleSelected = visible.filter(box => box.checked).length;
const defaultsLabel = defaultsButton.dataset.baseLabel || '✓ {{ t('categories.activate_default_fields', 'Standardfelder aktivieren')|e }}';
const fieldsLabel = fieldsButton.dataset.baseLabel || '⚙ {{ t('categories.fields.manage', 'Felder verwalten')|e }}';
const editLabel = editButton.dataset.baseLabel || '✏ {{ t('categories.bulk.edit', 'Feld ändern')|e }}';
defaultsButton.disabled = count === 0;
defaultsButton.textContent = count ? `${defaultsLabel} (${count})` : defaultsLabel;
fieldsButton.disabled = count === 0;
fieldsButton.textContent = count ? `${fieldsLabel} (${count})` : fieldsLabel;
editButton.disabled = count === 0;
editButton.textContent = count ? `${editLabel} (${count})` : editLabel;
all.checked = visible.length > 0 && visibleSelected === visible.length;
all.indeterminate = visibleSelected > 0 && visibleSelected < visible.length;
}
function updateFieldUi() {
const isImage = fieldSelect.value === 'image';
imageWrap.hidden = !isImage;
textWrap.hidden = isImage;
clearBox.checked = false;
}
boxes.forEach(box => box.addEventListener('change', refresh));
all?.addEventListener('change', () => {
visibleBoxes().forEach(box => box.checked = all.checked);
refresh();
});
editButton?.addEventListener('click', () => {
const selected = selectedBoxes();
if (!selected.length) return;
editIds.replaceChildren(...selected.map(box => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'category_ids';
input.value = box.value;
return input;
}));
const countTemplate = {{ t('categories.bulk.selected_count', '{count} Kategorien ausgewählt')|tojson }};
selectedCount.textContent = countTemplate.replace('{count}', selected.length);
nameOption.disabled = selected.length !== 1;
nameHint.hidden = selected.length === 1;
if (nameOption.disabled && fieldSelect.value === 'name') fieldSelect.value = 'description';
updateFieldUi();
dialog.showModal();
});
fieldsButton?.addEventListener('click', () => {
const selected = selectedBoxes();
if (!selected.length) return;
fieldsIds.replaceChildren(...selected.map(box => {
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'category_ids'; input.value = box.value;
return input;
}));
const countTemplate = {{ t('categories.bulk.selected_count', '{count} Kategorien ausgewählt')|tojson }};
fieldsSelectedCount.textContent = countTemplate.replace('{count}', selected.length);
fieldsDialog.showModal();
});
function selectFieldsOperation(operation) {
fieldsOperation.value = operation;
fieldsDialog.querySelectorAll('.category-fields-tab').forEach(button => button.classList.toggle('is-active', button.dataset.operation === operation));
fieldsDialog.querySelectorAll('.category-fields-panel').forEach(panel => panel.hidden = panel.dataset.panel !== operation);
// Only controls in the active panel are submitted. This prevents duplicate
// field_ids from the add/remove panels.
fieldsDialog.querySelectorAll('.category-fields-panel').forEach(panel => {
panel.querySelectorAll('input, select, textarea').forEach(control => control.disabled = panel.dataset.panel !== operation);
});
}
fieldsDialog?.querySelectorAll('.category-fields-tab').forEach(button => button.addEventListener('click', () => selectFieldsOperation(button.dataset.operation)));
fieldsDialog?.querySelectorAll('[data-fields-dialog-close]').forEach(button => button.addEventListener('click', () => fieldsDialog.close()));
selectFieldsOperation('add');
fieldSelect?.addEventListener('change', updateFieldUi);
dialog?.querySelectorAll('[data-dialog-close]').forEach(button => button.addEventListener('click', () => dialog.close()));
// Table filters can change row visibility without changing checkboxes. Keep the
// selection state consistent whenever the shared table tools signal activity.
document.addEventListener('input', event => {
if (event.target.closest('.categories-table')) requestAnimationFrame(refresh);
});
document.addEventListener('change', event => {
if (event.target.closest('.categories-table')) requestAnimationFrame(refresh);
});
refresh();
})();
</script>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends 'base.html' %}{% block content %}
<h1>{% if category %}{{ t('categories.edit') }}{% else %}{{ t('categories.create') }}{% endif %}</h1>
<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>
<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>
{% for definition in definitions %}{% set cfg=selected_fields.get(definition.field_name) %}
<tr><td><input class="checkbox" type="checkbox" name="active_{{definition.id}}" {% if (cfg and cfg.active) or definition.field_name == 'name' %}checked{% endif %}{% if definition.field_name == 'name' %} disabled{% endif %}></td>
<td><code>{{definition.field_name}}</code></td><td><strong>{{ t(definition.translation_key, definition.label) }}</strong>{% if definition.description %}<div class="muted">{{definition.description}}</div>{% endif %}</td>
<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 %}
+35
View File
@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block content %}
<h1>{% if chart %}{{ t('charts.edit') }}{% else %}{{ t('charts.create') }}{% endif %}</h1>
{% if chart and chart.is_system %}<p class="notice">{{ t('charts.system_help') }}</p>{% endif %}
<form method="post" action="{% if chart %}/charts/{{ chart.id }}/edit{% else %}/charts/new{% endif %}">
<label>{{ t('common.name') }}<input name="name" value="{{ chart.name if chart else '' }}" required></label>
<label>{{ t('common.description') }}<textarea name="description">{{ chart.description or '' if chart else '' }}</textarea></label>
<div class="form-grid">
<label>{{ t('charts.type') }}<select name="chart_type">{% for value,label in chart_types.items() %}<option value="{{ value }}" {% if chart and chart.chart_type==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>{{ t('charts.group_by') }}<select name="group_field">{% for value,label in chart_fields.items() %}<option value="{{ value }}" {% if chart and chart.group_field==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>{{ t('charts.stack_field') }}<select name="stack_field"><option value="">– {{ t('common.none', 'none') }} –</option>{% for value,label in chart_fields.items() %}<option value="{{ value }}" {% if chart and chart.stack_field==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>{{ t('charts.sorting') }}<select name="sort_mode"><option value="count_desc" {% if not chart or chart.sort_mode=='count_desc' %}selected{% endif %}>{{ t('charts.count_desc') }}</option><option value="alpha" {% if chart and chart.sort_mode=='alpha' %}selected{% endif %}>{{ t('charts.alphabetical') }}</option></select></label>
<label>{{ t('charts.max_items') }}<input type="number" name="max_items" min="1" max="50" value="{{ chart.max_items if chart else 12 }}"></label>
<label>{{ t('charts.value_display') }}<select name="value_mode"><option value="raw" {% if not chart or chart.value_mode=='raw' %}selected{% endif %}>{{ t('charts.raw_values') }}</option><option value="automatic" {% if chart and chart.value_mode=='automatic' %}selected{% endif %}>{{ t('charts.auto_group') }}</option><option value="custom" {% if chart and chart.value_mode=='custom' %}selected{% endif %}>{{ t('charts.custom_mappings') }}</option></select></label>
</div>
<label><input type="checkbox" name="dashboard_visible" {% if chart and chart.dashboard_visible %}checked{% endif %}> {{ t('charts.show_dashboard') }}</label>
<label><input type="checkbox" name="show_empty" {% if chart and chart.show_empty %}checked{% endif %}> {{ t('charts.include_empty') }}</label>
<fieldset>
<legend>{{ t('charts.status_filter') }}</legend>
<p class="help-text">{{ t('charts.status_filter_help') }}</p>
<div class="checkbox-grid">
{% for option in status_options %}
<label class="checkbox-option"><input type="checkbox" name="excluded_statuses" value="{{ option.name }}" {% if option.name in excluded_statuses %}checked{% endif %}> {{ option.name }}</label>
{% else %}<p>{{ t('charts.no_status_values') }}</p>{% endfor %}
</div>
</fieldset>
<label>{{ t('charts.custom_value_mappings') }}
<textarea name="value_mappings_text" rows="8" placeholder="Microsoft Windows 11 => Windows 11">{{ value_mappings_text }}</textarea>
<small>{{ t('charts.mapping_help') }}</small>
</label>
<div class="form-actions"><button class="button button-save" type="submit">💾 {{ t('common.save') }}</button><a class="button" href="/charts/manage">{{ t('common.cancel') }}</a></div>
</form>
{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><div><h1>{{ t('app.charts') }}</h1><p class="muted">{{ t('charts.visible_assets_hint') }}</p></div>{% if is_admin %}<a class="button" href="/charts/manage">⚙ {{ t('charts.manage') }}</a>{% endif %}</div>
<div class="chart-grid">{% for item in charts %}{% include '_chart.html' %}{% else %}<div class="empty-state">{{ t('charts.none') }}</div>{% endfor %}</div>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<h1>{{ t('charts.manage') }}</h1>
<a class="button button-save" href="/charts/new">+ {{ t('charts.create') }}</a>
</div>
<section class="panel">
<table class="data-table">
<thead><tr><th>{{ t('common.name') }}</th><th>{{ t('charts.type') }}</th><th>{{ t('charts.grouping') }}</th><th>{{ t('charts.status_filter') }}</th><th>{{ t('charts.dashboard') }}</th><th>{{ t('common.actions') }}</th></tr></thead>
<tbody>
{% for chart in charts %}
<tr>
<td><strong>{{ chart.name }}</strong>{% if chart.is_system %}<br><small>{{ t('charts.system_chart') }}</small>{% endif %}{% if chart.description %}<br><small>{{ chart.description }}</small>{% endif %}</td>
<td>{{ chart_types.get(chart.chart_type, chart.chart_type) }}</td>
<td>{{ chart_fields.get(chart.group_field, chart.group_field) }}{% if chart.stack_field %} / {{ chart_fields.get(chart.stack_field, chart.stack_field) }}{% endif %}</td>
<td>{% if chart.excluded_statuses %}{{ t('charts.excluded') }}: {{ chart.excluded_statuses|join(', ') }}{% else %}–{% endif %}</td>
<td>{{ t('common.yes') if chart.dashboard_visible else t('common.no') }}</td>
<td class="actions">
<a class="button" href="/charts/{{ chart.id }}/edit">✏ {{ t('common.edit') }}</a>
<form method="post" action="/charts/{{ chart.id }}/duplicate" class="inline-form"><button type="submit" class="button">⧉ {{ t('common.duplicate') }}</button></form>
{% if not chart.is_system %}<form method="post" action="/charts/{{ chart.id }}/delete" class="inline-form" onsubmit="return confirm({{ t('charts.delete_confirm')|tojson }});"><button type="submit" class="button button-danger">🗑 {{ t('common.delete') }}</button></form>{% endif %}
</td>
</tr>
{% else %}<tr><td colspan="6">{{ t('charts.none') }}</td></tr>{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar"><h1>{{ t('dashboard.overview') }}</h1>{% if is_admin %}<a class="button" href="/assets/new">➕ {{ t('assets.create') }}</a>{% endif %}</div>
<div class="cards">{% for c in categories %}<a class="card" href="/assets?category_id={{c.id}}">{% if c.image_path %}<img src="{{c.image_path}}">{% else %}<div class="placeholder">{{c.name[:1]}}</div>{% endif %}<div><strong>{{c.name}}</strong><small>{{c.assets|length}} {{ t('common.assets') }}</small></div></a>{% endfor %}</div>
{% if dashboard_charts %}<div class="section-heading"><h2>{{ t('app.charts') }}</h2><a href="/charts">{{ t('charts.show_all') }}</a></div><div class="chart-grid dashboard-charts">{% for item in dashboard_charts %}{% include '_chart.html' %}{% endfor %}</div>{% endif %}
<h2>{{ t('dashboard.recently_changed') }}</h2>
<p class="table-hint">{{ t('dashboard.list_fields_hint') }}</p>
<div class="table-scroll sticky-table"><table class="data-table" id="dashboard-asset-table"><thead><tr><th>{{ t('assets.name') }}</th><th>{{ t('assets.category') }}</th>{% for f in fields if f.field_name != 'name' %}<th>{{ t('field.' ~ f.field_name, f.label) }}</th>{% endfor %}<th data-no-sort="1" data-no-filter="1">{{ t('common.actions') }}</th></tr></thead><tbody>
{% for a in assets %}<tr><td><a href="/assets/{{a.id}}">{{a.name}}</a></td><td>{{a.category.name}}</td>{% for f in fields if f.field_name != 'name' %}<td class="multiline">{{a|attr(f.field_name) or ''}}</td>{% endfor %}<td class="row-actions">{% if is_admin %}{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="dashboard"><button class="icon-button button-return" title="{{ t('assets.return') }}">↩</button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="dashboard" title="{{ t('assets.issue') }}">↗</button>{% endif %}<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('assets.duplicate') }}">⧉</a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only') }}</span>{% endif %}</td></tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none') }}</td></tr>{% endfor %}
</tbody></table></div>
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.html' %}{% block content %}<h1>{% if definition %}Edit field{% else %}Add field{% endif %}</h1>
<form method="post" class="settings-form">
{% if not definition %}<label>Technical field name<input name="field_name" pattern="[a-zA-Z0-9_]+" required><small>Letters, numbers and underscores only.</small></label>{% else %}<label>Technical field name<input value="{{definition.field_name}}" readonly></label>{% endif %}
<label>Label<input name="label" value="{{definition.label if definition else ''}}" required></label><label>Description<textarea name="description">{{definition.description or '' if definition else ''}}</textarea></label>
<label>Data type<select name="data_type">{% for key,label in data_types.items() %}<option value="{{key}}" {% if definition and definition.data_type==key %}selected{% endif %}>{{label}}</option>{% endfor %}</select></label><label>{{ t('fields.list_width', 'Breite in Listen (Pixel)') }}
<input type="number" name="list_width" min="40" max="800" step="10" value="{{ definition.list_width if definition and definition.list_width else '' }}" placeholder="automatisch">
<small>{{ t('fields.list_width_hint', 'Leer = automatische Breite. Sinnvoll sind etwa 60 für kurze Werte und 180–300 für längere Texte.') }}</small>
</label>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="readonly" {% if definition and definition.readonly %}checked{% endif %}{% if definition and definition.field_name in ['mesh_node_id','mesh_mtype'] %} disabled{% endif %}> Read only</label>{% if definition and not definition.is_system %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_active" {% if definition.is_active %}checked{% endif %}> Active</label>{% endif %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_default" {% if definition and definition.is_default %}checked{% endif %}> Standardfeld</label></div>
<div class="checkbox-row">{% for key,label in [('searchable','Searchable'),('filterable','Filterable'),('chart_enabled','Available for charts'),('excel_import','Excel import'),('excel_export','Excel export'),('bulk_edit','Bulk edit')] %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="{{key}}" {% if not definition or definition|attr(key) %}checked{% endif %}> {{label}}</label>{% endfor %}</div>
<button class="button button-save">💾 {{t('common.save')}}</button></form>{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>Fields</h1><div class="toolbar-actions"><a class="button" href="/settings/fields/new">➕ Add field</a><a class="button button-secondary" href="/settings">{{ t('common.back') }}</a></div></div>
<p class="muted">Central labels and data types apply immediately to all categories, forms, tables and exports.</p>
<div class="sticky-table management-table-scroll"><table class="data-table"><thead><tr><th>Field</th><th>Label</th><th>Description</th><th>Data type</th><th>Read only</th><th>System</th><th>Stored values</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for d in definitions %}<tr><td><code>{{d.field_name}}</code></td><td>{{d.label}}</td><td>{{d.description or ''}}</td><td>{{data_types.get(d.data_type,d.data_type)}}</td><td>{{t('common.yes') if d.readonly else t('common.no')}}</td><td>{{t('common.yes') if d.is_system else t('common.no')}}</td><td>{{value_counts.get(d.id,0)}}</td><td><a class="button button-secondary" href="/settings/fields/{{d.id}}/edit">{{t('common.edit')}}</a>{% if not d.is_system %}<form class="inline-form" method="post" action="/settings/fields/{{d.id}}/delete" onsubmit="return confirm('Delete this field and all values?');"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="confirm_values">Confirm values</label><button class="button button-danger">{{t('common.delete')}}</button></form>{% endif %}</td></tr>{% endfor %}
</tbody></table></div>{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar"><div><h1>{{ t('jobdefs.edit') if definition else t('jobdefs.new') }}</h1><p class="muted">{{ t('jobdefs.admin_only') }}</p></div><a class="button button-secondary" href="/job-definitions">{{ t('common.back') }}</a></div>
<form method="post" action="{{ '/job-definitions/' ~ definition.id if definition else '/job-definitions' }}" class="panel form-grid job-definition-form" id="job-definition-form">
<label class="checkbox-label job-enabled-top form-wide"><input type="checkbox" name="enabled" {% if not definition or definition.enabled %}checked{% endif %}> {{ t('common.active') }}</label>
<label>{{ t('common.name') }}<input name="name" required maxlength="180" value="{{ definition.name if definition else '' }}"></label>
<label>{{ t('jobdefs.kind') }}<select name="job_kind" id="job-kind"><option value="script" {% if not definition or definition.job_kind=='script' %}selected{% endif %}>{{ t('jobdefs.kind.script') }}</option><option value="registry" {% if definition and definition.job_kind=='registry' %}selected{% endif %}>{{ t('jobdefs.kind.registry') }}</option></select><small>{{ t('jobdefs.kind.help') }}</small></label>
<label>{{ t('jobdefs.platform') }}<select name="platform">{% for x in ['windows','linux','macos','all'] %}<option value="{{x}}" {% if definition and definition.platform==x %}selected{% endif %}>{{ t('jobdefs.platform.'~x,x) }}</option>{% endfor %}</select></label>
<label class="script-only-field">{{ t('jobdefs.interpreter') }}<select name="interpreter" id="job-interpreter">{% for x in ['powershell','cmd','bash','python'] %}<option value="{{x}}" {% if definition and definition.interpreter==x %}selected{% endif %}>{{ t('jobdefs.interpreter.'~x,x) }}</option>{% endfor %}</select></label>
<label class="script-only-field">{{ t('jobdefs.source') }}<select name="source_type" id="job-source-type">{% for x in ['inline','mounted','unc'] %}<option value="{{x}}" {% if definition and definition.source_type==x %}selected{% endif %}>{{ t('jobdefs.source.'~x,x) }}</option>{% endfor %}</select></label>
<label class="form-wide">{{ t('common.description') }}<textarea name="description" rows="2">{{ definition.description if definition else '' }}</textarea></label>
<label class="form-wide job-source-path">{{ t('jobdefs.path') }}<input name="source_path" value="{{ definition.source_path if definition else '' }}" placeholder="/scripts/windows/example.ps1 oder \\server\share\script.ps1"><small>{{ t('jobdefs.path_help') }}</small></label>
<div class="form-wide job-inline-script">
<label>{{ t('jobdefs.own_script') }}<textarea id="job-inline-script" name="inline_script" rows="18" spellcheck="false" class="code-editor">{{ definition.inline_script if definition else '' }}</textarea><small>{{ t('jobdefs.own_script_help') }}</small></label>
<div class="form-actions" style="margin-top:.75rem"><button class="button button-secondary" type="button" id="insert-job-test-script">{{ t('jobdefs.insert_test_commands') }}</button></div>
<section class="job-script-help" aria-live="polite">
<h3>{{ t('jobdefs.script_help.title') }}</h3>
<p>{{ t('jobdefs.script_help.common') }}</p>
<div class="job-script-help-common">
<p><strong>{{ t('jobdefs.script_help.variables') }}</strong></p>
{% raw %}<code>{{JobId}}</code>, <code>{{AssetName}}</code>, <code>{{Hostname}}</code>, <code>{{IPAddress}}</code>, <code>{{CurrentUser}}</code>, <code>{{SerialNumber}}</code>, <code>{{Manufacturer}}</code>, <code>{{Model}}</code>, <code>{{Department}}</code>, <code>{{Location}}</code>, <code>{{MeshNodeId}}</code>{% endraw %}
<p><strong>{{ t('jobdefs.script_help.parameters') }}</strong> {% raw %}<code>{{Param.ParameterKey}}</code>{% endraw %}</p>
</div>
<div data-script-help="powershell">
<p>{{ t('jobdefs.script_help.powershell') }}</p>
<pre class="sync-log">$count = 3
Write-JobLog "Gefundene Dateien: $count"
$JobResult["processed_count"] = $count</pre>
</div>
<div data-script-help="python">
<p>{{ t('jobdefs.script_help.python') }}</p>
<pre class="sync-log">count = 3
print(f"Processed files: {count}")
job_result["processed_count"] = count</pre>
</div>
<div data-script-help="bash">
<p>{{ t('jobdefs.script_help.bash') }}</p>
<pre class="sync-log">count=3
echo "Processed files: $count"
{% raw %}echo "Asset: {{Hostname}}"{% endraw %}</pre>
</div>
<div data-script-help="cmd">
<p>{{ t('jobdefs.script_help.cmd') }}</p>
<pre class="sync-log">set "COUNT=3"
echo Processed files: %COUNT%
{% raw %}echo Asset: {{Hostname}}{% endraw %}</pre>
</div>
<p class="muted" id="job-source-helper-note">{{ t('jobdefs.script_help.wrapper_sources') }}</p>
</section>
</div>
<section class="form-wide job-registry-editor" hidden>
<div class="toolbar"><div><h2>{{ t('registry.title') }}</h2><p class="muted">{{ t('registry.help') }}</p></div><button type="button" class="button button-secondary" id="add-registry-entry">{{ t('registry.add') }}</button></div>
<label class="checkbox-label"><input type="checkbox" name="registry_backup" {% if definition and definition.configuration and definition.configuration.get('registry_backup') %}checked{% endif %}> {{ t('registry.backup') }}</label>
<div class="registry-parameter-help"><strong>{{ t('registry.parameters.title') }}</strong><p>{{ t('registry.parameters.body') }}</p><code>{% raw %}{{Param.TechnicalKey}}{% endraw %}</code><div id="registry-parameter-keys" class="muted"></div></div>
<input type="hidden" name="registry_entries_json" id="registry-entries-json" value="[]">
<datalist id="registry-rights-list"><option value="ReadKey"><option value="SetValue"><option value="CreateSubKey"><option value="WriteKey"><option value="ChangePermissions"><option value="FullControl"></datalist>
<div id="registry-entry-rows"></div>
<details class="job-script-help"><summary>{{ t('registry.notes.title') }}</summary><p>{{ t('registry.notes.body') }}</p><pre class="sync-log">HKLM\SOFTWARE\Example
Name: Enabled
Type: REG_DWORD
{% raw %}Value: {{Param.Enabled}}{% endraw %}</pre></details>
</section>
<section class="form-wide job-parameters-editor"><div class="toolbar"><div><h2>{{ t('jobparams.title') }}</h2><p class="muted">{{ t('jobparams.help') }}</p></div><button type="button" class="button button-secondary" id="add-job-parameter">{{ t('jobparams.add') }}</button></div>
<input type="hidden" name="parameters_json" id="job-parameters-json" value="[]">
<div id="job-parameter-rows"></div></section>
<label class="script-only-field">{{ t('jobdefs.arguments') }}<input name="arguments" value="{{ definition.arguments if definition else '' }}"></label>
<label>{{ t('jobdefs.timeout') }}<input type="number" name="timeout_seconds" min="1" max="86400" value="{{ definition.timeout_seconds if definition else 300 }}"></label>
<label class="script-only-field">{{ t('jobdefs.success_codes') }}<input name="success_codes" value="{{ definition.success_codes if definition else '0' }}"></label>
<div class="form-actions form-wide"><button class="button button-save" type="submit">{{ t('common.save') }}</button>{% if definition %}<button class="button button-secondary" type="submit" formaction="/job-definitions/{{ definition.id }}/test">{{ t('jobdefs.test') }}</button>{% endif %}</div>
</form>
{% if test_result %}<section class="panel"><h2>{{ t('jobdefs.test_result') }}</h2><pre class="sync-log">{{ test_result }}</pre></section>{% endif %}
{% if script_preview is not none %}<section class="panel"><div class="toolbar"><div><h2>{{ t('jobdefs.preview') }}</h2><p class="muted">{{ t('jobdefs.preview_help') }}</p></div><button class="button button-secondary" type="button" id="copy-job-preview">{{ t('jobdefs.copy_preview') }}</button></div><pre class="sync-log" id="job-script-preview">{{ script_preview }}</pre>{% if preview_values %}<details><summary>{{ t('jobdefs.preview.sample_notice') }}</summary><pre class="sync-log">{% for key,value in preview_values.items() %}{{ key }} = {{ value }}
{% endfor %}</pre></details>{% endif %}</section>{% endif %}
<style>
.registry-entry-row{border:1px solid var(--border-color,#415166);border-radius:8px;padding:1rem;margin-top:1rem}.registry-entry-row [hidden]{display:none!important}.registry-mode-picker{border:0;padding:0;margin:0}.registry-mode-options{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:.6rem}.registry-mode-options label{display:flex;align-items:flex-start;gap:.55rem;border:1px solid var(--border-color,#415166);border-radius:7px;padding:.75rem;cursor:pointer}.registry-mode-options label:has(input:checked){outline:2px solid var(--accent-color,#20a5df);background:rgba(32,165,223,.08)}.registry-mode-options input{margin-top:.2rem}.registry-mode-options span{display:flex;flex-direction:column;gap:.2rem}.registry-mode-options small{opacity:.75}.registry-set-fields,.registry-permission-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.registry-checkbox-stack{display:flex;flex-direction:column;align-items:stretch;gap:.55rem;grid-column:1/-1;width:100%}.registry-checkbox-stack .checkbox-label,.job-definition-form .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;max-width:none!important;text-align:left!important}.registry-checkbox-stack .checkbox-label input,.job-definition-form .checkbox-label input{flex:0 0 auto!important;margin:0!important}.registry-parameter-help{border-left:3px solid var(--accent-color,#20a5df);padding:.65rem .85rem;margin:.75rem 0}.registry-parameter-help p{margin:.25rem 0}.script-only-field[hidden]{display:none!important}.registry-delete-value-fields{display:grid;grid-template-columns:minmax(0,1fr);gap:.75rem}@media(max-width:800px){.registry-set-fields,.registry-permission-fields{grid-template-columns:1fr}}
</style>
<script>
(() => {
const kind=document.getElementById('job-kind'), source=document.getElementById('job-source-type'), interpreter=document.getElementById('job-interpreter'), inlineArea=document.querySelector('.job-inline-script'), pathArea=document.querySelector('.job-source-path'), registryArea=document.querySelector('.job-registry-editor');
const sync=()=>{const isRegistry=kind?.value==='registry';const x=!isRegistry&&source?.value==='inline';if(inlineArea)inlineArea.style.display=x?'block':'none';if(pathArea)pathArea.style.display=(!isRegistry&&!x)?'flex':'none';if(registryArea)registryArea.hidden=!isRegistry;document.querySelectorAll('.script-only-field').forEach(el=>{el.hidden=isRegistry;el.querySelectorAll('input,select,textarea').forEach(c=>c.disabled=isRegistry)});document.querySelectorAll('[data-script-help]').forEach(el=>el.hidden=el.dataset.scriptHelp!==interpreter?.value)}; kind?.addEventListener('change',sync);source?.addEventListener('change',sync);interpreter?.addEventListener('change',sync);sync();
const initial=[{% if definition %}{% for x in definition.parameters %}{{ {'parameter_key':x.parameter_key,'label':x.label,'description':x.description or '','data_type':x.data_type,'default_value':x.default_value or '','required':x.required}|tojson }}{% if not loop.last %},{% endif %}{% endfor %}{% endif %}];
const rows=document.getElementById('job-parameter-rows'), hidden=document.getElementById('job-parameters-json');
const esc=v=>String(v??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function addRow(p={}){const d=document.createElement('div');d.className='job-parameter-row';d.innerHTML=`<label>{{ t('jobparams.key') }}<input data-p="parameter_key" value="${esc(p.parameter_key)}" pattern="[A-Za-z0-9_]+"></label><label>{{ t('jobparams.label') }}<input data-p="label" value="${esc(p.label)}"></label><label>{{ t('jobparams.type') }}<select data-p="data_type">${['text','number','boolean','path'].map(x=>`<option value="${x}" ${p.data_type===x?'selected':''}>${x}</option>`).join('')}</select></label><label>{{ t('jobparams.default') }}<input data-p="default_value" value="${esc(p.default_value)}"></label><label class="form-wide">{{ t('common.description') }}<textarea data-p="description" rows="2">${esc(p.description)}</textarea></label><label class="checkbox-label"><input type="checkbox" data-p="required" ${p.required?'checked':''}> {{ t('jobparams.required') }}</label><button type="button" class="button button-danger" data-remove>{{ t('common.delete') }}</button>`;d.querySelector('[data-remove]').onclick=()=>d.remove();rows.appendChild(d)}
const updateRegistryParameterKeys=()=>{const target=document.getElementById('registry-parameter-keys');if(!target)return;const keys=[...rows.querySelectorAll('[data-p="parameter_key"]')].map(x=>x.value.trim()).filter(Boolean);target.textContent=keys.length?{{ t('registry.parameters.available')|tojson }}+' '+keys.map(k=>String.fromCharCode(123,123)+'Param.'+k+String.fromCharCode(125,125)).join(', '):{{ t('registry.parameters.none')|tojson }}};
initial.forEach(addRow); document.getElementById('add-job-parameter')?.addEventListener('click',()=>{addRow({data_type:'text'});updateRegistryParameterKeys()}); rows.addEventListener('input',updateRegistryParameterKeys); rows.addEventListener('click',()=>setTimeout(updateRegistryParameterKeys)); updateRegistryParameterKeys();
document.getElementById('insert-job-test-script')?.addEventListener('click',()=>{const e=document.getElementById('job-inline-script');if(e)e.value={{ t('jobdefs.test_commands_content')|tojson }}});
const registryInitial={{ (definition.configuration.get('registry_entries', []) if definition and definition.configuration else [])|tojson }};
const registryRows=document.getElementById('registry-entry-rows'), registryHidden=document.getElementById('registry-entries-json');
function addRegistryRow(p={}){
const d=document.createElement('div');
d.className='job-parameter-row registry-entry-row';
const legacyPermission=p.action==='set_permissions';
let mode='set';
if(legacyPermission || (p.action==='set' && p.set_value===false && p.permissions_enabled)) mode='permissions';
else if(p.action==='delete_value') mode='delete_value';
else if(p.action==='delete_key') mode='delete_key';
const knownPrincipals={'S-1-5-32-545':'builtin_users','S-1-5-32-544':'builtin_admins','S-1-5-18':'system','S-1-5-11':'authenticated_users','S-1-1-0':'everyone'};
const principalValue=p.principal||'S-1-5-32-545';
const principalChoice=knownPrincipals[principalValue]||'custom';
const knownRights=['ReadKey','WriteKey','FullControl'];
const rightsValue=p.rights||'FullControl';
const rightsChoice=knownRights.includes(rightsValue)?rightsValue:'custom';
const id='registry-mode-'+Math.random().toString(36).slice(2);
d.innerHTML=`
<fieldset class="registry-mode-picker form-wide">
<legend>{{ t('registry.operation') }}</legend>
<div class="registry-mode-options">
<label><input type="radio" name="${id}" value="set" ${mode==='set'?'checked':''}><span><strong>{{ t('registry.action.set') }}</strong><small>{{ t('registry.action.set.help') }}</small></span></label>
<label><input type="radio" name="${id}" value="permissions" ${mode==='permissions'?'checked':''}><span><strong>{{ t('registry.action.set_permissions') }}</strong><small>{{ t('registry.action.set_permissions.help') }}</small></span></label>
<label><input type="radio" name="${id}" value="delete_value" ${mode==='delete_value'?'checked':''}><span><strong>{{ t('registry.action.delete_value') }}</strong><small>{{ t('registry.action.delete_value.help') }}</small></span></label>
<label><input type="radio" name="${id}" value="delete_key" ${mode==='delete_key'?'checked':''}><span><strong>{{ t('registry.action.delete_key') }}</strong><small>{{ t('registry.action.delete_key.help') }}</small></span></label>
</div>
</fieldset>
<input type="hidden" data-r="action" value="${esc(p.action||'set')}">
<input type="hidden" data-r="set_value" value="${p.set_value!==false?'true':'false'}">
<input type="hidden" data-r="permissions_enabled" value="${p.permissions_enabled?'true':'false'}">
<label>{{ t('registry.hive') }}<select data-r="hive">${['HKLM','HKCU','HKCR','HKU','HKCC'].map(x=>`<option value="${x}" ${p.hive===x?'selected':''}>${x}</option>`).join('')}</select></label>
<label class="form-wide">{{ t('registry.path') }}<input data-r="path" required value="${esc(p.path)}" placeholder="SOFTWARE\\Vendor\\Product oder {% raw %}{{Param.RegistryPath}}{% endraw %}"><small>{{ t('registry.path.help') }}</small></label>
<div class="registry-set-fields form-wide">
<label>{{ t('registry.name') }}<input data-r="name" value="${esc(p.name)}"></label>
<label>{{ t('registry.type') }}<select data-r="value_type">${['REG_SZ','REG_EXPAND_SZ','REG_MULTI_SZ','REG_DWORD','REG_QWORD','REG_BINARY'].map(x=>`<option value="${x}" ${p.value_type===x?'selected':''}>${x}</option>`).join('')}</select></label>
<label class="form-wide">{{ t('registry.value') }}<textarea data-r="value" rows="2" placeholder="{% raw %}{{Param.Value}}{% endraw %}">${esc(p.value)}</textarea></label>
<div class="registry-checkbox-stack"><label class="checkbox-label"><input type="checkbox" data-r="only_if_missing" ${p.only_if_missing?'checked':''}> {{ t('registry.only_if_missing') }}</label></div>
</div>
<div class="registry-delete-value-fields form-wide">
<label>{{ t('registry.name') }}<input data-r-display="delete-name" value="${esc(p.name)}"></label>
</div>
<div class="registry-permission-fields form-wide">
<label>{{ t('registry.principal') }}
<select data-principal-choice>
<option value="builtin_users" ${principalChoice==='builtin_users'?'selected':''}>{{ t('registry.principal.users') }} [S-1-5-32-545]</option>
<option value="builtin_admins" ${principalChoice==='builtin_admins'?'selected':''}>{{ t('registry.principal.admins') }} [S-1-5-32-544]</option>
<option value="system" ${principalChoice==='system'?'selected':''}>SYSTEM [S-1-5-18]</option>
<option value="authenticated_users" ${principalChoice==='authenticated_users'?'selected':''}>{{ t('registry.principal.authenticated') }} [S-1-5-11]</option>
<option value="everyone" ${principalChoice==='everyone'?'selected':''}>{{ t('registry.principal.everyone') }} [S-1-1-0]</option>
<option value="custom" ${principalChoice==='custom'?'selected':''}>{{ t('registry.custom') }}</option>
</select>
</label>
<label class="registry-custom-principal">{{ t('registry.principal_custom') }}<input data-principal-custom value="${esc(principalChoice==='custom'?principalValue:'')}"><small>{{ t('registry.principal_help') }}</small></label>
<input type="hidden" data-r="principal" value="${esc(principalValue)}">
<label>{{ t('registry.rights') }}
<select data-rights-choice>
<option value="ReadKey" ${rightsChoice==='ReadKey'?'selected':''}>{{ t('registry.rights.read') }}</option>
<option value="WriteKey" ${rightsChoice==='WriteKey'?'selected':''}>{{ t('registry.rights.change') }}</option>
<option value="FullControl" ${rightsChoice==='FullControl'?'selected':''}>{{ t('registry.rights.full') }}</option>
<option value="custom" ${rightsChoice==='custom'?'selected':''}>{{ t('registry.custom') }}</option>
</select>
</label>
<label class="registry-custom-rights">{{ t('registry.rights_custom') }}<input data-rights-custom list="registry-rights-list" value="${esc(rightsChoice==='custom'?rightsValue:'')}"></label>
<input type="hidden" data-r="rights" value="${esc(rightsValue)}">
<label>{{ t('registry.access_type') }}<select data-r="access_type"><option value="Allow" ${p.access_type!=='Deny'?'selected':''}>Allow</option><option value="Deny" ${p.access_type==='Deny'?'selected':''}>Deny</option></select></label>
<div class="registry-checkbox-stack">
<label class="checkbox-label"><input type="checkbox" data-r="inherit_subkeys" ${p.inherit_subkeys!==false?'checked':''}> {{ t('registry.inherit_subkeys') }}</label>
<label class="checkbox-label"><input type="checkbox" data-r="replace_existing" ${p.replace_existing!==false?'checked':''}> {{ t('registry.replace_existing') }}</label>
</div>
</div>
<button type="button" class="button button-danger" data-remove>{{ t('common.delete') }}</button>`;
const modeInputs=[...d.querySelectorAll(`input[name="${id}"]`)];
const actionField=d.querySelector('[data-r="action"]');
const setValueField=d.querySelector('[data-r="set_value"]');
const permissionsField=d.querySelector('[data-r="permissions_enabled"]');
const valueName=d.querySelector('[data-r="name"]');
const deleteName=d.querySelector('[data-r-display="delete-name"]');
const principalMap={builtin_users:'S-1-5-32-545',builtin_admins:'S-1-5-32-544',system:'S-1-5-18',authenticated_users:'S-1-5-11',everyone:'S-1-1-0'};
const syncPrincipal=()=>{const c=d.querySelector('[data-principal-choice]').value;const custom=d.querySelector('[data-principal-custom]');d.querySelector('.registry-custom-principal').hidden=c!=='custom';d.querySelector('[data-r="principal"]').value=c==='custom'?custom.value:(principalMap[c]||'')};
const syncRights=()=>{const c=d.querySelector('[data-rights-choice]').value;const custom=d.querySelector('[data-rights-custom]');d.querySelector('.registry-custom-rights').hidden=c!=='custom';d.querySelector('[data-r="rights"]').value=c==='custom'?custom.value:c};
const syncRegistryFields=()=>{const m=modeInputs.find(x=>x.checked)?.value||'set';actionField.value=(m==='set'||m==='permissions')?'set':m;setValueField.value=m==='set'?'true':'false';permissionsField.value=m==='permissions'?'true':'false';const sections={set:d.querySelector('.registry-set-fields'),delete_value:d.querySelector('.registry-delete-value-fields'),permissions:d.querySelector('.registry-permission-fields')};Object.entries(sections).forEach(([key,section])=>{const active=m===key;section.hidden=!active;section.setAttribute('aria-hidden',active?'false':'true');section.querySelectorAll('input,select,textarea').forEach(control=>control.disabled=!active)});if(m==='delete_value') deleteName.value=valueName.value; else if(deleteName.value) valueName.value=deleteName.value};
modeInputs.forEach(x=>x.addEventListener('change',syncRegistryFields));
deleteName.addEventListener('input',()=>valueName.value=deleteName.value);
d.querySelector('[data-principal-choice]').addEventListener('change',syncPrincipal);d.querySelector('[data-principal-custom]').addEventListener('input',syncPrincipal);
d.querySelector('[data-rights-choice]').addEventListener('change',syncRights);d.querySelector('[data-rights-custom]').addEventListener('input',syncRights);
syncPrincipal();syncRights();syncRegistryFields();
d.querySelector('[data-remove]').onclick=()=>d.remove();registryRows.appendChild(d)
}
registryInitial.forEach(addRegistryRow);document.getElementById('add-registry-entry')?.addEventListener('click',()=>addRegistryRow({action:'set',hive:'HKLM',value_type:'REG_SZ'}));
document.getElementById('job-definition-form')?.addEventListener('submit',()=>{hidden.value=JSON.stringify([...rows.children].map(r=>{const o={};r.querySelectorAll('[data-p]').forEach(x=>o[x.dataset.p]=x.type==='checkbox'?x.checked:x.value);return o}));registryHidden.value=JSON.stringify([...registryRows.children].map(r=>{const o={};r.querySelectorAll('[data-r]').forEach(x=>{let v=x.type==='checkbox'?x.checked:x.value;if((x.dataset.r==='set_value'||x.dataset.r==='permissions_enabled')&&typeof v==='string')v=v==='true';o[x.dataset.r]=v});return o}))});
document.getElementById('copy-job-preview')?.addEventListener('click',async()=>{await navigator.clipboard.writeText(document.getElementById('job-script-preview')?.textContent||'')});
})();
</script>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('jobdefs.title') }}</h1>
<p class="muted">{{ t('jobdefs.description') }}</p>
</div>
<a class="button button-save" href="/job-definitions/new">{{ t('jobdefs.new') }}</a>
</div>
<section class="panel">
<form method="get" action="/job-definitions" class="jobdefs-filter-form">
<label class="jobdefs-filter-search">
<span>{{ t('jobdefs.filter.search') }}</span>
<input type="search" name="q" value="{{ search_text }}" placeholder="{{ t('jobdefs.filter.search_placeholder') }}">
</label>
<input type="hidden" id="jobdefs-active-only-value" name="active_only" value="{{ '1' if active_only else '0' }}">
<label class="jobdefs-active-filter">
<input id="jobdefs-active-only" type="checkbox" {% if active_only %}checked{% endif %} onchange="document.getElementById('jobdefs-active-only-value').value=this.checked?'1':'0'">
<span>{{ t('jobdefs.filter.active_only') }}</span>
</label>
<button class="button button-secondary" type="submit">{{ t('jobdefs.filter.apply') }}</button>
{% if search_text or not active_only %}<a class="button button-secondary" href="/job-definitions">{{ t('jobdefs.filter.reset') }}</a>{% endif %}
</form>
</section>
<div class="panel table-panel">
<table class="data-table">
<thead>
<tr>
<th>{{ t('jobdefs.status') }}</th>
<th>{{ t('common.name') }}</th>
<th>{{ t('jobdefs.platform') }}</th>
<th>{{ t('jobdefs.interpreter') }}</th>
<th>{{ t('jobdefs.source') }}</th>
<th>{{ t('jobdefs.revision') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
{% for row in definition_rows %}
{% set item = row.item %}
<tr>
<td>
<div class="jobdefs-status-list">
<span class="software-settings-badge">{{ t('jobdefs.status.active') if item.enabled else t('jobdefs.status.inactive') }}</span>
{% if item.is_system %}<span class="software-settings-badge">{{ t('jobdefs.system') }}</span>{% endif %}
</div>
</td>
<td><strong>{{ item.name }}</strong><div class="muted">{{ item.description or '' }}</div></td>
<td>{{ t('jobdefs.platform.' ~ item.platform, item.platform) }}</td>
<td>{{ t('jobdefs.interpreter.' ~ item.interpreter, item.interpreter) }}</td>
<td>{{ t('jobdefs.source.' ~ item.source_type, item.source_type) }}</td>
<td>{{ item.revision }}</td>
<td>
<div class="table-actions">
<a class="button button-secondary" href="/job-definitions/{{ item.id }}/edit">{{ t('common.edit') }}</a>
{% if row.can_delete %}
<form method="post" action="/job-definitions/{{ item.id }}/delete" class="inline-form" onsubmit="return confirm({{ t('jobdefs.delete.confirm')|tojson }});">
<button class="button button-danger" type="submit">{{ t('common.delete') }}</button>
</form>
{% else %}
<button class="button button-danger" type="button" disabled title="{{ t('jobdefs.delete.system_forbidden') if row.delete_reason == 'system' else t('jobdefs.delete.referenced') }}">{{ t('common.delete') }}</button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td colspan="7">{{ t('jobdefs.none_filtered') if search_text or active_only else t('jobdefs.none') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<section class="panel"><h2>{{ t('jobdefs.delete.behavior_title') }}</h2><p>{{ t('jobdefs.delete.behavior') }}</p></section>
<style>
.jobdefs-filter-form{display:flex;align-items:end;gap:1rem;flex-wrap:wrap}
.jobdefs-filter-search{display:grid;gap:.35rem;min-width:min(26rem,100%);flex:1}
.jobdefs-active-filter{display:flex;align-items:center;gap:.5rem;min-height:2.5rem;white-space:nowrap}
.jobdefs-status-list{display:flex;gap:.35rem;flex-wrap:wrap}
</style>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
echo "AssetManager test job"
echo "JobId={{JobId}}"
echo "AssetName={{AssetName}}"
echo "Hostname={{Hostname}}"
echo "IPAddress={{IPAddress}}"
echo "Department={{Department}}"
echo "Location={{Location}}"
echo "MeshNodeId={{MeshNodeId}}"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CALLBACK_URL='{{CallbackUrl}}'
JOB_ID='{{JobId}}'
callback() {
local status="$1" code="$2" message="$3" attempt
for attempt in 1 2 3; do
if curl -fsS --max-time 60 -X POST -H 'Content-Type: application/json' --data "{\"status\":\"${status}\",\"exit_code\":${code},\"completed\":true,\"message\":\"${message}\"}" "$CALLBACK_URL" >/dev/null; then
return 0
fi
echo "Callback attempt ${attempt} of 3 failed" >&2
if [ "$attempt" -lt 3 ]; then sleep $((attempt * 2)); fi
done
return 1
}
trap 'code=$?; callback failed "$code" "Job failed" || true; exit "$code"' ERR
echo "$(date --iso-8601=seconds) job=${JOB_ID} started"
# --- Eigene Befehle hier einfügen ---
# --- Ende eigene Befehle ---
callback success 0 'Job completed'
+8
View File
@@ -0,0 +1,8 @@
echo AssetManager test job
echo JobId={{JobId}}
echo AssetName={{AssetName}}
echo Hostname={{Hostname}}
echo IPAddress={{IPAddress}}
echo Department={{Department}}
echo Location={{Location}}
echo MeshNodeId={{MeshNodeId}}
+15
View File
@@ -0,0 +1,15 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "JOB_ID={{JobId}}"
set "CALLBACK_URL={{CallbackUrl}}"
rem --- Eigene Befehle hier einfügen ---
rem --- Ende eigene Befehle ---
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" goto :failed
powershell -NoProfile -ExecutionPolicy Bypass -Command "$body=@{status='success';exit_code=0;completed=$true;message='Job completed'}|ConvertTo-Json -Compress; $last=$null; for($attempt=1;$attempt -le 3;$attempt++){try{Invoke-RestMethod -Method Post -Uri '%CALLBACK_URL%' -ContentType 'application/json' -Body $body -TimeoutSec 60|Out-Null; exit 0}catch{$last=$_.Exception; if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}}}; Write-Error ('Callback failed after 3 attempts: '+$last.Message); exit 1"
if errorlevel 1 exit /b 1
exit /b 0
:failed
powershell -NoProfile -ExecutionPolicy Bypass -Command "$body=@{status='failed';exit_code=%EXIT_CODE%;completed=$true;message='Job failed'}|ConvertTo-Json -Compress; for($attempt=1;$attempt -le 3;$attempt++){try{Invoke-RestMethod -Method Post -Uri '%CALLBACK_URL%' -ContentType 'application/json' -Body $body -TimeoutSec 60|Out-Null; break}catch{if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}}}"
exit /b %EXIT_CODE%
+8
View File
@@ -0,0 +1,8 @@
Write-JobLog "AssetManager test job"
Write-JobLog "JobId={{JobId}}"
Write-JobLog "AssetName={{AssetName}}"
Write-JobLog "Hostname={{Hostname}}"
Write-JobLog "IPAddress={{IPAddress}}"
Write-JobLog "Department={{Department}}"
Write-JobLog "Location={{Location}}"
Write-JobLog "MeshNodeId={{MeshNodeId}}"
+45
View File
@@ -0,0 +1,45 @@
$ErrorActionPreference = 'Stop'
$log = Join-Path $env:ProgramData 'AssetManager\commands.log'
$clientLog = [System.Collections.Generic.List[string]]::new()
$JobResult = @{}
New-Item -ItemType Directory -Force -Path (Split-Path $log) | Out-Null
function Send-JobCallback([hashtable]$Data) {
$json = $Data | ConvertTo-Json -Depth 8 -Compress
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($json)
$lastError = $null
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-RestMethod -Method Post -Uri '{{CallbackUrl}}' -ContentType 'application/json; charset=utf-8' -Body $bodyBytes -TimeoutSec 60 | Out-Null
if ($attempt -gt 1) { Write-JobLog ("Callback accepted on attempt $attempt of 3") }
return
} catch {
$lastError = $_.Exception
Write-JobLog ("Callback attempt $attempt of 3 failed: " + $_.Exception.Message)
if ($attempt -lt 3) { Start-Sleep -Seconds (2 * $attempt) }
}
}
throw ("Callback failed after 3 attempts: " + $lastError.Message)
}
function Write-JobLog([string]$Message) {
$line = "$(Get-Date -Format o) job={{JobId}} $Message"
$clientLog.Add($line)
Add-Content -Path $log -Value $line -Encoding UTF8
Write-Output $line
}
try {
Write-JobLog 'Job started'
try { Send-JobCallback @{status='running'; completed=$false; message='Job started on client'; log=($clientLog -join "`n")} } catch { Write-JobLog ("Initial callback failed: " + $_.Exception.Message) }
# --- Eigene Befehle hier einfügen ---
# --- Ende eigene Befehle ---
Write-JobLog 'Job completed'
$bodyData = @{status='success'; exit_code=0; completed=$true; message='Job completed'; log=($clientLog -join "`n")}
if ($JobResult -is [System.Collections.IDictionary]) { foreach ($key in $JobResult.Keys) { $bodyData[$key] = $JobResult[$key] } }
$body = $bodyData | ConvertTo-Json -Depth 8 -Compress
Send-JobCallback $bodyData
} catch {
Write-JobLog ("Job failed: " + $_.Exception.Message)
$body = @{status='failed'; exit_code=1; completed=$true; message=$_.Exception.Message; log=($clientLog -join "`n"); stderr=($_ | Out-String)} | ConvertTo-Json -Compress
try { Send-JobCallback @{status='failed'; exit_code=1; completed=$true; message=$_.Exception.Message; log=($clientLog -join "`n"); stderr=($_ | Out-String)} } catch {}
exit 1
}
+8
View File
@@ -0,0 +1,8 @@
print("AssetManager test job")
print("JobId={{JobId}}")
print("AssetName={{AssetName}}")
print("Hostname={{Hostname}}")
print("IPAddress={{IPAddress}}")
print("Department={{Department}}")
print("Location={{Location}}")
print("MeshNodeId={{MeshNodeId}}")
+45
View File
@@ -0,0 +1,45 @@
import datetime
import json
import time
import traceback
import urllib.request
def callback(payload):
data = json.dumps(payload).encode("utf-8")
last_error = None
for attempt in range(1, 4):
request = urllib.request.Request(
"{{CallbackUrl}}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
urllib.request.urlopen(request, timeout=60).read()
return
except Exception as exc:
last_error = exc
print(f"Callback attempt {attempt} of 3 failed: {exc}")
if attempt < 3:
time.sleep(2 * attempt)
raise RuntimeError(f"Callback failed after 3 attempts: {last_error}")
job_result = {}
try:
print(datetime.datetime.now(datetime.timezone.utc).isoformat(), "job={{JobId}} started")
# --- Eigene Befehle hier einfügen ---
# --- Ende eigene Befehle ---
payload = {"status": "success", "exit_code": 0, "completed": True, "message": "Job completed"}
if isinstance(job_result, dict):
payload.update(job_result)
callback(payload)
except Exception as exc:
try:
callback({"status": "failed", "exit_code": 1, "completed": True, "message": str(exc), "stderr": traceback.format_exc()})
finally:
raise
+65
View File
@@ -0,0 +1,65 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('ldap.title') }}</h1>
<p class="muted">{{ t('ldap.description') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/users">{{ t('ldap.back_to_users') }}</a>
</div>
</div>
<form method="post" action="/settings/ldap-search" class="panel">
<label>{{ t('ldap.search_label') }}
<input type="text" name="ldap_query" value="{{ query }}" placeholder="{{ t('ldap.search_placeholder') }}">
</label>
<div class="toolbar-actions">
<button type="submit">🔎 {{ t('ldap.query') }}</button>
<a class="button button-secondary" href="/settings/ldap-search?show_all=1">{{ t('ldap.show_all') }}</a>
</div>
<p class="muted">{{ t('ldap.search_help') }}</p>
</form>
{% if loaded %}
<form method="post" action="/settings/ldap-search/import" class="panel">
<div class="toolbar">
<h2>{{ t('ldap.users_found', count=results|length) }}</h2>
<div class="toolbar-actions">
<button type="submit">+ {{ t('ldap.save_selected') }}</button>
</div>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr>
<th class="selection-column" data-no-filter="1"><input type="checkbox" id="select-all-ldap"></th>
<th>{{ t('ldap.username') }}</th>
<th>{{ t('ldap.display_name') }}</th>
<th>{{ t('ldap.email') }}</th>
<th>{{ t('ldap.status') }}</th>
<th>{{ t('ldap.dn') }}</th>
</tr></thead>
<tbody>
{% for item in results %}
<tr>
<td class="selection-column"><input type="checkbox" class="ldap-select" name="usernames" value="{{ item.username }}" {% if item.existing and item.existing_source != 'ldap' %}disabled{% endif %}></td>
<td>{{ item.username }}</td>
<td>{{ item.display_name }}</td>
<td>{{ item.email }}</td>
<td>{% if item.existing %}{{ t('ldap.exists') }}{% else %}{{ t('ldap.can_save') }}{% endif %}</td>
<td>{{ item.dn }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
<script>
(() => {
const all = document.getElementById('select-all-ldap');
const boxes = [...document.querySelectorAll('.ldap-select:not(:disabled)')];
all?.addEventListener('change', () => boxes.forEach(box => box.checked = all.checked));
})();
</script>
{% endif %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<section class="auth-card">
<h1>{{ t('login.title') }}</h1>
<form method="post" action="/login">
<label>{{ t('login.username') }}<input name="username" autocomplete="username" required autofocus></label>
<label>{{ t('login.password') }}<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">🔐 {{ t('login.submit') }}</button>
</form>
</section>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{t('mesh.title')}}</h1><button id="sync-start">{{t('mesh.start')}}</button></div>
<div class="cards"><div class="card"><strong>{{linked}}</strong><span>{{t('mesh.linked_assets')}}</span></div><div class="card"><strong>{{conflicts}}</strong><span>{{t('mesh.conflicts')}}</span></div><div class="card"><strong>{{missing}}</strong><span>{{t('mesh.missing')}}</span></div></div>
<section class="panel"><div class="log-header"><h2>{{t('mesh.live_log')}}</h2><span id="sync-state" class="sync-status">{{t('mesh.ready')}}</span></div><div id="sync-log" class="sync-log"><div class="log-line log-info">{{t('mesh.ready_message')}}</div></div></section>
<section class="panel">
<h2>1. {{ t('mesh.connection') }}</h2>
<p class="muted">{{ t('mesh.password_env_help') }}</p>
<form method="post" action="/sync/meshcentral/settings" class="compact-form">
<div class="form-grid">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="enabled" {% if config.meshcentral.enabled %}checked{% endif %}> {{ t('common.active') }}</label>
<label>WebSocket-URL<input name="url" value="{{config.meshcentral.url or ''}}" placeholder="wss://meshcentral.example.org:443"></label>
<label>{{ t('mesh.username') }}<input name="username" value="{{config.meshcentral.username or ''}}"></label>
<label>{{ t('mesh.password_env') }}<input name="password_env" value="{{config.meshcentral.password_env or 'MESHCENTRAL_PASSWORD'}}"></label>
<label>Tenant<input name="tenant" value="{{config.meshcentral.tenant or ''}}"></label>
<label>meshctrl.js-Pfad<input name="meshctrl_path" value="{{config.meshcentral.meshctrl_path}}"></label>
<label>{{ t('mesh.timeout_seconds') }}<input type="number" min="10" max="3600" name="timeout_seconds" value="{{config.meshcentral.timeout_seconds or 600}}"></label>
</div>
<div class="checkbox-row">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="verify_tls" {% if config.meshcentral.verify_tls %}checked{% endif %}> {{ t('mesh.verify_tls') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="include_details" {% if config.meshcentral.get('include_details',true) %}checked{% endif %}> {{ t('mesh.include_details') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="create_missing_assets" {% if config.meshcentral.create_missing_assets %}checked{% endif %}> {{ t('mesh.create_missing_assets') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="mark_missing_devices" {% if config.meshcentral.mark_missing_devices %}checked{% endif %}> {{ t('mesh.mark_missing_devices') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="store_source_json" {% if config.meshcentral.get('store_source_json',true) %}checked{% endif %}> {{ t('mesh.store_source_json') }}</label>
</div>
<h3>{{ t('mesh.asset_matching') }}</h3><div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="node_id" {% if 'node_id' in config.meshcentral.match_order %}checked{% endif %}> Node-ID</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="serial_number" {% if 'serial_number' in config.meshcentral.match_order %}checked{% endif %}> Seriennummer</label></div>
<button class="button button-save">💾 {{ t('mesh.save_connection') }}</button>
</form>
</section>
<section class="panel">
<h2>2. {{ t('mesh.field_mappings') }}</h2>
<p class="muted">{{ t('mesh.mappings_help') }} {{ t('mesh.mappings_examples') }}: <code>node.osdesc</code>, <code>sys.hardware.windows.cpu[].Name</code>, <code>node.name|name</code>.</p>
<form method="post" action="/sync/meshcentral/mappings/save">
<div class="table-scroll management-table-scroll"><table class="data-table mesh-mapping-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('mesh.priority') }}</th><th>{{ t('mesh.target_field') }}</th><th>Source Path</th><th>{{ t('mesh.transformation') }}</th><th>{{ t('mesh.multi_values') }}</th><th>{{ t('mesh.separator') }}</th><th>{{ t('mesh.update_rule') }}</th><th>{{ t('common.description') }}</th><th></th></tr></thead><tbody>
{% for m in mappings %}
<tr>
<td><input type="checkbox" name="mapping_{{m.id}}_enabled" {% if m.enabled %}checked{% endif %}></td>
<td><input class="mapping-priority" type="number" name="mapping_{{m.id}}_priority" value="{{m.priority}}"></td>
<td data-filter-value="{% for d in definitions %}{% if d.field_name==m.target_field_name %}{{ d.label|e }} {{ d.field_name|e }} {{ 'System' if d.is_system else 'Custom' }}{% endif %}{% endfor %}"><select name="mapping_{{m.id}}_target_field_name">{% for d in definitions %}<option value="{{d.field_name}}" {% if d.field_name==m.target_field_name %}selected{% endif %}>{{d.label}} ({{d.field_name}}) – {{'System' if d.is_system else 'Custom'}}</option>{% endfor %}</select>{% if m.is_system %}<small class="muted">{{ t('mesh.system_mapping') }}</small>{% else %}<small class="muted">{{ t('mesh.custom_mapping') }}</small>{% endif %}</td>
<td><input class="mapping-path" name="mapping_{{m.id}}_source_path" value="{{m.source_path}}" required></td>
<td data-filter-value="{{ m.transform|e }}"><select name="mapping_{{m.id}}_transform">{% for v in transform_options %}<option value="{{v}}" {% if m.transform==v %}selected{% endif %}>{{v}}</option>{% endfor %}</select></td>
<td><select name="mapping_{{m.id}}_multi_value_mode">{% for v in ['first','join','json','count'] %}<option value="{{v}}" {% if m.multi_value_mode==v %}selected{% endif %}>{{v}}</option>{% endfor %}</select></td>
<td><input class="mapping-separator" name="mapping_{{m.id}}_separator" value="{{m.separator|replace('\n','\\n')|replace('\t','\\t')}}"></td>
<td><select name="mapping_{{m.id}}_update_rule"><option value="fill_empty" {% if m.update_rule=='fill_empty' %}selected{% endif %}>{{ t('mesh.rule_fill_empty') }}</option><option value="meshcentral_wins" {% if m.update_rule=='meshcentral_wins' %}selected{% endif %}>{{ t('mesh.rule_mesh_wins') }}</option><option value="local_wins" {% if m.update_rule=='local_wins' %}selected{% endif %}>{{ t('mesh.rule_local_wins') }}</option></select></td>
<td><input name="mapping_{{m.id}}_description" value="{{m.description or ''}}"></td>
<td>{% if not m.is_system %}<button class="button danger" type="submit" formaction="/sync/meshcentral/mappings/{{m.id}}/delete" formmethod="post" onclick="return confirm({{ t('mesh.delete_mapping_confirm')|tojson }})">{{ t('common.delete') }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody></table></div>
<button class="button button-save">💾 {{ t('mesh.save_mappings') }}</button>
</form>
</section>
<section class="panel">
<h2>3. {{ t('mesh.new_mapping') }}</h2>
<p class="muted">{{ t('mesh.new_mapping_help') }}</p>
<form method="post" action="/sync/meshcentral/mappings/new" class="compact-form">
<div class="form-grid">
<label>Zielfeld<select name="target_field_name" required><option value="">{{ t('common.select') }}</option>{% for d in definitions %}<option value="{{d.field_name}}">{{d.label}} ({{d.field_name}}) – {{'System' if d.is_system else 'Custom'}}</option>{% endfor %}</select></label>
<label>Source Path<input name="source_path" required placeholder="node.example.path"></label>
<label>Transformation<select name="transform">{% for v in transform_options %}<option value="{{v}}">{{v}}</option>{% endfor %}</select></label>
<label>Mehrfachwerte<select name="multi_value_mode"><option value="first">first</option><option value="join">join</option><option value="json">json</option><option value="count">count</option></select></label>
<label>Trennzeichen<input name="separator" value="\n"></label>
<label>Update-Regel<select name="update_rule"><option value="fill_empty">{{ t('mesh.rule_fill_empty') }}</option><option value="meshcentral_wins">{{ t('mesh.rule_mesh_wins') }}</option><option value="local_wins">{{ t('mesh.rule_local_wins') }}</option></select></label>
<label>Beschreibung<input name="description"></label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="enabled" checked> {{ t('common.active') }}</label>
</div><button class="button button-save">+ {{ t('mesh.create_mapping') }}</button>
</form>
</section>
<section class="panel"><h2>{{t('mesh.recent_runs')}}</h2><table><thead><tr><th>{{t('mesh.start_time')}}</th><th>{{t('assets.status')}}</th><th>{{t('mesh.found')}}</th><th>{{t('mesh.created')}}</th><th>{{t('mesh.changed')}}</th><th>{{t('mesh.conflicts')}}</th><th>{{t('mesh.errors')}}</th><th>{{t('mesh.message')}}</th></tr></thead><tbody>{% for run in runs %}<tr><td><time class="local-time" datetime="{{ utc_iso(run.started_at) }}" data-utc="{{ utc_iso(run.started_at) }}">{{ run.started_at }}</time></td><td>{{run.status}}</td><td>{{run.devices_found}}</td><td>{{run.created_count}}</td><td>{{run.updated_count}}</td><td>{{run.conflict_count}}</td><td>{{run.error_count}}</td><td>{{run.message or ''}}</td></tr>{% else %}<tr><td colspan="8">{{t('mesh.no_runs')}}</td></tr>{% endfor %}</tbody></table></section>
<script>(()=>{const button=document.getElementById('sync-start'),logBox=document.getElementById('sync-log'),state=document.getElementById('sync-state');let offset=0,timer=null;function addLog(e){const l=document.createElement('div');l.className=`log-line log-${e.level||'info'}`;const localTime=window.AssetManagerTime?.formatUtc(e.time,e.time||'--')||(e.time||'--');l.textContent=`[${localTime}] ${e.message}`;logBox.appendChild(l);logBox.scrollTop=logBox.scrollHeight}async function jsonResponse(response,fallback){const text=await response.text();let data;try{data=JSON.parse(text)}catch(_){throw new Error(`${fallback} (HTTP ${response.status}): ${text.slice(0,300)||'{{ t('mesh.empty_response') }}'}`)}if(!response.ok)throw new Error(data.error||data.detail||fallback);return data}async function poll(id){try{const r=await fetch(`/sync/meshcentral/jobs/${id}?offset=${offset}`,{cache:'no-store'}),d=await jsonResponse(r,'{{ t('mesh.log_load_error') }}');d.logs.forEach(addLog);offset=d.next_offset;state.textContent=d.status;if(d.finished){clearInterval(timer);button.disabled=false}}catch(e){clearInterval(timer);button.disabled=false;addLog({level:'error',message:e.message})}}button.onclick=async()=>{button.disabled=true;logBox.innerHTML='';offset=0;try{const r=await fetch('/sync/meshcentral/start',{method:'POST'}),d=await jsonResponse(r,'{{ t('mesh.start_error') }}');await poll(d.job_id);timer=setInterval(()=>poll(d.job_id),800)}catch(e){addLog({level:'error',message:e.message});button.disabled=false}}})();</script>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block content %}
<style>
.profile-layout{display:grid;grid-template-columns:minmax(420px,720px) minmax(320px,460px);gap:1.25rem;align-items:start;max-width:1100px;margin:0}
.profile-layout>.panel,.profile-settings,.profile-settings .auth-card{width:100%;max-width:none;margin:0;box-sizing:border-box}
.profile-settings{display:grid;gap:1.25rem;align-content:start}.profile-settings form{min-width:0}
@media(max-width:900px){.profile-layout{grid-template-columns:1fr;max-width:760px;margin:0}.profile-layout dl{grid-template-columns:minmax(150px,220px) 1fr}}
@media(max-width:600px){.profile-layout dl{grid-template-columns:1fr}.profile-layout dt{border-bottom:0;padding-bottom:2px}.profile-layout dd{padding-top:2px}}
</style>
<div class="toolbar">
<h1>{{ t('profile.title') }}</h1>
<a class="button button-secondary" href="/logout">{{ t('app.logout') }}</a>
</div>
<div class="profile-layout">
<div class="panel profile-panel">
<dl>
<dt>{{ t('profile.username') }}</dt><dd>{{ user.username }}</dd>
<dt>{{ t('profile.display_name') }}</dt><dd>{{ user.display_name or '' }}</dd>
<dt>{{ t('profile.email') }}</dt><dd>{{ user.email or '' }}</dd>
<dt>{{ t('profile.source') }}</dt><dd>{{ user.auth_source }}</dd>
<dt>{{ t('profile.last_login') }}</dt><dd>{% if user.last_login %}<time class="local-time" datetime="{{ utc_iso(user.last_login) }}" data-utc="{{ utc_iso(user.last_login) }}">{{ user.last_login }}</time>{% else %}{{ '' }}{% endif %}</dd>
</dl>
</div>
<div class="profile-settings">
<form method="post" action="/profile/language" class="auth-card">
<h2>{{ t('common.language') }}</h2>
<input type="hidden" name="next_url" value="/profile">
<label>
{{ t('common.language') }}
<select name="language_code">
{% for language in languages %}
<option value="{{ language.code }}" {% if language.code == user.language_code %}selected{% endif %}>{{ language.native_name }}</option>
{% endfor %}
</select>
</label>
<button type="submit">🌐 {{ t('profile.save_language') }}</button>
</form>
<form method="post" action="/profile/theme" class="auth-card">
<h2>{{ t('appearance.theme') }}</h2>
<p class="table-hint">{{ t('appearance.profile_help') }}</p>
<input type="hidden" name="next_url" value="/profile">
<div class="theme-mode-choice">
<label class="theme-choice-card">
<input type="radio" name="theme_mode" value="light" {% if user.theme_mode != 'dark' %}checked{% endif %}>
<span class="theme-choice-preview theme-choice-preview-light" aria-hidden="true"><span></span><span></span></span>
<strong>☀ {{ t('appearance.light') }}</strong>
</label>
<label class="theme-choice-card">
<input type="radio" name="theme_mode" value="dark" {% if user.theme_mode == 'dark' %}checked{% endif %}>
<span class="theme-choice-preview theme-choice-preview-dark" aria-hidden="true"><span></span><span></span></span>
<strong>☾ {{ t('appearance.dark') }}</strong>
</label>
</div>
<button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('common.save') }}</button>
</form>
<form method="post" action="/profile/browser-state" class="auth-card"><h2>{{ t('profile.browser_storage') }}</h2><p class="table-hint">{{ t('profile.browser_storage_help') }}</p><label class="checkbox-label"><input type="checkbox" name="persist_browser_state" {% if user.persist_browser_state %}checked{% endif %}>{{ t('profile.browser_storage_enable') }}</label><button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('profile.save_browser_storage') }}</button></form>
<form method="post" action="/profile/toast-settings" class="auth-card">
<h2>{{ t('profile.toast_settings') }}</h2>
<label>{{ t('profile.toast_position') }}
<select name="toast_position">
{% for value, label_key in [('top-left','profile.toast_top_left'),('top-center','profile.toast_top_center'),('top-right','profile.toast_top_right'),('bottom-left','profile.toast_bottom_left'),('bottom-center','profile.toast_bottom_center'),('bottom-right','profile.toast_bottom_right')] %}
<option value="{{ value }}" {% if user.toast_position == value %}selected{% endif %}>{{ t(label_key) }}</option>
{% endfor %}
</select>
</label>
<label>{{ t('profile.toast_duration') }}
<input type="number" name="toast_duration_seconds" min="1" max="120" step="1" value="{{ user.toast_duration_seconds or 6 }}" required>
</label>
<button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('common.save') }}</button>
</form>
{% if user.auth_source == 'local' %}
<form method="post" action="/profile/password" class="auth-card">
<h2>{{ t('profile.change_password') }}</h2>
<label>{{ t('profile.current_password') }}<input type="password" name="current_password" required></label>
<label>{{ t('profile.new_password') }}<input type="password" name="new_password" minlength="8" required></label>
<button type="submit">🔑 {{ t('profile.change_password') }}</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}
+260
View File
@@ -0,0 +1,260 @@
{% extends 'base.html' %}
{% block content %}
{% set appinfo = application_info() %}
<div class="toolbar"><h1>{{ t("app.settings") }}</h1></div>
<form method="post" action="/settings" data-active-settings-section="{{ settings_section }}" enctype="multipart/form-data" class="settings-form">
<section class="settings-section" data-settings-section="appearance">
<h2>{{ t("settings.appearance") }}</h2>
<label>{{ t("settings.application_title") }}<input type="text" name="title" value="{{ settings.general.title or 'AssetManager' }}" required></label>
<label>{{ t("settings.company_name") }}<input type="text" name="company_name" value="{{ settings.general.company_name or '' }}"></label>
<label>{{ t("settings.company_logo") }}<input type="file" name="logo" accept=".png,.jpg,.jpeg,.webp,.gif"></label>
{% if settings.general.logo %}<div class="logo-preview"><img src="{{ settings.general.logo }}" alt="{{ t('settings.current_logo') }}"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="remove_logo"> {{ t('settings.remove_logo') }}</label></div>{% endif %}
<label>{{ t('settings.favicon', 'Browser-Tab-Logo (Favicon)') }}
<input type="file" name="favicon" accept=".png,.jpg,.jpeg,.webp,.gif">
<small>{{ t('settings.favicon_hint', 'Empfohlen: quadratisches PNG, zum Beispiel 32 × 32 oder 64 × 64 Pixel.') }}</small>
</label>
{% if settings.general.favicon %}
<div class="logo-preview favicon-preview">
<img src="{{ settings.general.favicon }}" alt="{{ t('settings.current_favicon', 'Aktuelles Browser-Tab-Logo') }}">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="remove_favicon"> {{ t('settings.remove_favicon', 'Browser-Tab-Logo entfernen') }}</label>
</div>
{% endif %}
<label>{{ t("common.language") }}<select name="default_language">{% for language in languages %}<option value="{{ language.code }}" {% if language.code == settings.general.get("default_language", "en") %}selected{% endif %}>{{ language.native_name }}</option>{% endfor %}</select></label>
<label>{{ t('appearance.default_theme') }}
<select name="default_theme">
<option value="light" {% if settings.general.get('default_theme', 'light') == 'light' %}selected{% endif %}>{{ t('appearance.light') }}</option>
<option value="dark" {% if settings.general.get('default_theme', 'light') == 'dark' %}selected{% endif %}>{{ t('appearance.dark') }}</option>
</select>
</label>
</section>
{% set theme_colors = settings.general.get('colors', {}) %}
<section class="settings-section appearance-color-section" data-settings-section="appearance">
<div class="settings-section-heading">
<div><h2>{{ t('appearance.colors_title') }}</h2><p class="muted">{{ t('appearance.colors_help') }}</p></div>
<button type="button" class="button button-secondary" id="reset-theme-colors">{{ t('appearance.reset_colors') }}</button>
</div>
<div class="theme-color-layout">
<div class="theme-color-grid">
{% set color_fields = [
('primary_color','appearance.primary_color','#0878bd'),
('primary_hover_color','appearance.primary_hover_color','#075f94'),
('header_color','appearance.header_color','#063c66'),
('navigation_color','appearance.navigation_color','#ffffff'),
('page_background','appearance.page_background','#f2f4f6'),
('surface_color','appearance.surface_color','#ffffff'),
('text_color','appearance.text_color','#1f2933'),
('border_color','appearance.border_color','#d7dde3'),
('success_color','appearance.success_color','#198754'),
('warning_color','appearance.warning_color','#d97706'),
('danger_color','appearance.danger_color','#b42318')
] %}
{% for name, label_key, default in color_fields %}
<label class="theme-color-field">
<span>{{ t(label_key) }}</span>
<span class="theme-color-input">
<input type="color" name="{{ name }}" value="{{ theme_colors.get(name, default) }}" data-default-color="{{ default }}">
<code>{{ theme_colors.get(name, default) }}</code>
</span>
</label>
{% endfor %}
</div>
<div class="theme-live-preview" id="theme-live-preview">
<h3>{{ t('appearance.color_preview') }}</h3>
<div class="theme-preview-window">
<div class="theme-preview-header"></div>
<div class="theme-preview-body">
<div class="theme-preview-nav"></div>
<div class="theme-preview-content">
<div class="theme-preview-card"></div>
<div class="theme-preview-buttons"><i></i><i></i><i></i></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="settings-section" data-settings-section="system">
<h2>{{ t('settings.system_information') }}</h2>
<p class="muted">{{ t('settings.system_information_file_help') }}</p>
<dl class="system-info-grid">
<dt>{{ t('settings.appinfo_path') }}</dt><dd><code>/app/config/APPINFO.json</code></dd>
<dt>{{ t('about.version') }}</dt><dd>{{ application_version() }}</dd>
<dt>{{ t('about.author') }}</dt><dd>{{ appinfo.author or '—' }}</dd>
<dt>{{ t('about.organization') }}</dt><dd>{{ appinfo.organization or '—' }}</dd>
<dt>{{ t('about.contact') }}</dt><dd>{{ appinfo.contact or '—' }}</dd>
<dt>{{ t('about.website') }}</dt><dd>{% if appinfo.website %}<a href="{{ appinfo.website }}" target="_blank" rel="noopener">{{ appinfo.website }}</a>{% else %}—{% endif %}</dd>
<dt>{{ t('about.repository') }}</dt><dd>{% if appinfo.repository %}<a href="{{ appinfo.repository }}" target="_blank" rel="noopener">{{ appinfo.repository }}</a>{% else %}—{% endif %}</dd>
<dt>{{ t('about.license') }}</dt><dd>{{ appinfo.license or '—' }}</dd>
<dt>{{ t('about.copyright') }}</dt><dd>{{ appinfo.copyright or '—' }}</dd>
</dl>
<h3>{{ t('settings.storage_information', 'Speicher und Laufzeit') }}</h3>
<p class="muted">{{ t('settings.storage_information_help', 'Die folgenden Pfade und Zustände werden zur Laufzeit im Container ermittelt.') }}</p>
<dl class="system-info-grid">
<dt>{{ t('settings.database', 'Datenbank') }}</dt>
<dd><span class="status-badge status-success">✓ {{ t('settings.database_connected', 'PostgreSQL verbunden') }}</span></dd>
<dt>{{ t('settings.config_directory', 'Konfigurationsverzeichnis') }}</dt>
<dd><code>{{ system_storage.config.path }}</code></dd>
<dt>{{ t('settings.upload_directory', 'Uploadverzeichnis') }}</dt>
<dd><code>{{ system_storage.uploads.path }}</code></dd>
<dt>{{ t('settings.log_directory', 'Logverzeichnis') }}</dt>
<dd><code>{{ system_storage.logs.path }}</code></dd>
<dt>{{ t('settings.backup_directory', 'Backupverzeichnis') }}</dt>
<dd><code>{{ system_storage.backups.path }}</code></dd>
<dt>{{ t('settings.backup_writable', 'Backupverzeichnis beschreibbar') }}</dt>
<dd>
{% if system_storage.backups.writable %}
<span class="status-badge status-success">✓ {{ t('common.yes', 'Ja') }}</span>
{% else %}
<span class="status-badge status-danger">✕ {{ t('common.no', 'Nein') }}</span>
{% if system_storage.backups.error %}<br><small class="muted">{{ system_storage.backups.error }}</small>{% endif %}
{% endif %}
</dd>
<dt>{{ t('settings.backup_mount_detected', 'Eigenes Docker-Mount erkannt') }}</dt>
<dd>{% if system_storage.backups.is_mount %}✓ {{ t('common.yes', 'Ja') }}{% else %}— {{ t('common.no', 'Nein') }}{% endif %}</dd>
<dt>{{ t('settings.latest_backup', 'Letztes Backup') }}</dt>
<dd>
{% if system_storage.latest_backup %}
<time class="local-time" datetime="{{ system_storage.latest_backup.modified_at.isoformat() }}" data-utc="{{ system_storage.latest_backup.modified_at.isoformat() }}">{{ system_storage.latest_backup.modified_at.strftime('%Y-%m-%d %H:%M:%S') }}</time>
{% else %}—{% endif %}
</dd>
<dt>{{ t('settings.backup_count', 'Anzahl vorhandener Backups') }}</dt>
<dd>{{ system_storage.backup_count }}</dd>
<dt>{{ t('settings.backup_storage_used', 'Belegter Backup-Speicher') }}</dt>
<dd>{{ '%.2f'|format(system_storage.backup_total_size / 1024 / 1024) }} MB</dd>
</dl>
</section>
<section class="settings-section" data-settings-section="appearance">
<h2>{{ t('settings.custom_css') }}</h2>
<p class="muted">{{ t('settings.custom_css_help') }}</p>
<label for="custom-css-editor">{{ t('settings.custom_css') }}
<textarea id="custom-css-editor" name="custom_css" class="custom-css-editor" rows="18" spellcheck="false" placeholder="/* Beispiel */
.inventory-tab { color: #000; }">{{ settings.general.get('custom_css', '') }}</textarea>
</label>
</section>
<section class="settings-section" data-settings-section="meshcentral">
<h2>{{ t('settings.mesh_mapping') }}</h2>
{% set mesh = settings.meshcentral %}
{% set mapping = mesh.get('category_mapping', {}) %}
<p class="muted">{{ t('settings.mesh_mapping_help') }}</p>
<label>{{ t('settings.fallback_category') }}
<select name="mesh_fallback_category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if category.id == (mesh.get('fallback_category_id', 1)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
<div class="form-grid">
{% set mesh_types = [
('1', 'settings.mesh_type_desktop'),
('2', 'settings.mesh_type_notebook'),
('3', 'settings.mesh_type_mobile'),
('4', 'settings.mesh_type_server'),
('5', 'settings.mesh_type_5'),
('6', 'settings.mesh_type_6'),
('7', 'settings.mesh_type_7'),
('8', 'settings.mesh_type_linux_other')
] %}
{% for type_id, type_label in mesh_types %}
<label>{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})
<select name="mesh_category_{{ type_id }}">
<option value="">{{ t('settings.use_fallback') }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if mapping.get(type_id) is not none and category.id == (mapping.get(type_id)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
{% endfor %}
</div>
</section>
<section class="settings-section" data-settings-section="authentication">
<h2>{{ t('settings.authentication') }}</h2>
<p class="muted">{{ t('settings.auth_help') }}</p>
<label>{{ t('settings.auth_mode') }}
<select name="auth_mode" id="auth-mode">
<option value="none" {% if settings.authentication.mode == 'none' %}selected{% endif %}>{{ t('settings.auth_none') }}</option>
<option value="local" {% if settings.authentication.mode == 'local' %}selected{% endif %}>{{ t('settings.auth_local') }}</option>
<option value="ldap" {% if settings.authentication.mode == 'ldap' %}selected{% endif %}>LDAP / Active Directory</option>
</select>
</label>
{% set ldap = settings.authentication.get('ldap', {}) %}
<div id="ldap-settings" class="settings-subsection">
<h3>{{ t('settings.ldap_connection') }}</h3>
<div class="form-grid">
<label>{{ t('settings.ldap_server') }}<input type="text" name="ldap_server" value="{{ ldap.get('server', '') }}" placeholder="dc01.example.org"></label>
<label>{{ t('settings.port') }}<input type="number" name="ldap_port" value="{{ ldap.get('port', 389) }}" min="1" max="65535"></label>
</div>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="ldap_use_ssl" {% if ldap.get('use_ssl') %}checked{% endif %}> {{ t('settings.use_ssl') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="ldap_start_tls" {% if ldap.get('start_tls') %}checked{% endif %}> {{ t('settings.use_starttls') }}</label></div>
<label>{{ t('settings.bind_user') }}<input type="text" name="ldap_bind_dn" value="{{ ldap.get('bind_dn', '') }}" placeholder="svc_assetmanager@example.org"></label>
<label>{{ t('settings.bind_password_env') }}<input type="text" name="ldap_bind_password_env" value="{{ ldap.get('bind_password_env', 'LDAP_BIND_PASSWORD') }}"></label>
<label>{{ t('settings.user_base_dn') }}<input type="text" name="ldap_user_base_dn" value="{{ ldap.get('user_base_dn', '') }}" placeholder="DC=example,DC=org"></label>
<label>{{ t('settings.user_filter') }}<input type="text" name="ldap_user_filter" value="{{ ldap.get('user_filter', '(sAMAccountName={username})') }}"></label>
<div class="form-grid"><label>{{ t('settings.display_name_attribute') }}<input type="text" name="ldap_display_name_attribute" value="{{ ldap.get('display_name_attribute', 'displayName') }}"></label><label>{{ t('settings.email_attribute') }}<input type="text" name="ldap_email_attribute" value="{{ ldap.get('email_attribute', 'mail') }}"></label></div>
<p class="muted">{{ t('settings.bind_password_help') }}</p><p><a class="button button-secondary" href="/settings/ldap-search">🔎 {{ t('ldap.title') }}</a></p>
</div>
</section>
<button type="submit" class="button button-save settings-save-button">💾 {{ t("settings.save") }}</button>
</form>
<script>
(() => { const mode=document.getElementById('auth-mode'); const ldap=document.getElementById('ldap-settings'); const update=()=>ldap.hidden=mode.value!=='ldap'; mode.addEventListener('change',update); update(); })();
</script>
<script>
(() => {
const form = document.querySelector('[data-active-settings-section]');
if (!form) return;
const active = form.dataset.activeSettingsSection || 'appearance';
form.querySelectorAll('[data-settings-section]').forEach(section => {
section.hidden = section.dataset.settingsSection !== active;
});
const save = form.querySelector('.settings-save-button');
if (save) save.hidden = active === 'system';
})();
</script>
<script>
(() => {
const fields = [...document.querySelectorAll('.theme-color-field input[type="color"]')];
const reset = document.getElementById('reset-theme-colors');
const variableNames = {
primary_color: '--preview-primary',
primary_hover_color: '--preview-primary-hover',
header_color: '--preview-header',
navigation_color: '--preview-navigation',
page_background: '--preview-page-bg',
surface_color: '--preview-surface',
text_color: '--preview-text',
border_color: '--preview-border',
success_color: '--preview-success',
warning_color: '--preview-warning',
danger_color: '--preview-danger'
};
const preview = document.getElementById('theme-live-preview');
function refresh() {
fields.forEach(input => {
input.nextElementSibling.textContent = input.value.toUpperCase();
preview?.style.setProperty(variableNames[input.name], input.value);
});
}
fields.forEach(input => input.addEventListener('input', refresh));
reset?.addEventListener('click', () => {
fields.forEach(input => { input.value = input.dataset.defaultColor; });
refresh();
});
refresh();
})();
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block content %}
<div class="backup-page">
<div class="backup-page-header">
<div>
<h1>{{ t('backup.title', 'Backups') }}</h1>
<p class="muted">{{ t('backup.description', 'Create, download, upload and restore complete AssetManager backups.') }}</p>
</div>
<form method="post" action="/settings/backup/create" class="backup-create-form">
<button type="submit" class="button backup-primary-action">{{ t('backup.create_now', 'Create backup now') }}</button>
</form>
</div>
<div class="backup-summary-grid">
<section class="backup-summary-card">
<span class="backup-summary-icon" aria-hidden="true">📁</span>
<div><span class="backup-summary-label">{{ t('backup.directory', 'Backup directory') }}</span><strong><code>{{ backup_directory }}</code></strong></div>
</section>
<section class="backup-summary-card">
<span class="backup-summary-icon" aria-hidden="true">🕒</span>
<div><span class="backup-summary-label">{{ t('backup.interval', 'Interval') }}</span><strong>{{ t('backup.every_hours', 'Every {hours} hours', hours=backup_interval_hours) }}</strong></div>
</section>
<section class="backup-summary-card">
<span class="backup-summary-icon" aria-hidden="true">🗓️</span>
<div><span class="backup-summary-label">{{ t('backup.retention', 'Retention') }}</span><strong>{{ t('backup.days', '{days} days', days=backup_retention_days) }}</strong></div>
</section>
</div>
<section class="panel backup-panel">
<div class="backup-panel-heading">
<div><h2>{{ t('backup.automatic_title', 'Automatic backup') }}</h2><p class="muted">{{ t('backup.automatic_help', 'Backups are created automatically according to the configured interval and deleted after the retention period.') }}</p></div>
<span class="backup-status-badge">{{ t('backup.active', 'Active') }}</span>
</div>
<div class="backup-content-note"><span aria-hidden="true">ℹ️</span><p>{{ t('backup.contents_help', 'Each backup contains a PostgreSQL database dump, configuration, APPINFO and uploaded files. Environment variables and the .env file are not included.') }}</p></div>
</section>
<section class="panel backup-panel">
<div class="backup-panel-heading"><div><h2>{{ t('backup.upload_title', 'Upload backup') }}</h2><p class="muted">{{ t('backup.upload_help', 'Upload an AssetManager backup ZIP to make it available for download or restoration.') }}</p></div></div>
<form method="post" action="/settings/backup/upload" enctype="multipart/form-data" class="backup-upload-form">
<input type="file" name="backup_file" accept=".zip,application/zip" required>
<button type="submit" class="button-secondary">{{ t('backup.upload', 'Upload') }}</button>
</form>
</section>
<section class="panel backup-panel">
<div class="backup-panel-heading"><div><h2>{{ t('backup.available', 'Available backups') }}</h2><p class="muted">{{ t('backup.available_help', 'Stored backups can be downloaded, restored or deleted here.') }}</p></div><span class="backup-count">{{ backups|length }}</span></div>
{% if backups %}
<div class="table-scroll backup-table-wrap"><table class="data-table backup-table"><thead><tr>
<th>{{ t('backup.file', 'File') }}</th><th>{{ t('backup.created', 'Created') }}</th><th>{{ t('backup.version', 'Version') }}</th><th>{{ t('backup.reason', 'Reason') }}</th><th>{{ t('backup.size', 'Size') }}</th><th>{{ t('common.actions', 'Actions') }}</th>
</tr></thead><tbody>{% for backup in backups %}<tr>
<td><code>{{ backup.name }}</code>{% if not backup.valid %}<br><span class="muted">{{ t('backup.invalid_metadata', 'Metadata could not be read') }}</span>{% endif %}</td>
<td><time class="local-time" datetime="{{ backup.modified_at.isoformat() }}" data-utc="{{ backup.modified_at.isoformat() }}">{{ backup.modified_at.strftime('%Y-%m-%d %H:%M:%S') }}</time></td><td>{{ backup.version if backup.version else t('backup.version_unknown') }}</td><td>{{ t('backup.reason_' ~ (backup.reason or 'unknown'), backup.reason or '—') }}</td><td>{{ '%.2f'|format(backup.size / 1024 / 1024) }} MB</td>
<td><div class="backup-actions"><a class="button button-secondary" href="/settings/backup/{{ backup.name }}/download">{{ t('backup.download', 'Download') }}</a><button type="button" class="button-secondary" onclick="document.getElementById('restore-{{ loop.index }}').showModal()">{{ t('backup.restore', 'Restore') }}</button><form method="post" action="/settings/backup/{{ backup.name }}/delete" onsubmit="return confirm('{{ t('backup.delete_confirm', 'Delete this backup permanently?')|e }}')"><button type="submit" class="button-danger">{{ t('common.delete', 'Delete') }}</button></form></div>
<dialog id="restore-{{ loop.index }}" class="backup-dialog"><form method="post" action="/settings/backup/{{ backup.name }}/restore"><h2>{{ t('backup.restore_title', 'Restore backup') }}</h2><p>{{ t('backup.restore_warning', 'The current database, configuration and uploads will be replaced. A safety backup is created immediately before restoration.') }}</p><label>{{ t('backup.restore_confirmation', 'Enter RESTORE to confirm') }}<input name="confirmation" autocomplete="off" required></label><div class="form-actions"><button type="button" class="button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel', 'Cancel') }}</button><button type="submit" class="button-danger">{{ t('backup.restore_now', 'Restore now') }}</button></div></form></dialog></td>
</tr>{% endfor %}</tbody></table></div>
{% else %}<div class="backup-empty-state"><span aria-hidden="true">🗄️</span><strong>{{ t('backup.none_title', 'No backups yet') }}</strong><p class="muted">{{ t('backup.none', 'No backups are available yet.') }}</p></div>{% endif %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('settings.backup', 'Sicherung') }}</h1>
</div>
</div>
<section class="panel">
<h2>{{ t('settings.backup_planned', 'Datenbanksicherung ist für v0.3.15.0 vorgesehen') }}</h2>
<p>{{ t('settings.backup_planned_help', 'Die automatische Sicherung alle 8 Stunden, eine Aufbewahrung von 3 Tagen sowie Download, Upload, manuelles Backup und Wiederherstellung werden im nächsten Entwicklungsschritt umgesetzt.') }}</p>
<p><strong>{{ t('settings.backup_directory', 'Backup-Verzeichnis') }}:</strong> <code>/app/data/backups</code></p>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('settings.import_export', 'Import / Export') }}</h1>
<p class="muted">{{ t('settings.import_export_help', 'Assets als Excel-Datei exportieren oder aus einer Excel-Datei importieren.') }}</p>
</div>
</div>
<div class="settings-action-grid">
<section class="panel">
<h2>{{ t('assets.import', 'Import') }}</h2>
<p>{{ t('settings.import_help', 'Vorhandene Assets über die Asset-ID aktualisieren und neue Assets ohne Asset-ID anlegen.') }}</p>
<div class="form-actions">
<a class="button" href="/assets/import">{{ t('assets.import_open', 'Import öffnen') }}</a>
<a class="button button-secondary" href="/assets/import/template.xlsx">{{ t('assets.import_template', 'Importvorlage herunterladen') }}</a>
</div>
</section>
<section class="panel">
<h2>{{ t('assets.export', 'Export') }}</h2>
<p>{{ t('settings.export_help', 'Den aktuellen Assetbestand mit Asset-ID und allen exportierbaren Feldern herunterladen.') }}</p>
<div class="form-actions">
<a class="button" href="/export/assets.xlsx">{{ t('assets.export_download', 'Assets als Excel herunterladen') }}</a>
</div>
</section>
</div>
{% endblock %}
+341
View File
@@ -0,0 +1,341 @@
{% extends 'base.html' %}
{% block content %}
<div class="software-settings-page">
<div class="toolbar software-settings-header">
<div>
<h1>{{ t('software.settings.title') }}</h1>
<p class="muted">{{ t('software.settings.description') }}</p>
</div>
<a class="button button-secondary" href="/software">{{ t('software.settings.back_to_software') }}</a>
</div>
<form method="post" action="/settings/software" class="software-settings-form">
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.dispatch_title') }}</h2>
<p class="muted">{{ t('software.settings.dispatch_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.dispatch_badge') }}</span>
</div>
<div class="software-settings-grid">
<label>
{{ t('software.settings.dispatch_delay') }}
<input type="number" name="dispatch_delay_seconds" min="0" max="60" step="1" value="{{ software_settings.get('dispatch_delay_seconds', 2) }}">
<small>{{ t('software.settings.dispatch_delay_help') }}</small>
</label>
<div class="software-callback-preview">
<span>{{ t('software.settings.timeout_check') }}</span>
<code>{{ t('software.settings.timeout_check_value') }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.retry_title') }}</h2>
<p class="muted">{{ t('software.settings.retry_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.retry_badge') }}</span>
</div>
<div class="software-settings-grid">
<label class="software-toggle-option software-settings-wide">
<input type="checkbox" name="automatic_retry_enabled" {% if software_settings.get('automatic_retry_enabled', true) %}checked{% endif %}>
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
<span>
<strong>{{ t('software.settings.retry_enabled') }}</strong>
<small>{{ t('software.settings.retry_enabled_help') }}</small>
</span>
</label>
<label>
{{ t('software.settings.retry_max_age') }}
<input type="number" name="automatic_retry_max_age_hours" min="1" max="168" step="1" value="{{ software_settings.get('automatic_retry_max_age_hours', 12) }}">
<small>{{ t('software.settings.retry_max_age_help') }}</small>
</label>
<label>
{{ t('software.settings.retry_interval') }}
<input type="number" name="automatic_retry_interval_seconds" min="10" max="86400" step="1" value="{{ software_settings.get('automatic_retry_interval_seconds', 60) }}">
<small>{{ t('software.settings.retry_interval_help') }}</small>
</label>
<label>
{{ t('software.settings.retry_max_retries') }}
<input type="number" name="automatic_retry_max_retries" min="1" max="10" step="1" value="{{ software_settings.get('automatic_retry_max_retries', 3) }}">
<small>{{ t('software.settings.retry_max_retries_help') }}</small>
</label>
<div class="software-callback-preview">
<span>{{ t('software.settings.retry_statuses') }}</span>
<code>{{ t('software.settings.retry_statuses_value') }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.callback_title') }}</h2>
<p class="muted">{{ t('software.settings.callback_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.callback_badge') }}</span>
</div>
<div class="software-settings-grid">
<label class="software-settings-wide">
{{ t('software.settings.callback_base_url') }}
<input
type="url"
name="callback_base_url"
value="{{ software_settings.get('callback_base_url', '') }}"
placeholder="https://assetmanager.example.org"
autocomplete="url"
>
<small>{{ t('software.settings.callback_base_url_help') }}</small>
</label>
<label>
{{ t('software.settings.callback_timeout') }}
<input type="number" name="callback_timeout_minutes" min="5" max="240" value="{{ software_settings.get('callback_timeout_minutes', 30) }}">
<small>{{ t('software.settings.callback_timeout_help') }}</small>
</label>
<label>
{{ t('software.settings.callback_workers') }}
<input type="number" name="callback_worker_count" min="1" max="8" value="{{ software_settings.get('callback_worker_count', 4) }}">
<small>{{ t('software.settings.callback_workers_help') }}</small>
</label>
<label>
{{ t('software.settings.test_timeout') }}
<input type="number" name="callback_test_timeout_seconds" min="2" max="60" value="{{ software_settings.get('callback_test_timeout_seconds', 10) }}">
<small>{{ t('software.settings.test_timeout_help') }}</small>
</label>
<label class="software-toggle-option">
<input type="checkbox" name="callback_test_verify_tls" {% if software_settings.get('callback_test_verify_tls', true) %}checked{% endif %}>
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
<span>
<strong>{{ t('software.settings.verify_tls') }}</strong>
<small>{{ t('software.settings.verify_tls_help') }}</small>
</span>
</label>
<div class="software-callback-preview software-settings-wide">
<span>{{ t('software.settings.health_url') }}</span>
<code id="callback-health-preview">{{ callback_health_url or t('software.settings.automatic_url') }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.debug_title') }}</h2>
<p class="muted">{{ t('software.settings.debug_help') }}</p>
</div>
</div>
<div class="software-settings-grid">
<label class="software-toggle-option">
<input type="checkbox" name="debug_logging" {% if software_settings.get('debug_logging', true) %}checked{% endif %}>
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
<span>
<strong>{{ t('software.settings.debug_logging') }}</strong>
<small>{{ t('software.settings.debug_logging_help') }}</small>
</span>
</label>
<label>
{{ t('software.settings.debug_max_lines') }}
<input type="number" name="debug_log_max_lines" min="100" max="10000" value="{{ software_settings.get('debug_log_max_lines', 1000) }}">
<small>{{ t('software.settings.debug_max_lines_help') }}</small>
</label>
<div class="software-debug-path software-settings-wide">
<span>{{ t('software.settings.debug_log_path') }}</span>
<code>{{ debug_log_path }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.exclusions_title') }}</h2>
<p class="muted">{{ t('software.settings.exclusions_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.active') }}</span>
</div>
<div class="software-exclusion-list" id="software-exclusion-list">
{% for rule in software_settings.get('inventory_exclusion_rules', []) %}
<div class="software-exclusion-row">
<label>
<span>{{ t('software.settings.exclude_platform') }}</span>
<select name="inventory_exclusion_platform">
{% for platform in ['all', 'windows', 'linux', 'macos', 'unknown'] %}
<option value="{{ platform }}" {% if rule.get('platform', 'all') == platform %}selected{% endif %}>{{ t('software.platform.' ~ platform, platform) }}</option>
{% endfor %}
</select>
</label>
<label class="software-exclusion-name">
<span>{{ t('software.settings.exclude_name_contains') }}</span>
<input type="text" name="inventory_exclusion_name" maxlength="300" value="{{ rule.get('name_contains', '') }}" placeholder="{{ t('software.settings.exclude_name_placeholder') }}">
</label>
<button type="button" class="button button-secondary software-exclusion-remove" title="{{ t('software.settings.exclude_remove') }}" aria-label="{{ t('software.settings.exclude_remove') }}">{{ t('common.delete') }}</button>
</div>
{% endfor %}
</div>
<template id="software-exclusion-row-template">
<div class="software-exclusion-row">
<label>
<span>{{ t('software.settings.exclude_platform') }}</span>
<select name="inventory_exclusion_platform">
<option value="all">{{ t('software.platform.all', 'All platforms') }}</option>
<option value="windows">{{ t('software.platform.windows', 'Windows') }}</option>
<option value="linux">{{ t('software.platform.linux', 'Linux') }}</option>
<option value="macos">{{ t('software.platform.macos', 'macOS') }}</option>
<option value="unknown">{{ t('software.platform.unknown', 'Unknown') }}</option>
</select>
</label>
<label class="software-exclusion-name">
<span>{{ t('software.settings.exclude_name_contains') }}</span>
<input type="text" name="inventory_exclusion_name" maxlength="300" placeholder="{{ t('software.settings.exclude_name_placeholder') }}">
</label>
<button type="button" class="button button-secondary software-exclusion-remove" title="{{ t('software.settings.exclude_remove') }}" aria-label="{{ t('software.settings.exclude_remove') }}">{{ t('common.delete') }}</button>
</div>
</template>
<div class="software-exclusion-actions">
<button type="button" class="button button-secondary" id="software-exclusion-add">{{ t('software.settings.exclude_add') }}</button>
<small class="muted">{{ t('software.settings.exclude_match_help') }}</small>
</div>
</section>
<div class="form-actions software-settings-actions">
<button type="submit" class="button button-save" data-save-shortcut>{{ t('common.save') }}</button>
</div>
</form>
<section class="panel software-settings-section software-test-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.test_title') }}</h2>
<p class="muted">{{ t('software.settings.test_help') }}</p>
</div>
<form method="post" action="/settings/software/test-callback" class="inline-form">
<button type="submit" class="button">{{ t('software.settings.run_test') }}</button>
</form>
</div>
<div class="software-debug-toolbar">
<span class="muted">{{ t('software.settings.log_auto_refresh') }}</span>
<div class="software-debug-actions">
<button type="button" class="button button-secondary" id="copy-software-debug-log">{{ t('software.copy_log') }}</button>
<form method="post" action="/settings/software/debug-log/clear" class="inline-form">
<button type="submit" class="button button-secondary">{{ t('software.settings.clear_log') }}</button>
</form>
</div>
</div>
<pre class="sync-log software-debug-log" id="software-debug-log" data-localize-log>{{ debug_log or t('software.settings.no_log') }}</pre>
</section>
<section class="panel software-settings-section software-future-section">
<h2>{{ t('software.settings.future_title') }}</h2>
<p class="muted">{{ t('software.settings.future_help') }}</p>
<div class="software-future-grid">
<span>{{ t('software.settings.future_inventory') }}</span>
<span>{{ t('software.settings.future_retention') }}</span>
<span>{{ t('software.settings.future_packages') }}</span>
<span>{{ t('software.settings.future_security') }}</span>
</div>
</section>
</div>
<script>
async function copyTextRobust(text) {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
const area = document.createElement('textarea');
area.value = value;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.left = '-9999px';
area.style.top = '0';
area.style.opacity = '0';
document.body.appendChild(area);
area.focus();
area.select();
area.setSelectionRange(0, area.value.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch (_) {
copied = false;
}
area.remove();
return copied;
}
(() => {
const baseInput = document.querySelector('input[name="callback_base_url"]');
const preview = document.getElementById('callback-health-preview');
const automaticLabel = {{ t('software.settings.automatic_url')|tojson }};
function updatePreview() {
const base = (baseInput?.value || '').trim().replace(/\/+$/, '');
preview.textContent = base ? base + '/api/software-callback/health' : automaticLabel;
}
baseInput?.addEventListener('input', updatePreview);
const exclusionList = document.getElementById('software-exclusion-list');
const exclusionTemplate = document.getElementById('software-exclusion-row-template');
function bindExclusionRow(row) {
row?.querySelector('.software-exclusion-remove')?.addEventListener('click', () => row.remove());
}
function addExclusionRow() {
if (!exclusionList || !exclusionTemplate) return;
const fragment = exclusionTemplate.content.cloneNode(true);
const row = fragment.querySelector('.software-exclusion-row');
bindExclusionRow(row);
exclusionList.appendChild(fragment);
row?.querySelector('input[name="inventory_exclusion_name"]')?.focus();
}
exclusionList?.querySelectorAll('.software-exclusion-row').forEach(bindExclusionRow);
document.getElementById('software-exclusion-add')?.addEventListener('click', addExclusionRow);
if (exclusionList && exclusionList.children.length === 0) addExclusionRow();
const log = document.getElementById('software-debug-log');
document.getElementById('copy-software-debug-log')?.addEventListener('click', async event => {
const ok = await copyTextRobust(log?.textContent || '');
event.currentTarget.textContent = ok ? {{ t('software.copied')|tojson }} : {{ t('software.copy_failed')|tojson }};
window.setTimeout(() => event.currentTarget.textContent = {{ t('software.copy_log')|tojson }}, 1600);
});
async function refreshLog() {
try {
const response = await fetch('/settings/software/debug-log', {cache: 'no-store'});
if (response.ok) {
const text = await response.text();
log.textContent = text || {{ t('software.settings.no_log')|tojson }}; window.AssetManagerTime?.scan(log);
log.scrollTop = log.scrollHeight;
}
} catch (_) {}
}
log.scrollTop = log.scrollHeight;
window.setInterval(refreshLog, 3000);
})();
</script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software.title') }}</h1>
<p class="muted">{{ t('software.central_description') }}</p>
</div>
<a class="button button-secondary" href="/settings/software">{{ t('software.settings.menu') }}</a>
</div>
<div class="software-summary-cards">
<a class="software-summary-card software-summary-link" href="/software/installations">
<span>{{ t('software.total_entries') }}</span>
<strong>{{ software_entry_count }}</strong>
<small>{{ t('software.open_detailed_list') }}</small>
</a>
<a class="software-summary-card software-summary-link" href="/software/summary">
<span>{{ t('software.distinct_software') }}</span>
<strong>{{ software_summary_count }}</strong>
<small>{{ t('software.open_aggregated_list') }}</small>
</a>
<section class="software-summary-card">
<span>{{ t('software.initialized_assets') }}</span>
<strong>{{ initialized_assets }}</strong>
</section>
</div>
<section class="panel software-navigation-panel">
<h2>{{ t('software.lists') }}</h2>
<div class="software-list-links">
<a href="/software/installations" class="software-list-link">
<strong>{{ t('software.all_installations') }}</strong>
<span>{{ t('software.all_installations_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
<a href="/software/summary" class="software-list-link">
<strong>{{ t('software.aggregated_list') }}</strong>
<span>{{ t('software.aggregated_list_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
</div>
</section>
<section class="panel">
<div class="software-log-heading">
<h2>{{ t('software.recent_jobs') }}</h2>
<div class="software-job-heading-actions">
<a class="button button-secondary button-small" href="/software/job-states">▦ {{ t('job_states.open', 'Jobstatus je Asset') }}</a>
<button
type="button"
class="button button-secondary button-small"
id="restart-selected-jobs-button"
data-base-label="↻ {{ t('jobs.restart_selected')|e }}"
disabled
>↻ {{ t('jobs.restart_selected') }}</button>
<form method="get" action="/software" class="inline-form software-job-limit-form">
<label for="software-job-limit">{{ t('software.jobs_count') }}</label>
{% for filter_name, filter_value in job_filters.items() %}
{% if filter_value %}<input type="hidden" name="job_filter_{{ filter_name }}" value="{{ filter_value }}">{% endif %}
{% endfor %}
<select id="software-job-limit" name="job_limit" onchange="this.form.submit()">
{% for option in job_limit_options %}
<option value="{{ option }}" {% if option == job_limit %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="button button-secondary button-small">{{ t('common.apply') }}</button></noscript>
</form>
</div>
</div>
<p class="muted software-job-filter-help">{{ t('software.jobs_filter_all_help') }}</p>
<form
id="restart-selected-jobs-form"
class="hidden-utility-form"
method="post"
action="/jobs/restart-selected"
data-confirm-template="{{ t('jobs.bulk_restart.confirm')|e }}"
><span id="restart-selected-job-ids"></span></form>
<div class="table-scroll management-table-scroll">
<table
class="data-table"
data-storage-key="software-jobs"
data-server-filters="1"
data-server-filter-url="/software"
data-server-filter-total="{{ jobs_filtered_total if jobs_filters_active else jobs|length }}"
data-restart-label="↻ {{ t('jobs.restart')|e }}"
data-restart-confirm="{{ t('jobs.restart.confirm')|e }}"
>
<thead><tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1"><input type="checkbox" id="select-all-restartable-jobs" aria-label="{{ t('jobs.select_all_restartable')|e }}"></th>
<th data-server-filter-name="job_id" data-server-filter-value="{{ job_filters.job_id|e }}">{{ t('software.job_id') }}</th>
<th data-server-filter-name="created_at" data-server-filter-value="{{ job_filters.created_at|e }}">{{ t('software.time') }}</th>
<th data-server-filter-name="asset" data-server-filter-value="{{ job_filters.asset|e }}">{{ t('common.assets') }}</th>
<th data-server-filter-name="job_type" data-server-filter-value="{{ job_filters.job_type|e }}">{{ t('jobs.type') }}</th>
<th data-server-filter-name="package" data-server-filter-value="{{ job_filters.package|e }}">{{ t('software.package') }}</th>
<th data-server-filter-name="action" data-server-filter-value="{{ job_filters.action|e }}">{{ t('jobs.action') }}</th>
<th data-server-filter-name="priority" data-server-filter-value="{{ job_filters.priority|e }}">{{ t('jobs.priority') }}</th>
<th data-server-filter-name="platform" data-server-filter-value="{{ job_filters.platform|e }}">{{ t('software.platform') }}</th>
<th data-server-filter-name="status" data-server-filter-value="{{ job_filters.status|e }}">{{ t('software.status') }}</th>
<th>{{ t('software.details') }}</th>
<th>{{ t('jobs.actions') }}</th>
</tr></thead>
<tbody>
{% for job in jobs %}
{% set can_bulk_restart = job.status not in ['success', 'sending', 'running'] and job.asset and job.asset.mesh_node_id %}
<tr data-job-id="{{ job.id }}" data-job-status="{{ job.status }}" data-job-has-node="{{ '1' if job.asset and job.asset.mesh_node_id else '0' }}">
<td class="selection-column"><input type="checkbox" class="software-job-restart-select" value="{{ job.id }}" aria-label="{{ t('jobs.select_for_restart', id=job.id)|e }}" {% if not can_bulk_restart %}disabled{% endif %}></td>
<td><a href="/jobs/{{ job.id }}"><code>{{ job.id }}</code></a></td>
<td>{% if job.created_at %}<time class="local-time" datetime="{{ utc_iso(job.created_at) }}" data-utc="{{ utc_iso(job.created_at) }}">{{ job.created_at }}</time>{% else %}{{ '' }}{% endif %}</td>
<td><a href="/assets/{{ job.asset.id }}?inventory_tab=software">{{ job.asset.name }}</a></td>
<td>{{ t(job.display_job_type_key, job.display_job_type_fallback) }}</td>
<td>{{ job.display_package_name }}</td>
<td>{{ t('jobs.action.' ~ job.action, job.action) }}</td>
<td>{{ job.priority }}</td>
<td>{{ job.platform }}</td>
<td><span class="job-status job-status-{{ job.status }}" data-job-status-field>{{ t('software.status.' ~ job.status, job.status) }}</span></td>
<td><a href="/jobs/{{ job.id }}">{{ t('software.open') }}</a></td>
<td data-job-restart-cell>{% if job.status not in ['sending', 'running'] %}<form method="post" action="/jobs/{{ job.id }}/restart" class="inline-form" onsubmit="return confirm({{ t('jobs.restart.confirm')|tojson }});"><button type="submit" class="button button-secondary button-small">↻ {{ t('jobs.restart') }}</button></form>{% else %}<span class="muted">—</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="12">{{ t('software.no_jobs') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<script src="/static/js/software-job-selection.js?v={{ app_version }}"></script>
<script src="/static/js/software-job-status.js?v={{ app_version }}"></script>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software.all_installations') }}</h1>
<p class="muted">{{ t('software.all_installations_help') }}</p>
</div>
<a class="button button-secondary" href="/software">{{ t('common.back') }}</a>
</div>
<section class="panel" data-software-selection>
<div class="software-section-heading">
<div>
<h2>{{ t('software.detailed_list') }}</h2>
<p class="muted">{{ t('software.table_filter_help') }}</p>
</div>
<span class="software-inventory-count">{{ software_entries|length }}</span>
</div>
<form method="post" action="/software/inventory/delete" data-software-delete-form data-confirm-template="{{ t('software.delete.confirm')|e }}" data-inventory-only-warning="{{ t('software.delete.inventory_only')|e }}" class="software-delete-toolbar">
<input type="hidden" name="redirect_to" value="/software/installations">
<span>{{ t('software.selected_count') }}: <strong data-software-selection-count>0</strong></span>
<button type="submit" class="button button-danger" data-software-delete-button disabled>🗑 {{ t('software.delete.selected') }}</button>
</form>
<div class="table-scroll management-table-scroll software-dedicated-table">
<table class="data-table" id="software-all-installations" data-storage-key="software-all-installations">
<thead><tr>
<th data-no-sort="1"><input type="checkbox" data-software-select-all aria-label="{{ t('software.select_all_visible')|e }}"></th>
<th>{{ t('common.assets') }}</th>
<th>{{ t('common.name') }}</th>
<th>{{ t('about.version') }}</th>
<th>{{ t('software.publisher') }}</th>
<th>{{ t('software.architecture') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('software.install_date') }}</th>
<th>{{ t('software.last_seen') }}</th>
</tr></thead>
<tbody>
{% for entry in software_entries %}
<tr>
<td><input type="checkbox" value="{{ entry.id }}" data-software-entry-checkbox aria-label="{{ t('software.select_entry', name=entry.name)|e }}"></td>
<td><a href="/assets/{{ entry.asset_id }}?inventory_tab=software">{{ entry.asset_name }}</a></td>
<td>{{ entry.name }}</td>
<td>{{ entry.version or '' }}</td>
<td>{{ entry.publisher or '' }}</td>
<td>{{ entry.architecture or '' }}</td>
<td>{{ t('software.platform.' ~ (entry.platform or 'unknown'), entry.platform or 'unknown') }}</td>
<td data-sort-value="{{ entry.install_date or '' }}">{{ entry.install_date or '' }}</td>
<td data-sort-value="{{ entry.last_seen_at.isoformat() if entry.last_seen_at else '' }}">{% if entry.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(entry.last_seen_at) }}" data-utc="{{ utc_iso(entry.last_seen_at) }}">{{ entry.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="9">{{ t('software.no_global_inventory') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('jobs.title', 'Job {id}').replace('{id}', job.id|string) }}</h1><div class="toolbar-actions">{% if can_restart %}<form method="post" action="/jobs/{{ job.id }}/restart" class="inline-form" onsubmit="return confirm({{ t('jobs.restart.confirm')|tojson }});"><button type="submit">↻ {{ t('jobs.restart') }}</button></form>{% endif %}<a class="button button-secondary" href="/software">{{ t('common.back') }}</a></div></div>
<section class="panel"><dl><dt>{{ t('software.job_id') }}</dt><dd><code>{{ job.id }}</code></dd><dt>{{ t('common.assets') }}</dt><dd><a href="/assets/{{ job.asset.id }}?inventory_tab=software">{{ job.asset.name }}</a></dd><dt>{{ t('jobs.type') }}</dt><dd>{{ t(job.display_job_type_key, job.display_job_type_fallback) }}</dd><dt>{{ t('software.package') }}</dt><dd>{{ job.display_package_name }}</dd><dt>{{ t('jobs.action') }}</dt><dd>{{ t('jobs.action.' ~ job.action, job.action) }}</dd><dt>{{ t('jobs.priority') }}</dt><dd>{{ job.priority }}</dd><dt>{{ t('software.status') }}</dt><dd><span id="job-status" class="job-status job-status-{{ job.status }}">{{ t('software.status.' ~ job.status, job.status) }}</span></dd><dt>{{ t('software.platform') }}</dt><dd>{{ job.platform }}</dd><dt>{{ t('software.created') }}</dt><dd>{% if job.created_at %}<time class="local-time" datetime="{{ utc_iso(job.created_at) }}" data-utc="{{ utc_iso(job.created_at) }}">{{ job.created_at }}</time>{% else %}{{ '' }}{% endif %}</dd><dt>{{ t('jobs.created_by') }}</dt><dd>{{ job.created_by or '—' }}</dd><dt>{{ t('jobs.scheduled_at') }}</dt><dd>{% if job.scheduled_at %}<time class="local-time" datetime="{{ utc_iso(job.scheduled_at) }}" data-utc="{{ utc_iso(job.scheduled_at) }}">{{ job.scheduled_at }}</time>{% else %}{{ '—' }}{% endif %}</dd><dt>{{ t('jobs.attempts') }}</dt><dd>{{ job.attempt_count }} / {{ job.max_attempts }}</dd><dt>{{ t('software.sent') }}</dt><dd>{% if job.sent_at %}<time class="local-time" datetime="{{ utc_iso(job.sent_at) }}" data-utc="{{ utc_iso(job.sent_at) }}">{{ job.sent_at }}</time>{% else %}{{ '' }}{% endif %}</dd><dt>{{ t('software.finished') }}</dt><dd id="job-finished">{% if job.finished_at %}<time class="local-time" datetime="{{ utc_iso(job.finished_at) }}" data-utc="{{ utc_iso(job.finished_at) }}">{{ job.finished_at }}</time>{% else %}{{ '' }}{% endif %}</dd><dt>{{ t('software.exit_code') }}</dt><dd id="job-exit-code">{{ job.exit_code if job.exit_code is not none else '' }}</dd><dt>{{ t('software.message') }}</dt><dd id="job-message">{{ job.message or '' }}</dd></dl></section>
<section class="panel job-data-grid"><div><h2>{{ t('jobs.parameters') }}</h2><pre class="sync-log">{{ job.parameters | tojson(indent=2) }}</pre></div><div><h2>{{ t('jobs.result') }}</h2><pre class="sync-log">{{ job.result_data | tojson(indent=2) }}</pre></div></section>
<section class="panel"><h2>{{ t('jobs.history') }}</h2><div class="table-scroll"><table class="data-table"><thead><tr><th>{{ t('software.time') }}</th><th>{{ t('jobs.attempt') }}</th><th>{{ t('jobs.event') }}</th><th>{{ t('software.status') }}</th><th>{{ t('software.message') }}</th><th>{{ t('jobs.created_by') }}</th></tr></thead><tbody>{% for event in events %}<tr><td><time class="local-time" datetime="{{ utc_iso(event.created_at) }}" data-utc="{{ utc_iso(event.created_at) }}">{{ event.created_at }}</time></td><td>{{ event.attempt }}</td><td>{{ t('jobs.event.' ~ event.event_type, event.event_type) }}</td><td>{{ t('software.status.' ~ event.status, event.status or '—') }}</td><td>{{ event.message or '' }}</td><td>{{ event.created_by or '—' }}</td></tr>{% else %}<tr><td colspan="6">{{ t('jobs.history.empty') }}</td></tr>{% endfor %}</tbody></table></div></section>
<section class="panel"><h2>{{ t('software.diagnostics') }}</h2><p><strong>{{ t('software.local_log') }}:</strong> {% if job.platform == 'windows' %}<code>C:\ProgramData\AssetManager\commands.log</code>{% elif job.platform == 'linux' %}<code>/var/log/assetmanager-commands.log</code>{% elif job.platform == 'macos' %}<code>/Library/Logs/AssetManager-commands.log</code>{% else %}<code>—</code>{% endif %}</p><p><strong>{{ t('software.callback_health') }}:</strong> <code>/api/software-callback/health</code></p><p class="notice"><strong>{{ t('software.callback_token_warning') }}</strong></p>{% if job.resolved_script %}<label>{{ t('jobs.resolved_script') }}<textarea id="resolved-job-script" class="custom-css-editor" rows="24" readonly>{{ job.resolved_script }}</textarea><small>{{ t('jobs.resolved_script_help') }}</small></label><button type="button" class="button button-secondary" id="copy-resolved-script">{{ t('jobs.copy_resolved_script') }}</button>{% endif %}<details style="margin-top:1rem"><summary>{{ t('jobs.meshctrl_command') }}</summary><label>{{ t('software.manual_command') }}<textarea id="manual-job-command" class="custom-css-editor" readonly>{{ job.command_preview or '' }}</textarea></label><button type="button" class="button button-secondary" id="copy-job-command">{{ t('software.copy_command') }}</button></details></section>
<section class="panel"><div class="software-log-heading"><h2>{{ t('software.client_log') }}</h2><button type="button" class="button button-secondary" data-copy-log="job-client-log">{{ t('software.copy_log') }}</button></div><pre class="sync-log" id="job-client-log" data-localize-log>{{ job.log_text or t('software.no_callback') }}</pre></section><section class="panel"><h2>{{ t('software.meshctrl') }}</h2><div class="software-log-heading"><h3>{{ t('software.stdout') }}</h3><button type="button" class="button button-secondary" data-copy-log="job-meshctrl-stdout">{{ t('software.copy_log') }}</button></div><pre class="sync-log" id="job-meshctrl-stdout" data-localize-log>{{ job.meshctrl_stdout or '' }}</pre><div class="software-log-heading"><h3>{{ t('software.stderr') }}</h3><button type="button" class="button button-secondary" data-copy-log="job-meshctrl-stderr">{{ t('software.copy_log') }}</button></div><pre class="sync-log" id="job-meshctrl-stderr" data-localize-log>{{ job.meshctrl_stderr or '' }}</pre></section>
<script>
async function copyTextRobust(text) {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
const area = document.createElement('textarea');
area.value = value;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.left = '-9999px';
area.style.top = '0';
area.style.opacity = '0';
document.body.appendChild(area);
area.focus();
area.select();
area.setSelectionRange(0, area.value.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch (_) {
copied = false;
}
area.remove();
return copied;
}
(()=>{document.getElementById('copy-resolved-script')?.addEventListener('click',async event=>{const ok=await copyTextRobust(document.getElementById('resolved-job-script')?.value||'');event.currentTarget.textContent=ok?{{ t('software.copied')|tojson }}:{{ t('software.copy_failed')|tojson }};setTimeout(()=>event.currentTarget.textContent={{ t('jobs.copy_resolved_script')|tojson }},1600);});document.getElementById('copy-job-command')?.addEventListener('click',async event=>{const ok=await copyTextRobust(document.getElementById('manual-job-command')?.value||'');event.currentTarget.textContent=ok?{{ t('software.copied')|tojson }}:{{ t('software.copy_failed')|tojson }};setTimeout(()=>event.currentTarget.textContent={{ t('software.copy_command')|tojson }},1600);});document.querySelectorAll('[data-copy-log]').forEach(button=>button.addEventListener('click',async event=>{const ok=await copyTextRobust(document.getElementById(button.dataset.copyLog)?.textContent||'');event.currentTarget.textContent=ok?{{ t('software.copied')|tojson }}:{{ t('software.copy_failed')|tojson }};setTimeout(()=>event.currentTarget.textContent={{ t('software.copy_log')|tojson }},1600);}));const terminal=new Set(['success','failed','partial','timeout','cancelled']);async function refreshJob(){try{const r=await fetch('/api/software-jobs/{{ job.id }}',{cache:'no-store'});if(!r.ok)return;const d=await r.json();const st=document.getElementById('job-status');if(st){st.textContent=d.status_label;st.className='job-status job-status-'+d.status;}document.getElementById('job-message').textContent=d.message||'';const finished=document.getElementById('job-finished');if(finished){finished.innerHTML=d.finished_at?`<time class="local-time" datetime="${d.finished_at}" data-utc="${d.finished_at}">${d.finished_at}</time>`:'';window.AssetManagerTime?.scan(finished);}document.getElementById('job-exit-code').textContent=d.exit_code??'';document.getElementById('job-client-log').textContent=d.log_text||{{ t('software.no_callback')|tojson }};if(!terminal.has(d.status))setTimeout(refreshJob,3000);}catch(_){setTimeout(refreshJob,5000);}}{% if job.status not in ['success','failed','partial','timeout','cancelled'] %}setTimeout(refreshJob,1500);{% endif %}})();</script>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('job_states.title', 'Jobstatus je Asset') }}</h1>
<p class="muted">{{ t('job_states.description', 'Aktueller kompakter Jobstatus pro Asset und Job. Die vollständige Historie bleibt in den einzelnen Jobs erhalten.') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/software">← {{ t('job_states.back', 'Zur Jobübersicht') }}</a>
</div>
</div>
<section class="panel">
<div class="table-scroll management-table-scroll">
<table class="data-table" data-storage-key="asset-job-states">
<thead>
<tr>
<th>{{ t('common.assets', 'Assets') }}</th>
<th>{{ t('job_states.job', 'Job') }}</th>
<th>{{ t('jobs.type', 'Job-Art') }}</th>
<th>{{ t('software.status', 'Status') }}</th>
<th>{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}</th>
<th>{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}</th>
<th>{{ t('job_states.revision', 'Letzte Revision') }}</th>
<th>{{ t('job_states.success_revision', 'Erfolgsrevision') }}</th>
<th>{{ t('job_states.execution_count', 'Ausführungen') }}</th>
<th>{{ t('job_states.last_job', 'Letzter Job') }}</th>
<th>{{ t('job_states.updated_at', 'Aktualisiert') }}</th>
</tr>
</thead>
<tbody>
{% for row in job_states %}
{% set state = row.state %}
<tr>
<td><a href="/assets/{{ row.asset.id }}?inventory_tab=software">{{ row.asset.name }}</a></td>
<td>{{ row.job_label }}</td>
<td>{{ t(row.job_type_key, row.job_type_fallback) }}</td>
<td><span class="job-status job-status-{{ state.last_status }}">{{ t('software.status.' ~ state.last_status, state.last_status) }}</span></td>
<td>{% if row.last_execution %}<time class="local-time" datetime="{{ utc_iso(row.last_execution) }}" data-utc="{{ utc_iso(row.last_execution) }}">{{ row.last_execution }}</time>{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{% if state.last_success_at %}<time class="local-time" datetime="{{ utc_iso(state.last_success_at) }}" data-utc="{{ utc_iso(state.last_success_at) }}">{{ state.last_success_at }}</time>{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{% if state.definition_revision is not none %}{{ state.definition_revision }}{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{% if state.last_success_revision is not none %}{{ state.last_success_revision }}{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{{ state.execution_count }}</td>
<td>{% if state.last_job_id %}<a href="/jobs/{{ state.last_job_id }}"><code>{{ state.last_job_id }}</code></a>{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{% if state.updated_at %}<time class="local-time" datetime="{{ utc_iso(state.updated_at) }}" data-utc="{{ utc_iso(state.updated_at) }}">{{ state.updated_at }}</time>{% else %}<span class="muted">—</span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="11" class="empty-state">{{ t('job_states.empty', 'Noch keine Jobstatusdaten vorhanden.') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software.aggregated_list') }}</h1>
<p class="muted">{{ t('software.aggregated_list_help') }}</p>
</div>
<a class="button button-secondary" href="/software">{{ t('common.back') }}</a>
</div>
<section class="panel">
<div class="software-section-heading">
<div>
<h2>{{ t('software.summary_table') }}</h2>
<p class="muted">{{ t('software.numeric_filter_help') }}</p>
</div>
<div class="software-summary-controls">
<form method="get" action="/software/summary" class="inline-form software-grouping-form">
<label for="software-summary-grouping">{{ t('software.grouping') }}</label>
<select id="software-summary-grouping" name="grouping" title="{{ t('software.grouping.help')|e }}" onchange="this.form.submit()">
<option value="name_platform" {% if grouping == 'name_platform' %}selected{% endif %}>{{ t('software.grouping.name_platform') }}</option>
<option value="software" {% if grouping == 'software' %}selected{% endif %}>{{ t('software.grouping.software') }}</option>
<option value="version" {% if grouping == 'version' %}selected{% endif %}>{{ t('software.grouping.version') }}</option>
</select>
<noscript><button type="submit" class="button button-secondary button-small">{{ t('common.apply') }}</button></noscript>
</form>
<span class="software-inventory-count">{{ software_summary|length }}</span>
</div>
</div>
<div class="table-scroll management-table-scroll software-dedicated-table">
<table class="data-table" id="software-aggregated" data-storage-key="software-aggregated">
<thead><tr>
<th>{{ t('common.name') }}</th>
<th>{{ t('software.publisher') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('software.versions') }}</th>
<th data-numeric-filter="1" data-filter-placeholder="{{ t('software.numeric_filter_placeholder') }}">{{ t('software.version_count') }}</th>
<th data-numeric-filter="1" data-filter-placeholder="{{ t('software.numeric_filter_placeholder') }}">{{ t('software.asset_count') }}</th>
<th data-numeric-filter="1" data-filter-placeholder="{{ t('software.numeric_filter_placeholder') }}">{{ t('software.entry_count') }}</th>
<th>{{ t('software.last_seen') }}</th>
</tr></thead>
<tbody>
{% for row in software_summary %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.publisher }}</td>
<td>{{ t('software.platform.' ~ (row.platform or 'unknown'), row.platform or 'unknown') }}</td>
<td>{{ row.versions }}</td>
<td data-sort-value="{{ row.version_count }}">{{ row.version_count }}</td>
<td data-sort-value="{{ row.asset_count }}">{{ row.asset_count }}</td>
<td data-sort-value="{{ row.entries }}">{{ row.entries }}</td>
<td data-sort-value="{{ row.last_seen_at.isoformat() if row.last_seen_at else '' }}">{% if row.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(row.last_seen_at) }}" data-utc="{{ utc_iso(row.last_seen_at) }}">{{ row.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8">{{ t('software.no_global_inventory') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('app.status_values') }}</h1></div><p>{{ t('status.help') }}</p>
<form method="post"><div class="table-scroll management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('common.order') }}</th><th>{{ t('assets.status') }}</th><th>{{ t('common.active') }}</th><th>{{ t('status.on_issue') }}</th><th>{{ t('status.on_return') }}</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for option in options %}<tr><td><input type="number" name="sort_{{option.id}}" value="{{option.sort_order}}"></td><td><input name="name_{{option.id}}" value="{{option.name}}" required></td><td><input class="checkbox" type="checkbox" name="active_{{option.id}}" {% if option.active %}checked{% endif %}></td><td><input type="radio" name="issue_status_id" value="{{ option.id }}" {% if option.use_for_issue %}checked{% endif %}></td><td><input type="radio" name="return_status_id" value="{{ option.id }}" {% if option.use_for_return %}checked{% endif %}></td><td><button formmethod="post" formaction="/status-options/{{option.id}}/delete" class="icon-button button-danger" onclick="return confirm({{ t('status.delete_confirm')|tojson }});">🗑</button></td></tr>{% endfor %}
<tr><td>{{ t('common.new') }}</td><td><input name="new_name" placeholder="{{ t('status.new_value') }}"></td><td colspan="4"></td></tr></tbody></table></div><div class="form-actions"><button class="button button-save" type="submit">💾 {{ t('common.save') }}</button></div></form>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('translations.title') }}</h1><div class="toolbar-actions"><a class="button button-secondary" href="/settings">{{ t('common.back') }}</a><button type="button" class="button" id="add-translation-button">➕ {{ t('translations.add') }}</button><a class="button" href="/settings/translations/export">📥 {{ t('translations.export_excel') }}</a></div></div>
<div class="panel translation-filter-panel">
<form method="get" class="translation-filter-form">
<label class="checkbox-label translation-filter-checkbox"><input class="checkbox" type="checkbox" name="missing_only" value="1" {% if missing_only %}checked{% endif %}> {{ t('translations.only_missing') }}</label>
<button type="submit">{{ t('common.search') }}</button>
</form>
</div>
<form method="post" action="/settings/translations/delete" id="translation-delete-form" onsubmit="return confirm({{ t('translations.delete_confirm')|tojson }});">
<div class="toolbar-actions translation-selection-actions"><button type="submit" class="button button-danger" id="delete-translations-button" disabled>🗑 {{ t('translations.delete_selected') }}</button></div>
<div class="table-wrap management-table-scroll"><table class="data-table"><thead><tr><th class="selection-column"><input type="checkbox" id="select-all-translations" aria-label="{{ t('translations.select_all') }}"></th><th>{{ t('translations.key') }}</th>{% for language in languages %}<th>{{ language.native_name }} ({{ language.code }})</th>{% endfor %}</tr></thead><tbody>{% for key, values in rows.items() %}<tr><td class="selection-column"><input type="checkbox" class="translation-select" name="translation_keys" value="{{ key }}" {% if key in protected_keys %}disabled title="{{ t('translations.builtin_protected') }}"{% endif %}></td><td><code>{{ key }}</code>{% if key in protected_keys %}<small class="muted block">{{ t('translations.builtin') }}</small>{% endif %}</td>{% for language in languages %}<td><textarea form="translation-save-form" name="tr__{{ language.code }}__{{ key }}" rows="2">{{ values.get(language.code, '') }}</textarea></td>{% endfor %}</tr>{% endfor %}</tbody></table></div>
</form>
<form method="post" action="/settings/translations/save" id="translation-save-form"><button type="submit" class="button button-save">💾 {{ t('common.save') }}</button></form>
<form method="post" action="/settings/translations/import" enctype="multipart/form-data" class="panel translation-import"><h2>{{ t('translations.import_excel') }}</h2><input type="file" name="file" accept=".xlsx" required><label class="checkbox-label"><input type="checkbox" name="ignore_empty" checked> {{ t('translations.ignore_empty') }}</label><button type="submit">📤 {{ t('common.import') }}</button></form>
<dialog id="add-translation-dialog"><form method="post" action="/settings/translations/add"><h2>{{ t('translations.add') }}</h2><label>{{ t('translations.key') }}<input name="translation_key" pattern="[A-Za-z0-9_.-]+" placeholder="example.section.label" required></label>{% for language in languages %}<label>{{ language.native_name }} ({{ language.code }})<textarea name="value_{{ language.code }}" rows="2"></textarea></label>{% endfor %}<div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">➕ {{ t('translations.add') }}</button></div></form></dialog>
<script>
(() => {
const dialog=document.getElementById('add-translation-dialog'); document.getElementById('add-translation-button')?.addEventListener('click',()=>dialog.showModal());
const boxes=[...document.querySelectorAll('.translation-select:not(:disabled)')], all=document.getElementById('select-all-translations'), button=document.getElementById('delete-translations-button');
const refresh=()=>{const count=boxes.filter(x=>x.checked).length; button.disabled=count===0; all.checked=boxes.length>0&&count===boxes.length; all.indeterminate=count>0&&count<boxes.length;};
boxes.forEach(x=>x.addEventListener('change',refresh)); all?.addEventListener('change',()=>{boxes.forEach(x=>x.checked=all.checked);refresh();}); refresh();
})();
</script>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><div><h1>{{ t('users.management') }}</h1><p class="muted">{{ t('users.permissions_help') }}</p></div><div class="toolbar-actions"><a class="button button-secondary" href="/settings/ldap-search?show_all=1">{{ t('users.ldap_import') }}</a><a class="button button-secondary" href="/settings/authentication">{{ t('users.login_settings') }}</a></div></div>
<section class="panel"><h2>{{ t('users.create_local') }}</h2><form method="post" action="/users/new" class="compact-form"><div class="form-grid"><label>{{ t('profile.username') }}<input name="username" required></label><label>{{ t('profile.display_name') }}<input name="display_name"></label><label>{{ t('profile.email') }}<input type="email" name="email"></label><label>{{ t('login.password') }}<input type="password" name="password" minlength="8" required></label></div><label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_admin"> {{ t('users.administrator') }}</label><button type="submit">➕ {{ t('users.new') }}</button></form></section>
<div class="sticky-table management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('users.user') }}</th><th>{{ t('profile.source') }}</th><th>{{ t('profile.last_login') }}</th><th>{{ t('users.permissions_groups') }}</th></tr></thead><tbody>
{% for user in users %}<tr><td><strong>{{ user.username }}</strong>{% if user.is_protected %}<br><span class="software-settings-badge">🔐 {{ t('users.protected_local_admin') }}</span>{% endif %}<br><span class="muted">{{ user.display_name or '' }}{% if user.email %} · {{ user.email }}{% endif %}</span>{% if user.is_protected %}<br><span class="muted">{{ t('users.protected_local_admin_help') }}</span>{% endif %}</td><td>{{ user.auth_source }}</td><td>{% if user.last_login %}<time class="local-time" datetime="{{ utc_iso(user.last_login) }}" data-utc="{{ utc_iso(user.last_login) }}">{{ user.last_login }}</time>{% else %}{{ '' }}{% endif %}</td><td><form method="post" action="/users/{{ user.id }}/update" class="user-permission-form"><div class="form-grid"><label>{{ t('profile.display_name') }}<input name="display_name" value="{{ user.display_name or '' }}"></label><label>{{ t('profile.email') }}<input type="email" name="email" value="{{ user.email or '' }}"></label><label>{{ t('users.profile_department') }}<input name="profile_department" value="{{ user.profile_department or '' }}" placeholder="{{ t('users.profile_department_placeholder') }}"></label><label>{{ t('users.profile_location') }}<input name="profile_location" value="{{ user.profile_location or '' }}" placeholder="{{ t('users.profile_location_placeholder') }}"></label>{% if user.auth_source == 'local' %}<label>{{ t('profile.new_password') }}<input type="password" name="new_password"></label>{% endif %}</div><div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %} {% if user.is_protected %}disabled{% endif %}> {{ t('common.active') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %} {% if user.is_protected %}disabled{% endif %}> {{ t('users.administrator') }}</label></div><fieldset><legend>{{ t('users.asset_visibility') }}</legend><p class="muted">{{ t('users.asset_visibility_help') }}</p><div class="group-checkboxes"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="self" {% if 'self' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_self') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="department" {% if 'department' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_department') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="location" {% if 'location' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_location') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="all" {% if 'all' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_all') }}</label></div><p class="muted">{{ t('users.scope_or_hint') }}</p></fieldset><fieldset><legend>{{ t('users.visible_mesh_groups') }}</legend><div class="group-checkboxes">{% for group in mesh_groups %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="mesh_groups" value="{{ group }}" {% if group in (user.allowed_mesh_groups or []) %}checked{% endif %}> {{ group }}</label>{% else %}<span class="muted">{{ t('users.no_mesh_groups') }}</span>{% endfor %}</div></fieldset><button type="submit">💾 {{ t('users.save') }}</button></form>{% if not user.is_protected %}<form method="post" action="/users/{{ user.id }}/delete" class="user-delete-form" onsubmit="return confirm({{ t('users.delete_confirm', username=user.username)|tojson }});"><button type="submit" class="button button-danger">🗑 {{ t('users.delete') }}</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4">{{ t('users.none') }}</td></tr>{% endfor %}
</tbody></table></div>
<p class="muted">{{ t('users.relogin_hint') }}</p>
{% endblock %}
+2
View File
@@ -0,0 +1,2 @@
APP_VERSION = "0.5.5.27"
__version__ = APP_VERSION