Initial commit: Windmill workspace sync

Scripts, flows, apps, resources and resource types from the Windmill workspace.
API token excluded via .gitignore (config/).
This commit is contained in:
Sebastian Serfling
2026-04-24 09:06:07 +02:00
commit 2b5d29ef67
302 changed files with 9229 additions and 0 deletions
@@ -0,0 +1,87 @@
summary: Proxmox Backup Webhook
description: |
Empfängt Backup-Benachrichtigungen von Proxmox via Webhook,
speichert alle VM-Backups in MySQL und sendet eine Zusammenfassung
an Nextcloud Talk.
value:
modules:
- id: a
summary: Payload parsen & aufbereiten
value:
type: rawscript
content: '!inline payload_parsen_&_aufbereiten.py'
input_transforms:
message:
type: javascript
expr: flow_input.message || ''
severity:
type: javascript
expr: flow_input.severity || ''
title:
type: javascript
expr: flow_input.title || ''
debug_mode:
type: javascript
expr: flow_input.debug_mode || false
lock: '!inline payload_parsen_&_aufbereiten.lock'
language: python3
- id: b
summary: In MySQL speichern
value:
type: rawscript
content: '!inline in_mysql_speichern.py'
input_transforms:
backups:
type: javascript
expr: results.a.backups
batch_id:
type: javascript
expr: results.a.batch_id
raw_payload:
type: javascript
expr: results.a.raw_payload
total_size:
type: javascript
expr: results.a.total_size
total_time:
type: javascript
expr: results.a.total_time
debug_mode:
type: javascript
expr: flow_input.debug_mode || false
lock: '!inline in_mysql_speichern.lock'
language: python3
- id: c
summary: Nachricht an Nextcloud Talk
value:
type: rawscript
content: '!inline nachricht_an_nextcloud_talk.py'
input_transforms:
talk_message:
type: javascript
expr: results.a.talk_message
debug_mode:
type: javascript
expr: flow_input.debug_mode || false
lock: '!inline nachricht_an_nextcloud_talk.lock'
language: python3
schema:
$schema: https://json-schema.org/draft/2020-12/schema
type: object
properties:
message:
type: string
description: Vollständige Nachricht mit Summary und Logs
default: "Details\n=======\nVMID Name Status Time Size Filename\n127 OLV-CLOUD01-IP17.2 ok 4s 50 GiB vm/127/2026-04-22T19:43:13Z\n131 OLV-WORDP01-IP17.3 ok 3s 50 GiB vm/131/2026-04-22T19:43:17Z\n132 OLV-SMTP01-IP17.1 ok 5s 50 GiB vm/132/2026-04-22T19:43:20Z\n\nTotal running time: 12s\nTotal size: 150 GiB\n\nLogs\n====\nvzdump 127 131 132 --prune-backups 'keep-all=1' --mode snapshot\n\n127: 2026-04-22 21:43:13 INFO: Starting Backup of VM 127 (qemu)\n127: 2026-04-22 21:43:17 INFO: Finished Backup of VM 127 (00:00:04)\n\n131: 2026-04-22 21:43:17 INFO: Starting Backup of VM 131 (qemu)\n131: 2026-04-22 21:43:20 INFO: Finished Backup of VM 131 (00:00:03)\n\n132: 2026-04-22 21:43:20 INFO: Starting Backup of VM 132 (qemu)\n132: 2026-04-22 21:43:25 INFO: Finished Backup of VM 132 (00:00:05)"
severity:
type: string
description: Severity-Level (info, warning, error)
default: "info"
title:
type: string
description: Titel der Proxmox-Notification
default: "vzdump backup status (proxmox.netbird.stines.de): backup successful"
debug_mode:
type: boolean
description: "Debug-Modus: überspringt DB-Insert und Nextcloud-Nachricht"
default: false
@@ -0,0 +1,10 @@
# py: 3.12
anyio==4.13.0
certifi==2026.2.25
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.11
mysql-connector-python==9.6.0
typing-extensions==4.15.0
wmill==1.688.0
@@ -0,0 +1,51 @@
import wmill
import json
import mysql.connector
def main(backups: list, batch_id: str, total_time: str, total_size: str, raw_payload: dict, debug_mode: bool = False):
if not backups:
print("Keine Backups zum Speichern.")
return {"inserted": 0}
if debug_mode:
print(f"[DEBUG] DB-Insert übersprungen (debug_mode=True)")
print(f"[DEBUG] batch_id={batch_id} | {len(backups)} VMs | total_time={total_time!r} | total_size={total_size!r}")
for b in backups:
print(f"[DEBUG] VM {b['vmid']} ({b['vm_name']}): status={b['status']}, size={b['size']}, duration={b['duration_sec']}s")
return {"inserted": 0, "batch_id": batch_id, "debug": True}
db_cfg = json.loads(wmill.get_variable("f/Backup/mysql_config"))
conn = mysql.connector.connect(**db_cfg)
cur = conn.cursor()
inserted = 0
for b in backups:
try:
cur.execute("""
INSERT INTO Kunden.`bronze.proxmox_backup_log`
(batch_id, vmid, vm_name, status, duration_sec, size, filename, log_text, total_time, total_size, raw_payload)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
batch_id,
b['vmid'],
b['vm_name'],
b['status'],
b['duration_sec'],
b['size'],
b['filename'],
b['log_text'],
total_time,
total_size,
json.dumps(raw_payload)
))
inserted += 1
except Exception as e:
print(f"Fehler beim Insert für VM {b['vmid']}: {e}")
conn.commit()
cur.close()
conn.close()
print(f"{inserted} Backup-Einträge in DB gespeichert (batch_id: {batch_id})")
return {"inserted": inserted, "batch_id": batch_id}
@@ -0,0 +1,9 @@
# py: 3.12
anyio==4.13.0
certifi==2026.2.25
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.11
typing-extensions==4.15.0
wmill==1.688.0
@@ -0,0 +1,46 @@
import wmill
import httpx
import base64
def main(talk_message: str, debug_mode: bool = False):
if debug_mode:
print(f"[DEBUG] Nextcloud Talk übersprungen (debug_mode=True)")
print(f"[DEBUG] Nachricht die gesendet worden wäre:\n{talk_message}")
return {"status": "skipped", "debug": True}
nc_url = wmill.get_variable("f/Backup/nextcloud_talk_url").rstrip("/")
nc_room = wmill.get_variable("f/Backup/nextcloud_talk_room")
nc_user = wmill.get_variable("f/Backup/nextcloud_talk_user")
nc_password = wmill.get_variable("f/Backup/nextcloud_talk_password")
credentials = base64.b64encode(
f"{nc_user}:{nc_password}".encode()
).decode()
url = f"{nc_url}/ocs/v2.php/apps/spreed/api/v1/chat/{nc_room}"
headers = {
"Authorization": f"Basic {credentials}",
"OCS-APIREQUEST": "true",
"Content-Type": "application/json",
"Accept": "application/json",
}
payload = {"message": talk_message}
response = httpx.post(
url,
headers=headers,
json=payload,
timeout=30,
verify=False
)
print(f"HTTP: {response.status_code}")
if response.status_code not in (200, 201):
raise Exception(
f"Nextcloud Talk Fehler: {response.status_code} {response.text}"
)
print("Nachricht gesendet ✓")
return {"status": "sent", "http": response.status_code}
@@ -0,0 +1 @@
# py: 3.12
@@ -0,0 +1,174 @@
import re
import uuid
from datetime import datetime
def parse_duration(duration_str):
"""Parse '1m 21s', '48s', '4s', '1h 58s' to seconds."""
if not duration_str:
return None
total = 0
for match in re.finditer(r'(\d+)\s*(h|m|s)', duration_str):
val, unit = int(match.group(1)), match.group(2)
if unit == 'h':
total += val * 3600
elif unit == 'm':
total += val * 60
elif unit == 's':
total += val
return total if total > 0 else None
def main(title: str = "", message: str = "", severity: str = "", debug_mode: bool = False):
batch_id = str(uuid.uuid4())[:8]
if debug_mode:
print(f"[DEBUG] title={title!r} | severity={severity!r}")
print(f"[DEBUG] message ({len(message)} Zeichen):\n{message[:2000]}")
lines = message.split('\n')
backups = []
total_time = None
total_size = None
in_details = False
in_logs = False
vm_logs = {}
current_vmid = None
for line in lines:
stripped = line.strip()
if stripped.startswith('Total running time:'):
total_time = stripped.replace('Total running time:', '').strip()
in_details = False
elif stripped.startswith('Total size:'):
total_size = stripped.replace('Total size:', '').strip()
elif stripped == 'Details':
in_details = True
continue
elif stripped == 'Logs':
in_details = False
in_logs = True
continue
elif re.match(r'^=+$', stripped):
continue
elif in_details:
# Skip header row and empty lines
if not stripped or stripped.upper().startswith('VMID'):
continue
# Split by 2+ spaces → works for both old and new Proxmox format
cols = re.split(r'\s{2,}', stripped)
if len(cols) >= 6 and cols[0].isdigit():
vmid, name, status, time_str, size_str, filename = cols[:6]
backups.append({
'vmid': int(vmid),
'vm_name': name,
'status': status.lower(),
'duration_sec': parse_duration(time_str),
'size': size_str.strip(),
'filename': filename.strip(),
'log_text': ''
})
elif in_logs:
vm_match = re.match(r'^(\d+):\s+\d{4}-\d{2}-\d{2}', line)
if vm_match:
current_vmid = vm_match.group(1)
if current_vmid not in vm_logs:
vm_logs[current_vmid] = []
if current_vmid:
vm_logs[current_vmid].append(line)
else:
# Old format without "Details" header: try direct VM row parsing
if stripped and not stripped.upper().startswith('VMID'):
cols = re.split(r'\s{2,}', stripped)
if len(cols) >= 6 and cols[0].isdigit():
vmid, name, status, time_str, size_str, filename = cols[:6]
backups.append({
'vmid': int(vmid),
'vm_name': name,
'status': status.lower(),
'duration_sec': parse_duration(time_str),
'size': size_str.strip(),
'filename': filename.strip(),
'log_text': ''
})
for backup in backups:
vmid_str = str(backup['vmid'])
if vmid_str in vm_logs:
backup['log_text'] = '\n'.join(vm_logs[vmid_str])
if debug_mode:
print(f"[DEBUG] {len(backups)} VMs geparst: {[b['vm_name'] for b in backups]}")
print(f"[DEBUG] total_time={total_time!r} | total_size={total_size!r}")
if not backups:
return {
'backups': [],
'batch_id': batch_id,
'total_time': total_time,
'total_size': total_size,
'talk_message': f"⚠️ Proxmox Webhook empfangen, aber keine VMs gefunden.\n\n```\n{message[:500]}\n```",
'raw_payload': {'title': title, 'message': message, 'severity': severity}
}
failed = [b for b in backups if b['status'] != 'ok']
ok = [b for b in backups if b['status'] == 'ok']
if len(failed) == 0:
status_icon = ""
elif len(ok) == 0:
status_icon = ""
else:
status_icon = "⚠️"
# --- Talk-Nachricht aufbauen ---
talk_lines = []
# Titel-Zeile mit Hostname und Status aus Proxmox
display_title = title if title else f"Proxmox Backup {datetime.now().strftime('%d.%m.%Y')}"
talk_lines.append(f"{status_icon} **{display_title}**")
talk_lines.append("")
# Details-Tabelle als Code-Block für korrekte Ausrichtung
talk_lines.append("```")
talk_lines.append(f"{'VMID':<6} {'Name':<22} {'Status':<8} {'Time':<6} {'Size':<10} Filename")
for b in backups:
dur = f"{b['duration_sec']}s" if b['duration_sec'] else "?"
talk_lines.append(
f"{b['vmid']:<6} {b['vm_name']:<22} {b['status']:<8} {dur:<6} {b['size']:<10} {b['filename']}"
)
talk_lines.append("```")
talk_lines.append("")
# Zusammenfassung
parts = []
if total_time:
parts.append(f"{total_time}")
if total_size:
parts.append(f"📦 {total_size}")
if parts:
talk_lines.append(" | ".join(parts))
# Fehler hervorheben
if failed:
talk_lines.append("")
talk_lines.append("**❌ Fehler bei:**")
for b in failed:
talk_lines.append(f"{b['vm_name']} ({b['vmid']})")
talk_message = '\n'.join(talk_lines)
if debug_mode:
print(f"[DEBUG] Generierte Talk-Nachricht:\n{talk_message}")
return {
'backups': backups,
'batch_id': batch_id,
'total_time': total_time,
'total_size': total_size,
'talk_message': talk_message,
'raw_payload': {'title': title, 'message': message, 'severity': severity}
}