341 lines
17 KiB
Python
Executable File
341 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import socket
|
|
import ssl
|
|
import urllib.request
|
|
import urllib.error
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
import json
|
|
from datetime import datetime
|
|
import threading
|
|
|
|
VERSION = "1.0.0"
|
|
|
|
SERVICES = {
|
|
"proxmox-prox2": {"name": "Proxmox Principal", "type": "ssh", "host": "192.168.0.95", "port": 22},
|
|
"proxmox-prox": {"name": "Proxmox Secondaire", "type": "ssh", "host": "192.168.0.94", "port": 22},
|
|
"vm100-haos": {"name": "Home Assistant OS", "type": "http", "host": "192.168.0.43", "port": 8123, "url": "/"},
|
|
"ct101-mqtt": {"name": "MQTT", "type": "ssh", "host": "192.168.0.5", "port": 22},
|
|
"ct102-frigate": {"name": "Frigate", "type": "http", "host": "192.168.0.91", "port": 5000, "url": "/"},
|
|
"ct103-adguard": {"name": "AdGuard", "type": "http", "host": "192.168.0.92", "port": 80, "url": "/"},
|
|
"ct105-alpine": {"name": "Alpine-Docker", "type": "ssh", "host": "192.168.0.155", "port": 22},
|
|
"ct106-zigbee": {"name": "Zigbee2MQTT", "type": "http", "host": "192.168.0.177", "port": 8081, "url": "/"},
|
|
"ct200-traefik": {"name": "Traefik Proxy", "type": "https", "host": "192.168.0.100", "port": 443, "url": "/", "insecure": True},
|
|
"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},
|
|
"pi3": {"name": "Raspberry Pi 3", "type": "ssh", "host": "192.168.0.156", "port": 22},
|
|
}
|
|
|
|
status_history = {}
|
|
status_cache = {}
|
|
lock = threading.Lock()
|
|
|
|
def check_ssh(host, port, timeout=3):
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
result = sock.connect_ex((host, port))
|
|
sock.close()
|
|
return {"status": "online", "error": None}
|
|
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):
|
|
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 Exception as e:
|
|
return {"status": "offline", "error": str(e)[:100]}
|
|
|
|
def check_https(host, port, url="/", timeout=5, insecure=False):
|
|
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 Exception as e:
|
|
return {"status": "offline", "error": str(e)[:100]}
|
|
|
|
def check_service(service_id, config):
|
|
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):
|
|
global status_history
|
|
if old != new:
|
|
status_history[sid] = {"old_status": old, "new_status": new, "changed_at": datetime.now().isoformat()}
|
|
|
|
def check_single(sid):
|
|
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():
|
|
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")
|
|
update_history(sid, old, result["status"])
|
|
new_cache[sid] = {"config": cfg, "result": result, "time": datetime.now().isoformat()}
|
|
with lock: status_cache = new_cache
|
|
return new_cache
|
|
|
|
print(f"Starting Status Monitor v{VERSION}")
|
|
check_all()
|
|
|
|
# Build HTML page
|
|
html_page = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Status Monitor v{VERSION}</title>
|
|
<style>
|
|
body {{ font-family: system-ui; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; }}
|
|
.container {{ max-width: 1200px; 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(300px, 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 c = {{'online': 'online', 'offline': 'offline', 'timeout': 'timeout', 'error': 'error', 'ssl_error': 'ssl_error'}};
|
|
return c[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;
|
|
document.getElementById('global-loading').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('_', ' ') + '</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();
|
|
|
|
if (refreshInProgress) document.getElementById('global-loading').innerHTML = '<span class="loading-spinner"></span>';
|
|
else document.getElementById('global-loading').innerHTML = '';
|
|
}}
|
|
|
|
function refreshService(sid) {{
|
|
const btn = event.target;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading-spinner"></span>';
|
|
fetch('/api/check/' + 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):
|
|
def log_message(self, *args): pass
|
|
def do_GET(self):
|
|
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(html_page.encode('utf-8'))
|
|
elif self.path == '/api/status':
|
|
if not status_cache: check_all()
|
|
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'))
|
|
|
|
if __name__ == '__main__':
|
|
from http.server import ThreadingHTTPServer
|
|
check_all()
|
|
server = ThreadingHTTPServer(('', 80), Handler)
|
|
print(f'Status Monitor v{VERSION} running on port 80')
|
|
server.serve_forever()
|