diff --git a/docs/services/lxc208-manage/alert/pbs.py b/docs/services/lxc208-manage/alert/pbs.py
new file mode 100644
index 0000000..14fb634
--- /dev/null
+++ b/docs/services/lxc208-manage/alert/pbs.py
@@ -0,0 +1,226 @@
+import re
+import json
+import logging
+from datetime import datetime
+from enum import Enum
+from typing import Optional, Dict, Tuple
+
+from alerts.utils import escape_html, format_size
+from alerts.alerts import ALERTS_CONFIG
+from alerts.formatters import get_formatter
+
+logger = logging.getLogger(__name__)
+
+BACKUP_CHANNEL = "backup"
+
+PBS_REQUIRED_FIELDS = {"backup_size", "guest", "timestamp"}
+
+
+class BackupSource(Enum):
+ PVE = "pve"
+ PBS = "pbs"
+
+
+def detect_backup_source(obj: dict) -> Optional[BackupSource]:
+ if not isinstance(obj, dict):
+ return None
+
+ text = (
+ str(obj.get("title", "")) +
+ "\n" +
+ str(obj.get("message", ""))
+ ).lower()
+
+ if "vzdump" in text:
+ logger.debug("Detected PVE backup source (vzdump in text)")
+ return BackupSource.PVE
+
+ if "proxmox backup" in text:
+ logger.debug("Detected PBS backup source (proxmox backup in text)")
+ return BackupSource.PBS
+
+ if "backup" in text:
+ pbs_keys = PBS_REQUIRED_FIELDS & set(obj.keys())
+ if pbs_keys:
+ logger.debug("Detected PBS backup source (keys: %s)", pbs_keys)
+ return BackupSource.PBS
+
+ return None
+
+
+def parse_size_to_bytes(size_str: str) -> Optional[float]:
+ try:
+ parts = size_str.strip().split()
+ if len(parts) != 2:
+ return None
+ value = float(parts[0])
+ unit = parts[1].upper()
+ multipliers = {
+ "B": 1,
+ "KB": 1024,
+ "KIB": 1024,
+ "MB": 1024 ** 2,
+ "MIB": 1024 ** 2,
+ "GB": 1024 ** 3,
+ "GIB": 1024 ** 3,
+ "TB": 1024 ** 4,
+ "TIB": 1024 ** 4,
+ "PB": 1024 ** 5,
+ "PIB": 1024 ** 5,
+ }
+ multiplier = multipliers.get(unit)
+ if multiplier is None:
+ return None
+ return value * multiplier
+ except Exception:
+ logger.exception("Failed to parse size")
+ return None
+
+
+def parse_pve_backup_table(message: str) -> Optional[Dict]:
+ table_pattern = r'^(\d+)\s+(\S+)\s+(ok|error|failed)\s+(\d+(?:m\s+\d+)?s)\s+([\d.]+\s+\S+)\s+((?:ct|vm)/\S+)'
+ matches = re.findall(table_pattern, message, re.MULTILINE)
+
+ if not matches:
+ return None
+
+ backups = []
+ total_size_bytes = 0
+
+ for match in matches:
+ vmid, name, status, duration, size_str, filename = match
+
+ prefix = "ct" if filename.startswith("ct/") else "vm"
+ backup_id = f"{prefix}-{vmid}"
+ size_bytes = parse_size_to_bytes(size_str)
+ if size_bytes is not None:
+ total_size_bytes += size_bytes
+
+ backups.append({
+ "backup_id": backup_id,
+ "name": name,
+ "status": status,
+ "duration": duration,
+ "size": size_str,
+ "size_bytes": size_bytes,
+ })
+
+ time_match = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', message)
+ time_str = time_match.group(1) if time_match else datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ all_success = all(b["status"] == "ok" for b in backups)
+ has_errors = any(b["status"] in ("error", "failed") for b in backups)
+
+ return {
+ "backups": backups,
+ "time": time_str,
+ "total_size": format_size(total_size_bytes),
+ "total_size_bytes": total_size_bytes,
+ "success": all_success and not has_errors,
+ "has_errors": has_errors,
+ }
+
+
+def parse_pbs_backup(obj: dict) -> Optional[Dict]:
+ node = obj.get("node", "")
+ vmid = obj.get("vmid", "") or obj.get("guest", "")
+ timestamp = obj.get("time") or obj.get("timestamp")
+ size = obj.get("size") or obj.get("backup_size")
+ status = str(obj.get("status", "unknown")).lower()
+
+ if not vmid and "backup" in obj:
+ match = re.search(r"(vm|ct)-(\d+)", obj.get("backup", ""))
+ if match:
+ vmid = f"{match.group(1)}-{match.group(2)}"
+
+ if not vmid:
+ return None
+
+ backup_id = f"{node}:{vmid}" if node else vmid
+
+ if timestamp:
+ try:
+ if isinstance(timestamp, (int, float)):
+ dt = datetime.fromtimestamp(timestamp)
+ else:
+ dt = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
+ time_str = dt.strftime("%Y-%m-%d %H:%M:%S")
+ except Exception:
+ logger.debug("Failed to parse timestamp: %s", timestamp)
+ time_str = str(timestamp)
+ else:
+ time_str = "Unknown"
+
+ return {
+ "backups": [{
+ "backup_id": backup_id,
+ "name": "",
+ "status": status,
+ "duration": "",
+ "size": format_size(size),
+ "size_bytes": 0,
+ }],
+ "time": time_str,
+ "total_size": format_size(size),
+ "total_size_bytes": 0,
+ "success": status in ("ok", "success"),
+ "has_errors": status not in ("ok", "success"),
+ }
+
+
+def parse_backup(obj: dict, source: Optional[BackupSource] = None) -> Optional[Dict]:
+ if not obj:
+ return None
+ if isinstance(obj, str):
+ try:
+ obj = json.loads(obj)
+ except Exception:
+ return None
+
+ if source is None:
+ source = detect_backup_source(obj)
+
+ if source == BackupSource.PVE:
+ return parse_pve_backup_table(obj.get("message", ""))
+ if source == BackupSource.PBS:
+ return parse_pbs_backup(obj)
+
+ return None
+
+
+def build_backup_message(info: Dict) -> Tuple[str, str]:
+ alertname = "Backup Success" if info.get("success") else "Backup Failed"
+ config = ALERTS_CONFIG.get(alertname)
+
+ if not config:
+ return (
+ "Backup notification received
" + + escape_html(json.dumps(info, ensure_ascii=False, indent=2)) + + "", + BACKUP_CHANNEL + ) + + formatter = get_formatter(config.get("formatter")) + text = f"{config['title']}\n\n" + + if formatter: + text += formatter(info) + + room = config.get("room", BACKUP_CHANNEL) + return text, room + + +def process_backup(obj: dict, send_func, source: Optional[BackupSource] = None) -> None: + info = parse_backup(obj, source) + + if not info: + send_func( + "Backup notification received
" + + escape_html(json.dumps(obj, ensure_ascii=False, indent=2)) + + "", + BACKUP_CHANNEL + ) + return + + message, room = build_backup_message(info) + send_func(message, room) \ No newline at end of file