#!/usr/bin/env python3 """ PVE Backup Log Viewer ===================== Eine kleine Flask-Anwendung, die direkt die vzdump-Task-Logs aus ``/var/log/pve/tasks/`` einliest und im Browser anzeigt: • Übersicht aller eingelesenen Backup-Läufe (jüngste oben) • Detailansicht pro Job: ID, Name, Typ, Status, Dauer, geändert, gesamt • Entwicklung einer einzelnen VM/CT-ID über alle Backup-Läufe (Diagramm) PVE-Task-Logs ------------- PVE legt für jeden Task (auch jeden vzdump-Lauf) eine Datei unter ``/var/log/pve/tasks//UPID::::::::`` ab. ```` ist ein zweistelliger Hex-Bucket aus ``pstart``. Wir filtern auf ``UPID:…:vzdump:…``. Ein vzdump-Job, der alle Gäste sichert, ist eine einzige Task-Datei mit mehreren "Starting Backup of VM …" Abschnitten darin. Installation auf dem PVE-Host ----------------------------- 1. Kopiere ``app.py`` z.B. nach ``/opt/pve-log-viewer/app.py``. 2. Flask installieren. Saubere Variante mit venv:: apt install python3-venv -y python3 -m venv /opt/pve-log-viewer/venv /opt/pve-log-viewer/venv/bin/pip install flask 3. Berechtigungen: ``/var/log/pve/tasks`` gehört ``www-data``. Am einfachsten läuft die App als ``www-data`` (siehe systemd-Unit unten). 4. systemd-Unit ``/etc/systemd/system/pve-log-viewer.service``:: [Unit] Description=PVE Backup Log Viewer After=network.target [Service] User=www-data Group=www-data WorkingDirectory=/opt/pve-log-viewer ExecStart=/opt/pve-log-viewer/venv/bin/python /opt/pve-log-viewer/app.py Restart=on-failure [Install] WantedBy=multi-user.target Dann:: systemctl daemon-reload systemctl enable --now pve-log-viewer # Aufruf: http://:5000 Konfiguration via Umgebungsvariablen ------------------------------------ PVE_TASK_DIR Pfad zu den Task-Logs (Default: /var/log/pve/tasks, Fallback: /var/log/pve/task) LISTEN_HOST Default 0.0.0.0 LISTEN_PORT Default 5000 """ from __future__ import annotations import os import re from datetime import datetime from pathlib import Path from flask import Flask, abort, render_template_string # --------------------------------------------------------------------------- # Konfiguration # --------------------------------------------------------------------------- def _default_pve_dir() -> str: for p in ("/var/log/pve/tasks", "/var/log/pve/task"): if Path(p).is_dir(): return p return "/var/log/pve/tasks" PVE_TASK_DIR = Path(os.environ.get("PVE_TASK_DIR", _default_pve_dir())) PBS_TASK_DIR = Path(os.environ.get("PBS_TASK_DIR", "/var/log/proxmox-backup/tasks")) LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0") LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "5000")) # Welche Quellen sind aktiv? Wenn PBS_TASK_DIR nicht existiert, einfach # ignorieren – damit die App auch reine PVE-Installationen abdeckt. SOURCES: list[tuple[str, Path, str]] = [] # (label, dir, filename_substring) if PVE_TASK_DIR.is_dir(): SOURCES.append(("pve", PVE_TASK_DIR, ":vzdump:")) if PBS_TASK_DIR.is_dir(): SOURCES.append(("pbs", PBS_TASK_DIR, ":backup:")) app = Flask(__name__) # --------------------------------------------------------------------------- # Parsing eines einzelnen vzdump-Logs # --------------------------------------------------------------------------- UNIT_TO_BYTES = { "B": 1, "KiB": 1024, "MiB": 1024 ** 2, "GiB": 1024 ** 3, "TiB": 1024 ** 4, } _SHORT_TO_LONG = {"K": "KiB", "M": "MiB", "G": "GiB", "T": "TiB"} def to_bytes(value, unit: str) -> float: return float(value) * UNIT_TO_BYTES.get(unit, 1) def parse_pve_log(text: str) -> dict: """Zerlegt ein vzdump-Log in eine strukturierte Form.""" result: dict = { "job_time": None, "guests": [], "job_status": "ok", # 'ok' | 'errors' | 'fatal' "job_error": None, # Text aus 'TASK ERROR: ...' (falls vorhanden) "source": "pve", } m = re.search(r"Backup started at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", text) if m: result["job_time"] = m.group(1) m = re.search(r"^TASK ERROR:\s*(.+?)$", text, re.MULTILINE) if m: result["job_status"] = "fatal" result["job_error"] = m.group(1).strip() elif "Backup job finished with errors" in text: result["job_status"] = "errors" pattern = re.compile( r"INFO: Starting Backup of VM (\d+) \((lxc|qemu)\)(.*?)" r"(?=INFO: Starting Backup of VM |\Z)", re.DOTALL, ) for vmid, gtype, body in pattern.findall(text): guest: dict = { "id": int(vmid), "type": "CT" if gtype == "lxc" else "VM", } m = re.search(r"INFO: (?:CT|VM) Name: (.+)", body) guest["name"] = m.group(1).strip() if m else "?" m = re.search(r"INFO: status = (\w+)", body) guest["status"] = m.group(1) if m else "?" m = re.search(r"Finished Backup of VM \d+ \((\d{2}):(\d{2}):(\d{2})\)", body) guest["duration_sec"] = ( int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) if m else None ) changed, total = None, None # CT (pxar) m = re.search( r"root\.pxar: had to backup ([\d.]+) (\w+) of ([\d.]+) (\w+)", body ) if m: changed = to_bytes(m.group(1), m.group(2)) total = to_bytes(m.group(3), m.group(4)) else: # QEMU running: dirty-bitmap m = re.search( r"using fast incremental mode \(dirty-bitmap\), " r"([\d.]+) (\w+) dirty of ([\d.]+) (\w+) total", body, ) if m: changed = to_bytes(m.group(1), m.group(2)) total = to_bytes(m.group(3), m.group(4)) else: # QEMU stopped: voller Disk-Scan m = re.search(r"transferred ([\d.]+) (\w+) in \d+ seconds", body) if m: changed = to_bytes(m.group(1), m.group(2)) m2 = re.search( r"include disk '\w+' '[^']+' (\d+)([GMTK])(?:\s|$|\n)", body ) if m2: total = to_bytes(m2.group(1), _SHORT_TO_LONG[m2.group(2)]) guest["changed_bytes"] = changed guest["total_bytes"] = total m = re.search(r"reused ([\d.]+) (\w+) \((\d+(?:\.\d+)?)%\)", body) if m: guest["reused_bytes"] = to_bytes(m.group(1), m.group(2)) guest["reused_pct"] = float(m.group(3)) else: guest["reused_bytes"] = None guest["reused_pct"] = None # Gespeichert = was tatsächlich neu auf den PBS-Datastore geschrieben # wurde, nach Dedup. Aus Total - Reused, falls beide bekannt. if guest["total_bytes"] is not None and guest["reused_bytes"] is not None: guest["saved_bytes"] = max(0.0, guest["total_bytes"] - guest["reused_bytes"]) else: guest["saved_bytes"] = None # Fehler-Erkennung: vzdump schreibt 'ERROR: Backup of VM failed - ...' # bei Fehlschlag. Die kurzversion steht meist in 'INFO: Error: ...'. err_line = re.search( r"^ERROR: Backup of VM \d+ failed - (.+?)$", body, re.MULTILINE ) if err_line: guest["status"] = "failed" info_err = re.search(r"^INFO: Error:\s*(.+?)$", body, re.MULTILINE) qmp_err = re.search(r"command error:\s*(.+?)$", err_line.group(1)) if info_err: msg = info_err.group(1).strip() elif qmp_err: msg = qmp_err.group(1).strip() else: msg = err_line.group(1).strip() # Sehr lange Strings abschneiden – PVE schreibt gerne die ganze # Kommandozeile in den Error. if len(msg) > 160: msg = msg[:157] + "…" guest["error"] = msg # Wenn 'Finished Backup' fehlt, Dauer aus Start- und Failed-Zeit: if guest["duration_sec"] is None: m_start = re.search( r"Backup started at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", body ) m_failed = re.search( r"Failed at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})", body ) if m_start and m_failed: try: t0 = datetime.strptime(m_start.group(1), "%Y-%m-%d %H:%M:%S") t1 = datetime.strptime(m_failed.group(1), "%Y-%m-%d %H:%M:%S") guest["duration_sec"] = max(0, int((t1 - t0).total_seconds())) except ValueError: pass else: guest["error"] = None result["guests"].append(guest) return result def parse_pbs_log(text: str) -> dict: """Zerlegt ein PBS-Backup-Task-Log in dieselbe Datenstruktur wie der PVE-Parser. Pro Datei genau ein Gast (PBS legt pro Backup-Snapshot eine eigene Task-Datei an). Mapping in das PVE-Schema: Übertragen = Upload size (was wirklich über die Leitung kam) Gespeichert = Upload size (PBS sieht und schreibt nur das) Reused = Size − Upload (was durch Dedup nicht nötig war) Gesamt = Size (Summe der Disk-/Archiv-Größen) """ result: dict = { "job_time": None, "guests": [], "job_status": "ok", "job_error": None, "source": "pbs", "datastore": None, "source_ip": None, } m = re.search( r"^([0-9T:+\-]+):\s*starting new backup on datastore '([^']+)' " r"from (\S+):\s*\"(ct|vm)/(\d+)/([^\"]+)\"", text, re.MULTILINE, ) if not m: return result start_ts_iso = m.group(1) result["datastore"] = m.group(2) src = m.group(3).rstrip(":") result["source_ip"] = src[7:] if src.startswith("::ffff:") else src backup_type = m.group(4) vmid = int(m.group(5)) snapshot = m.group(6) result["job_time"] = start_ts_iso[:10] + " " + start_ts_iso[11:19] total_size = 0 total_upload = 0 blocks = re.findall( r"Upload statistics for '([^']+)'.*?" r"Size:\s*(\d+).*?" r"Upload size:\s*(\d+)\s*\((\d+)%\)", text, re.DOTALL, ) for _archive, size, upload, _pct in blocks: total_size += int(size) total_upload += int(upload) duration_sec = None m_end = re.search(r"^([0-9T:+\-]+):\s*TASK (?:OK|ERROR)", text, re.MULTILINE) if m_end: try: t0 = datetime.fromisoformat(start_ts_iso) t1 = datetime.fromisoformat(m_end.group(1)) duration_sec = max(0, int((t1 - t0).total_seconds())) except ValueError: pass guest = { "id": vmid, "type": "CT" if backup_type == "ct" else "VM", "name": "?", # PBS kennt keinen Gast-Namen "status": "running", "duration_sec": duration_sec, "changed_bytes": float(total_upload) if total_size else None, "total_bytes": float(total_size) if total_size else None, "reused_bytes": (float(total_size - total_upload) if total_size and total_size >= total_upload else None), "reused_pct": (100.0 * (total_size - total_upload) / total_size if total_size else None), "saved_bytes": float(total_upload) if total_size else None, "error": None, "snapshot": snapshot, } m_taskerr = re.search(r"^[0-9T:+\-]+:\s*TASK ERROR:\s*(.+)$", text, re.MULTILINE) if m_taskerr: guest["status"] = "failed" guest["error"] = m_taskerr.group(1).strip() result["job_status"] = "fatal" result["job_error"] = m_taskerr.group(1).strip() result["guests"].append(guest) return result def parse_log(text: str, source: str = "pve") -> dict: """Dispatcher. 'source' = 'pve' oder 'pbs'.""" if source == "pbs": return parse_pbs_log(text) return parse_pve_log(text) # --------------------------------------------------------------------------- # UPID-Filename parsen # --------------------------------------------------------------------------- # PVE schreibt UPIDs heute als # UPID:::::::: # Ältere Doku zeigt ein zusätzliches -Feld vor . Wir # parsen über Split + Anker auf '@' im User-Feld – dann sind beide Varianten # (7- und 8-Feld) abgedeckt. def parse_upid(filename: str) -> dict | None: if not filename.startswith("UPID:"): return None rest = filename[5:] if rest.endswith(":"): rest = rest[:-1] parts = rest.split(":") # User-Feld enthält immer ein '@' (z.B. root@pam) – stabilster Anker. user_idx = next((i for i, p in enumerate(parts) if "@" in p), None) if user_idx is None or user_idx < 4: return None try: pid = int(parts[1], 16) pstart = int(parts[2], 16) starttime = int(parts[user_idx - 3], 16) # letztes Hex vor type except (ValueError, IndexError): return None return { "node": parts[0], "pid": pid, "pstart": pstart, "starttime": starttime, "worker_type": parts[user_idx - 2], "worker_id": parts[user_idx - 1], # leer = Job über mehrere Gäste "user": parts[user_idx], } # --------------------------------------------------------------------------- # Cache + Scan # --------------------------------------------------------------------------- # key = absoluter Pfad, value = dict(mtime, upid, parsed, filename) _cache: dict[str, dict] = {} _scan_stats: dict = { "subdirs_total": 0, "subdirs_unreadable": 0, "files_total": 0, "files_vzdump": 0, "unreadable_examples": [], } def scan_tasks() -> None: """Scannt PVE- und PBS-Task-Verzeichnisse. Cached unveränderte Dateien.""" _scan_stats.update(subdirs_total=0, subdirs_unreadable=0, files_total=0, files_vzdump=0) _scan_stats["unreadable_examples"] = [] seen: set[str] = set() for source_label, src_dir, name_filter in SOURCES: if not src_dir.is_dir(): continue try: subdirs = list(os.scandir(src_dir)) except PermissionError: continue for sub in subdirs: if not sub.is_dir(): continue _scan_stats["subdirs_total"] += 1 try: entries = list(os.scandir(sub.path)) except PermissionError: _scan_stats["subdirs_unreadable"] += 1 if len(_scan_stats["unreadable_examples"]) < 3: _scan_stats["unreadable_examples"].append(sub.path) continue for entry in entries: name = entry.name if not name.startswith("UPID:"): continue _scan_stats["files_total"] += 1 if name_filter not in name: continue if not entry.is_file(): continue _scan_stats["files_vzdump"] += 1 key = entry.path seen.add(key) try: mtime = entry.stat().st_mtime except OSError: continue cached = _cache.get(key) if cached and cached["mtime"] == mtime: continue try: with open(entry.path, "r", encoding="utf-8", errors="replace") as fh: text = fh.read() except OSError: continue _cache[key] = { "mtime": mtime, "upid": parse_upid(name), "parsed": parse_log(text, source=source_label), "filename": name, "source": source_label, } for k in list(_cache.keys()): if k not in seen: del _cache[k] def task_dir_status() -> tuple[bool, str]: """Sanity-Check: mindestens eine Quelle erreichbar?""" if not SOURCES: return False, ( f"Weder {PVE_TASK_DIR} noch {PBS_TASK_DIR} existieren. " f"Bitte PVE_TASK_DIR / PBS_TASK_DIR setzen." ) bad = [] for label, src_dir, _ in SOURCES: if not os.access(src_dir, os.R_OK | os.X_OK): bad.append(f"{src_dir} (Quelle: {label})") if bad: return False, ( "Keine Leseberechtigung auf: " + ", ".join(bad) + ". App als Benutzer www-data bzw. backup starten." ) return True, "" # --------------------------------------------------------------------------- # Hilfsfunktionen # --------------------------------------------------------------------------- def fmt_bytes(b) -> str: if b is None: return "—" units = ("B", "KiB", "MiB", "GiB", "TiB") i, n = 0, float(b) while n >= 1024 and i < len(units) - 1: n /= 1024 i += 1 return f"{n:.1f} {units[i]}" def fmt_duration(s) -> str: if s is None: return "—" if s < 60: return f"{s}s" if s < 3600: return f"{s // 60}m {s % 60:02d}s" return f"{s // 3600}h {(s % 3600) // 60:02d}m {s % 60:02d}s" def entry_starttime(entry: dict) -> str: """Liefert den menschenlesbaren Startzeitpunkt eines Task-Eintrags.""" parsed = entry.get("parsed") or {} if parsed.get("job_time"): return parsed["job_time"] upid = entry.get("upid") or {} if upid.get("starttime"): return datetime.fromtimestamp(upid["starttime"]).strftime("%Y-%m-%d %H:%M:%S") return "—" def entry_kind(entry: dict) -> str: """'Job (alle)' bei leerer worker_id, sonst 'VM/CT '.""" upid = entry.get("upid") or {} wid = upid.get("worker_id", "") return f"VM/CT {wid}" if wid else "Job (alle)" app.jinja_env.filters["bytes"] = fmt_bytes app.jinja_env.filters["dur"] = fmt_duration # --------------------------------------------------------------------------- # Templates # --------------------------------------------------------------------------- BASE_CSS = r""" :root { --bg: #0d1117; --panel: #161b22; --panel-2: #1c232c; --border: #2d333b; --text: #e6edf3; --muted: #7d8590; --accent: #ff7a45; --accent-2: #f59e0b; --ok: #3fb950; --warn: #d29922; --bad: #f85149; --mono: "IBM Plex Mono", ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace; --sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif; } @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); * { box-sizing: border-box; } html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.55; } body { background: radial-gradient(1200px 600px at 80% -10%, rgba(255,122,69,.08), transparent 60%), radial-gradient(900px 500px at -10% 110%, rgba(245,158,11,.05), transparent 60%), var(--bg); min-height: 100vh; } .wrap { max-width: 1200px; margin: 0 auto; padding: 32px 24px 80px; } header.top { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid var(--border); padding-bottom: 20px; margin-bottom: 32px; } header.top h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; } header.top h1 .dot { color: var(--accent); margin-right: 8px; } header.top nav a { color: var(--muted); text-decoration: none; font-family: var(--mono); font-size: 12px; margin-left: 20px; transition: color .15s; } header.top nav a:hover { color: var(--text); } header.top .path { color: var(--muted); font-family: var(--mono); font-size: 11px; flex: 1; text-align: center; } .crumbs { font-family: var(--mono); font-size: 12px; color: var(--muted); margin-bottom: 16px; } .crumbs a { color: var(--muted); text-decoration: none; } .crumbs a:hover { color: var(--accent); } .crumbs .sep { margin: 0 8px; opacity: .5; } h2 { font-size: 18px; font-weight: 600; margin: 0 0 4px; letter-spacing: -0.01em; } .subtitle { color: var(--muted); font-family: var(--mono); font-size: 12px; margin-bottom: 24px; } table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } thead th { text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); background: var(--panel-2); padding: 12px 16px; border-bottom: 1px solid var(--border); } tbody td { padding: 12px 16px; border-bottom: 1px solid var(--border); font-family: var(--mono); font-size: 13px; } tbody tr:last-child td { border-bottom: none; } tbody tr { transition: background .12s; } tbody tr:hover { background: var(--panel-2); } tbody tr.clickable { cursor: pointer; } a.id-link { color: var(--accent); text-decoration: none; font-weight: 500; } a.id-link:hover { text-decoration: underline; } a.file-link { color: var(--text); text-decoration: none; font-weight: 500; } a.file-link:hover { color: var(--accent); } .pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-family: var(--mono); letter-spacing: 0.02em; } .pill.ct { background: rgba(63,185,80,.12); color: #6fd37e; border: 1px solid rgba(63,185,80,.25); } .pill.vm { background: rgba(88,166,255,.12); color: #79b8ff; border: 1px solid rgba(88,166,255,.25); } .pill.run { background: rgba(63,185,80,.10); color: #6fd37e; } .pill.stop{ background: rgba(210,153,34,.12); color: #e0a93d; } .pill.fail{ background: rgba(248,81,73,.15); color: #ff8b86; border: 1px solid rgba(248,81,73,.4); } .pill.job { background: rgba(255,122,69,.12); color: #ff8c5a; border: 1px solid rgba(255,122,69,.25); } .pill.single { background: rgba(125,133,144,.15); color: #b1bac4; border: 1px solid rgba(125,133,144,.25); } .pill.status-ok { background: rgba(63,185,80,.10); color: #6fd37e; } .pill.status-err{ background: rgba(248,81,73,.15); color: #ff8b86; } .pill.src-pve { background: rgba(255,122,69,.10); color: #ff8c5a; border: 1px solid rgba(255,122,69,.30); } .pill.src-pbs { background: rgba(88,166,255,.10); color: #79b8ff; border: 1px solid rgba(88,166,255,.30); } tr.row-fail td { background: rgba(248,81,73,.04); border-left-color: rgba(248,81,73,.4); } tr.row-fail td:first-child { box-shadow: inset 3px 0 0 0 #f85149; } .error-msg { color: #ff8b86; font-style: italic; white-space: normal; } .banner-err { background: rgba(248,81,73,.10); border: 1px solid rgba(248,81,73,.35); color: #ffb1ad; padding: 14px 18px; border-radius: 8px; margin-bottom: 24px; font-family: var(--mono); font-size: 12.5px; } .banner-err strong { color: #ffd1cd; font-weight: 600; } .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; } .kpi { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } .kpi .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 6px; } .kpi .value { font-family: var(--mono); font-size: 20px; font-weight: 500; color: var(--text); } .kpi .value.accent { color: var(--accent); } .empty { text-align: center; padding: 60px 20px; background: var(--panel); border: 1px dashed var(--border); border-radius: 8px; color: var(--muted); } .empty code, .codey { font-family: var(--mono); color: var(--accent-2); background: var(--panel-2); padding: 2px 6px; border-radius: 4px; } .alert { background: rgba(248,81,73,.08); border: 1px solid rgba(248,81,73,.3); color: #ffb1ad; padding: 14px 18px; border-radius: 8px; margin-bottom: 24px; font-family: var(--mono); font-size: 12px; } .chart-box { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 24px; } .chart-box h3 { margin: 0 0 16px; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } .chart-canvas-wrap { position: relative; height: 220px; width: 100%; } .muted { color: var(--muted); } footer { margin-top: 60px; font-family: var(--mono); font-size: 11px; color: var(--muted); text-align: center; opacity: .6; } """ PAGE_SHELL = r""" {{ title }} – PVE Log Viewer {% block head_extra %}{% endblock %}

PVE Log Viewer

{{ task_dir }}
{% block body %}{% endblock %}
vzdump / proxmox-backup-client log parser · {{ entry_count }} Task-Logs gecached · Scan: {{ stats.subdirs_total }} Subdirs ({{ stats.subdirs_unreadable }} unlesbar), {{ stats.files_total }} UPID-Files, davon {{ stats.files_vzdump }} vzdump
""" INDEX_TPL = PAGE_SHELL.replace("{% block body %}{% endblock %}", r""" {% if error %}
{{ error }}
{% endif %} {% if not items and stats.subdirs_unreadable %}
{{ stats.subdirs_unreadable }} von {{ stats.subdirs_total }} Bucket-Verzeichnissen konnten nicht gelesen werden (Permission denied). Beispiele: {{ stats.unreadable_examples|join(', ') }}.
Lösung: App als www-data starten – siehe systemd-Unit im Kopf von app.py.
{% elif not items and stats.files_total and not stats.files_vzdump %}
{{ stats.files_total }} UPID-Files gefunden, aber keine vzdump-Tasks darunter. Vielleicht liegen die Backup-Logs auf einem anderen Knoten oder die Retention hat sie schon aufgeräumt.
{% endif %}

Backup-Protokolle

{{ items|length }} vzdump-Task{{ '' if items|length == 1 else 's' }} aus {{ task_dir }}

{% if items %} {% for it in items %} {% endfor %}
Startzeit Quelle Knoten Art Worker Status Gäste
{{ it.starttime }} {{ it.source|upper }} {{ it.upid.node if it.upid else '—' }} {{ 'Job (alle)' if not it.worker_disp else 'Einzeln' }} {{ it.worker_disp or '—' }} {% if it.job_status == 'ok' %} OK {% else %} {{ it.failed_count }} Fehler {% endif %} {{ it.guest_count }}
{% else %}
Keine vzdump-Task-Logs gefunden.

Erwartet werden Dateien UPID:<node>:…:vzdump:… unter {{ task_dir }}/<XX>/.
{% endif %} """) LOG_TPL = PAGE_SHELL.replace("{% block body %}{% endblock %}", r"""
Logs / {{ starttime }} · {{ kind }}

{{ starttime }}

{{ data.source|upper }} {% if data.source == 'pbs' %}  Datastore {{ data.datastore or '—' }} · von {{ data.source_ip or '—' }} {% if upid %} · {{ upid.user }}{% endif %} {% else %}  {{ upid.node if upid else '—' }} · {{ upid.worker_type if upid else '—' }}{% if upid and upid.worker_id %}:{{ upid.worker_id }}{% endif %} · {{ upid.user if upid else '—' }} {% endif %}

{% if data.job_status != 'ok' %} {% endif %}
Gäste
{{ data.guests|length }}
Fehlgeschlagen
{{ failed_count }}
Gesamtdauer
{{ total_duration|dur }}
Übertragen (Σ)
{{ total_changed|bytes }}
Gespeichert (Σ)
{{ total_saved|bytes }}
Daten (Σ)
{{ total_size|bytes }}
{% for g in data.guests %} {% if g.error %} {% else %} {% endif %} {% endfor %}
ID Name Typ Status Dauer Übertragen Gespeichert Reused Gesamt
{{ g.id }} {{ g.name }} {{ g.type }} {{ g.status }} {{ g.duration_sec|dur }}{{ g.error }}{{ g.changed_bytes|bytes }} {{ g.saved_bytes|bytes }} {{ ((g.reused_bytes|bytes) ~ ' · ' ~ ('%.1f' % g.reused_pct) ~ ' %') if g.reused_pct is not none else '—' }} {{ g.total_bytes|bytes }}
""") VM_TPL = PAGE_SHELL.replace( "{% block head_extra %}{% endblock %}", '', ).replace("{% block body %}{% endblock %}", r"""
Logs / VM/CT {{ vm_id }}

{{ name }} #{{ vm_id }}

Entwicklung über {{ series|length }} Backup-Lauf{{ '' if series|length == 1 else 'läufe' }}

{% if series %}

Datenmenge pro Lauf

Backup-Dauer pro Lauf

{% for s in series %} {% if s.error %} {% else %} {% endif %} {% endfor %}
Startzeit Status Dauer Übertragen Gespeichert Reused Gesamt
{{ s.starttime }} {{ s.status }} {{ s.duration_sec|dur }}{{ s.error }}{{ s.changed_bytes|bytes }} {{ s.saved_bytes|bytes }} {{ ((s.reused_bytes|bytes) ~ ' · ' ~ ('%.1f' % s.reused_pct) ~ ' %') if s.reused_pct is not none else '—' }} {{ s.total_bytes|bytes }}
{% else %}
Keine Backups für ID {{ vm_id }} gefunden.
{% endif %} """) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- def _common_ctx() -> dict: src_summary = ", ".join( f"{lbl}:{p}" for lbl, p, _ in SOURCES ) or "(keine)" return { "BASE_CSS": BASE_CSS, "task_dir": src_summary, "entry_count": len(_cache), "stats": _scan_stats, } @app.route("/") def index(): ok, err = task_dir_status() if ok: scan_tasks() items = [] for entry in _cache.values(): parsed = entry.get("parsed") or {} guests = parsed.get("guests", []) source = entry.get("source", "pve") upid = entry.get("upid") or {} # Worker-Anzeige: bei PBS aus dem Snapshot-Target ableiten, bei PVE # roh aus dem UPID (entweder VMID oder leer). if source == "pbs" and guests: g0 = guests[0] worker_disp = f"{g0['type'].lower()}/{g0['id']}" else: worker_disp = upid.get("worker_id") or "" items.append({ "filename": entry["filename"], "starttime": entry_starttime(entry), "upid": upid, "guest_count": len(guests), "failed_count": sum(1 for g in guests if g.get("error")), "job_status": parsed.get("job_status", "ok"), "source": source, "worker_disp": worker_disp, "sort_key": upid.get("starttime", 0), }) items.sort(key=lambda x: x["sort_key"], reverse=True) return render_template_string( INDEX_TPL, title="Übersicht", items=items, error=err if not ok else None, **_common_ctx(), ) @app.route("/log/") def show_log(filename): scan_tasks() entry = next( (e for e in _cache.values() if e["filename"] == filename), None, ) if entry is None: abort(404) data = entry["parsed"] total_duration = sum((g["duration_sec"] or 0) for g in data["guests"]) total_changed = sum((g["changed_bytes"] or 0) for g in data["guests"]) total_saved = sum((g["saved_bytes"] or 0) for g in data["guests"]) total_size = sum((g["total_bytes"] or 0) for g in data["guests"]) failed_count = sum(1 for g in data["guests"] if g.get("error")) return render_template_string( LOG_TPL, title=entry_starttime(entry), data=data, upid=entry.get("upid"), starttime=entry_starttime(entry), kind=entry_kind(entry), total_duration=total_duration, total_changed=total_changed, total_saved=total_saved, total_size=total_size, failed_count=failed_count, **_common_ctx(), ) @app.route("/vm/") def show_vm(vm_id): scan_tasks() series = [] for entry in _cache.values(): parsed = entry.get("parsed") or {} upid = entry.get("upid") or {} for g in parsed.get("guests", []): if g["id"] == vm_id: series.append({ "filename": entry["filename"], "starttime": entry_starttime(entry), "sort_key": upid.get("starttime", 0), **g, }) series.sort(key=lambda x: x["sort_key"]) name = series[0]["name"] if series else f"#{vm_id}" chart_labels = [s["starttime"][:16] for s in series] chart_changed = [ round((s["changed_bytes"] or 0) / (1024 ** 2), 1) for s in series ] chart_saved = [ round((s["saved_bytes"] or 0) / (1024 ** 2), 1) for s in series ] chart_duration = [s["duration_sec"] or 0 for s in series] return render_template_string( VM_TPL, title=f"VM/CT {vm_id}", vm_id=vm_id, name=name, series=series, chart_labels=chart_labels, chart_changed=chart_changed, chart_saved=chart_saved, chart_duration=chart_duration, **_common_ctx(), ) # --------------------------------------------------------------------------- if __name__ == "__main__": print("PVE Log Viewer") for label, src_dir, _ in SOURCES: print(f" {label.upper():<3} {src_dir}") if not SOURCES: print(" Keine Log-Verzeichnisse gefunden. Setze PVE_TASK_DIR / PBS_TASK_DIR.") else: ok, err = task_dir_status() if not ok: print(f" WARNUNG: {err}") else: print(" initialer Scan ...") scan_tasks() print(f" {len(_cache)} Task-Logs gecached") print(f" http://{LISTEN_HOST}:{LISTEN_PORT}") app.run(host=LISTEN_HOST, port=LISTEN_PORT, debug=False)