This commit is contained in:
Mistral
2026-07-19 15:47:22 +02:00
parent 9291f2c657
commit 239ad0a8b1
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env python3
"""Homelab Status Monitor v1.1.0"""
import socket
import ssl
import urllib.request
import json
import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "1.1.0"
# Service configuration: name, type (ssh/http/https), host, port, url (for http/https), insecure (for https)
SERVICES = {
# Containers LXC
"ct101-mqtt": {"name": "MQTT", "type": "ssh", "host": "192.168.0.5", "port": 22},
"ct101-mqtt-http": {"name": "MQTT HTTP", "type": "http", "host": "192.168.0.5", "port": 1883, "url": "/"},
"ct102-frigate": {"name": "Frigate", "type": "ssh", "host": "192.168.0.91", "port": 22},
"ct102-frigate-http": {"name": "Frigate Web", "type": "http", "host": "192.168.0.91", "port": 5000, "url": "/"},
"ct103-adguard": {"name": "AdGuard", "type": "ssh", "host": "192.168.0.92", "port": 22},
"ct103-adguard-http": {"name": "AdGuard Web", "type": "http", "host": "192.168.0.92", "port": 80, "url": "/"},
"ct103-adguard-https": {"name": "AdGuard HTTPS", "type": "https", "host": "192.168.0.92", "port": 443, "url": "/", "insecure": True},
"ct105-alpine": {"name": "Alpine-Docker", "type": "ssh", "host": "192.168.0.155", "port": 22},
"ct105-alpine-docker": {"name": "Alpine Docker API", "type": "http", "host": "192.168.0.155", "port": 2375, "url": "/"},
"ct106-zigbee": {"name": "Zigbee2MQTT", "type": "ssh", "host": "192.168.0.177", "port": 22},
"ct106-zigbee-http": {"name": "Zigbee2MQTT Web", "type": "http", "host": "192.168.0.177", "port": 8081, "url": "/"},
"ct200-traefik": {"name": "Traefik Proxy", "type": "ssh", "host": "192.168.0.100", "port": 22},
"ct200-traefik-http": {"name": "Traefik HTTP", "type": "http", "host": "192.168.0.100", "port": 80, "url": "/"},
"ct200-traefik-https": {"name": "Traefik HTTPS", "type": "https", "host": "192.168.0.100", "port": 443, "url": "/", "insecure": True},
"ct201-status": {"name": "Status Monitor", "type": "ssh", "host": "192.168.0.201", "port": 22},
"ct201-status-http": {"name": "Status Monitor Web", "type": "http", "host": "192.168.0.201", "port": 80, "url": "/"},
# VM
"vm100-ha": {"name": "Home Assistant", "type": "ssh", "host": "192.168.0.43", "port": 22},
"vm100-ha-http": {"name": "Home Assistant Web", "type": "http", "host": "192.168.0.43", "port": 8123, "url": "/"},
# Proxmox hosts
"prox": {"name": "Proxmox (prox)", "type": "ssh", "host": "192.168.0.94", "port": 22},
"prox2": {"name": "Proxmox (prox2)", "type": "ssh", "host": "192.168.0.95", "port": 22},
# Raspberry Pi
"pi3": {"name": "Raspberry Pi 3", "type": "ssh", "host": "192.168.0.156", "port": 22},
# External HTTPS endpoints
"ha-peis-fr": {"name": "ha.peis.fr", "type": "https", "host": "ha.peis.fr", "port": 443, "url": "/", "insecure": True},
"traefik-dash": {"name": "traefik.peis.fr", "type": "https", "host": "traefik.peis.fr", "port": 443, "url": "/dashboard/", "insecure": True},
"test-peis-fr": {"name": "test.peis.fr", "type": "https", "host": "test.peis.fr", "port": 443, "url": "/", "insecure": True},
"status-peis-fr": {"name": "status.peis.fr", "type": "https", "host": "status.peis.fr", "port": 443, "url": "/", "insecure": True},
}
status_history = {}
status_cache = {}
lock = threading.Lock()
def check_ssh(host, port, timeout=3):
"""Check if SSH port is open."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
result = sock.connect_ex((host, port))
sock.close()
if result == 0:
return {"status": "online", "error": None}
else:
return {"status": "offline", "error": f"Connection refused (code: {result})"}
except socket.timeout:
return {"status": "timeout", "error": "Connection timed out"}
except Exception as e:
return {"status": "offline", "error": str(e)[:100]}
def check_http(host, port, url="/", timeout=5):
"""Check HTTP endpoint."""
try:
full_url = f"http://{host}:{port}{url}"
req = urllib.request.Request(full_url, headers={"User-Agent": "StatusMon"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return {"status": "online", "status_code": r.status, "error": None}
except urllib.error.HTTPError as e:
return {"status": "online", "status_code": e.code, "error": None} # HTTP error still means service is up
except Exception as e:
return {"status": "offline", "error": str(e)[:100]}
def check_https(host, port, url="/", timeout=5, insecure=False):
"""Check HTTPS endpoint."""
try:
full_url = f"https://{host}:{port}{url}"
req = urllib.request.Request(full_url, headers={"User-Agent": "StatusMon"})
if insecure:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
else:
ctx = ssl.create_default_context()
with urllib.request.urlopen(req, context=ctx, timeout=timeout) as r:
return {"status": "online", "status_code": r.status, "error": None}
except ssl.SSLError as e:
return {"status": "ssl_error", "error": f"SSL: {str(e)[:80]}"}
except urllib.error.HTTPError as e:
return {"status": "online", "status_code": e.code, "error": None}
except Exception as e:
return {"status": "offline", "error": str(e)[:100]}
def check_service(service_id, config):
"""Check a single service based on its type."""
st = config["type"]
if st == "ssh":
return check_ssh(config["host"], config["port"])
elif st == "http":
return check_http(config["host"], config["port"], config.get("url", "/"))
elif st == "https":
return check_https(config["host"], config["port"], config.get("url", "/"), config.get("insecure", False))
return {"status": "error", "error": f"Unknown type: {st}"}
def update_history(sid, old, new):
"""Update status change history."""
global status_history
if old != new:
status_history[sid] = {
"old_status": old,
"new_status": new,
"changed_at": datetime.now().isoformat()
}
def check_single(sid):
"""Check a single service and return its status."""
if sid not in SERVICES:
return None
result = check_service(sid, SERVICES[sid])
return {"config": SERVICES[sid], "result": result, "time": datetime.now().isoformat()}
def check_all():
"""Check all services and update cache."""
global status_cache, status_history
new_cache = {}
for sid, cfg in SERVICES.items():
result = check_service(sid, cfg)
old = status_cache.get(sid, {}).get("result", {}).get("status", "unknown")
new = result["status"]
if old != new:
update_history(sid, old, new)
new_cache[sid] = {"config": cfg, "result": result, "time": datetime.now().isoformat()}
with lock:
status_cache = new_cache
return new_cache
# HTML Template (using a function to avoid f-string escaping issues)
def generate_html():
"""Generate the HTML page."""
return '''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homelab Status Monitor v''' + VERSION + '''</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; }
h1 { color: #60a5fa; text-align: center; margin-bottom: 5px; }
.version { text-align: center; color: #64748b; font-size: 0.85em; margin-bottom: 15px; }
.controls { text-align: center; margin: 20px 0; position: relative; }
button { padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
button:hover { background: #2563eb; }
button:disabled { opacity: 0.7; cursor: not-allowed; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 15px; margin-top: 20px; }
.card { background: #1e293b; padding: 15px; border-radius: 8px; border-left: 4px solid; position: relative; }
.card.online { border-left-color: #22c55e; }
.card.offline { border-left-color: #ef4444; }
.card.timeout { border-left-color: #f97316; }
.card.ssl_error { border-left-color: #f97316; }
.card.error { border-left-color: #ef4444; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.name { font-weight: bold; font-size: 1.1em; flex: 1; }
.type { font-size: 0.8em; background: #334155; padding: 2px 8px; border-radius: 12px; color: #94a3b8; }
.badge { padding: 4px 12px; border-radius: 12px; font-size: 0.85em; font-weight: bold; }
.badge.online { background: #166534; color: #dcfce7; }
.badge.offline { background: #991b1b; color: #fca5a5; }
.badge.timeout { background: #92400e; color: #fde68a; }
.badge.ssl_error { background: #92400e; color: #fde68a; }
.badge.error { background: #991b1b; color: #fca5a5; }
.details { margin-top: 10px; font-size: 0.85em; color: #94a3b8; }
.error { color: #fca5a5; margin-top: 5px; font-size: 0.85em; word-break: break-word; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0; }
.card-sum { background: #1e293b; padding: 15px; border-radius: 8px; text-align: center; }
.card-sum .count { font-size: 2em; font-weight: bold; color: #60a5fa; }
.card-sum .label { color: #94a3b8; }
.history-note { font-size: 0.75em; color: #64748b; margin-top: 5px; }
.refresh-btn { position: absolute; top: 10px; right: 10px; background: transparent; border: none; cursor: pointer; font-size: 1.2em; padding: 0; line-height: 1; }
.refresh-btn:hover { opacity: 0.8; }
.refresh-btn:disabled { cursor: not-allowed; opacity: 0.5; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #334155; border-top-color: #60a5fa; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; }
</style>
</head>
<body>
<div class="container">
<h1>Homelab Status Monitor</h1>
<div class="version">v''' + VERSION + '''</div>
<div class="controls">
<button class="refresh-all" onclick="refreshAll()">Refresh All</button>
<button onclick="location.reload()">Reload Page</button>
<span id="ts"></span>
<span id="global-loading"></span>
</div>
<div class="summary">
<div class="card-sum"><div class="count" id="total">0</div><div class="label">Total</div></div>
<div class="card-sum"><div class="count" id="ok">0</div><div class="label">Online</div></div>
<div class="card-sum"><div class="count" id="ko">0</div><div class="label">Offline</div></div>
<div class="card-sum"><div class="count" id="err">0</div><div class="label">Errors</div></div>
</div>
<div class="grid" id="grid"></div>
</div>
<script>
let data = {};
let history = {};
let refreshInProgress = false;
function getClass(s) {
var classes = {"online": "online", "offline": "offline", "timeout": "timeout", "error": "error", "ssl_error": "ssl_error"};
return classes[s] || "offline";
}
function fmtHist(sid) {
if (history[sid]) {
var h = history[sid];
return "Changed: " + h.old_status + " -> " + h.new_status + " at " + new Date(h.changed_at).toLocaleTimeString();
}
return "";
}
function setGlobalLoad(b) {
refreshInProgress = b;
var elem = document.getElementById("global-loading");
elem.innerHTML = b ? "<span class=\"loading-spinner\"></span>" : "";
}
function render() {
let h = "";
let t = 0, ok = 0, ko = 0, err = 0;
for (const [id, v] of Object.entries(data)) {
const c = v.config, r = v.result, s = r.status;
t++;
if (s === "online") ok++;
else if (s === "offline" || s === "error" || s === "timeout" || s === "ssl_error") ko++;
else err++;
const bc = getClass(s);
const pi = c.url ? c.host + ":" + c.port + c.url : c.host + ":" + c.port;
h += "<div class=\"card " + bc + "\">";
h += "<button class=\"refresh-btn\" onclick=\"refreshService('" + id + "')\" title=\"Refresh\">🔄</button>";
h += "<div class=\"header\"><div class=\"name\">" + c.name + "</div><span class=\"type\">" + c.type + "</span></div>";
h += "<span class=\"badge " + bc + "\">" + s.replace(/_/g, " ") + "</span>";
h += "<div class=\"details\">" + pi + "</div>";
if (r.error) h += "<div class=\"error\">" + r.error + "</div>";
if (r.status_code) h += "<div class=\"details\">Status: " + r.status_code + "</div>";
h += "<div class=\"details\">Checked: " + new Date(v.time).toLocaleTimeString() + "</div>";
const hist = fmtHist(id);
if (hist) h += "<div class=\"history-note\">" + hist + "</div>";
h += "</div>";
}
document.getElementById("grid").innerHTML = h || "<p style=\"color:#64748b;text-align:center;\">No services to display</p>";
document.getElementById("total").textContent = t;
document.getElementById("ok").textContent = ok;
document.getElementById("ko").textContent = ko;
document.getElementById("err").textContent = err;
document.getElementById("ts").textContent = "Updated: " + new Date().toLocaleTimeString();
}
function refreshService(sid) {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = "<span class=\"loading-spinner\"></span>";
fetch("/api/check/" + encodeURIComponent(sid))
.then(r => r.json())
.then(d => {
data[sid] = d;
if (d.history) history[sid] = d.history;
render();
})
.catch(err => console.error("Error:", err))
.finally(() => {
btn.disabled = false;
btn.innerHTML = "🔄";
});
}
function refreshAll() {
const btn = document.querySelector(".refresh-all");
setGlobalLoad(true);
btn.disabled = true;
btn.innerHTML = "<span class=\"loading-spinner\"></span> Refreshing...";
fetch("/api/refresh")
.then(r => r.json())
.then(d => {
data = d.data;
history = d.history || {};
render();
})
.catch(err => console.error("Error:", err))
.finally(() => {
setGlobalLoad(false);
btn.disabled = false;
btn.innerHTML = "Refresh All";
});
}
// Initial load
fetch("/api/status")
.then(r => r.json())
.then(d => {
data = d.data;
history = d.history || {};
render();
})
.catch(err => console.error("Error:", err));
// Auto-refresh every 30 seconds
setInterval(function() {
setGlobalLoad(true);
fetch("/api/status")
.then(r => r.json())
.then(function(d) {
data = d.data;
history = d.history || {};
render();
})
.catch(function(err) {
console.error("Error:", err);
})
.finally(function() {
setGlobalLoad(false);
});
}, 30000);
</script>
</body>
</html>'''
class Handler(BaseHTTPRequestHandler):
"""HTTP request handler."""
def log_message(self, *args):
"""Suppress default logging."""
pass
def do_GET(self):
"""Handle GET requests."""
try:
if self.path in ['/', '/index.html']:
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(generate_html().encode('utf-8'))
elif self.path == '/api/status':
check_all() # Always refresh cache
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.send_header('Cache-Control', 'no-cache')
self.end_headers()
self.wfile.write(json.dumps({"data": status_cache, "history": status_history}, default=str).encode('utf-8'))
elif self.path == '/api/refresh':
result = check_all()
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({"data": result, "history": status_history}, default=str).encode('utf-8'))
elif self.path.startswith('/api/check/'):
sid = self.path.split('/')[-1]
if sid in SERVICES:
old = status_cache.get(sid, {}).get("result", {}).get("status", "unknown")
rd = check_single(sid)
new_st = rd["result"]["status"]
if old != new_st:
update_history(sid, old, new_st)
with lock:
status_cache[sid] = rd
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({**rd, "history": status_history.get(sid)}, default=str).encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
except Exception as e:
self.send_response(500)
self.send_header('Content-type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
def main():
"""Main entry point."""
print(f"Starting Status Monitor v{VERSION}")
check_all()
server = ThreadingHTTPServer(('', 80), Handler)
print(f'Status Monitor v{VERSION} running on port 80')
server.serve_forever()
if __name__ == '__main__':
main()