Initialer Import des AssetManagers
This commit is contained in:
+337
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+1351
File diff suppressed because it is too large
Load Diff
+1068
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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]));
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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');
|
||||
})();
|
||||
@@ -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});
|
||||
})();
|
||||
@@ -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};
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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?.();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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') }}">✎</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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 %}
|
||||
@@ -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 %}
|
||||
@@ -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}}"
|
||||
@@ -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'
|
||||
@@ -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}}
|
||||
@@ -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%
|
||||
@@ -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}}"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}")
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,2 @@
|
||||
APP_VERSION = "0.5.5.27"
|
||||
__version__ = APP_VERSION
|
||||
Reference in New Issue
Block a user