feat: rebuild xiteng.site homelab platform
This commit is contained in:
+226
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Expose a small, sanitized snapshot of host CPU, memory, disk, and NVIDIA GPU usage."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PORT = int(os.environ.get("PORT", "8092"))
|
||||
HOST_PROC = Path(os.environ.get("HOST_PROC", "/host/proc"))
|
||||
DISK_PATH = Path(os.environ.get("DISK_PATH", "/host/disk"))
|
||||
CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "2"))
|
||||
|
||||
cache_lock = threading.Lock()
|
||||
cached_snapshot = None
|
||||
cached_at = 0.0
|
||||
previous_cpu = None
|
||||
|
||||
|
||||
def read_text(path):
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def read_cpu_times():
|
||||
first_line = read_text(HOST_PROC / "stat").splitlines()[0]
|
||||
values = [int(value) for value in first_line.split()[1:]]
|
||||
idle = values[3] + (values[4] if len(values) > 4 else 0)
|
||||
return sum(values), idle
|
||||
|
||||
|
||||
def cpu_usage_percent():
|
||||
global previous_cpu
|
||||
|
||||
current = read_cpu_times()
|
||||
if previous_cpu is None:
|
||||
previous_cpu = current
|
||||
time.sleep(0.12)
|
||||
current = read_cpu_times()
|
||||
|
||||
total_delta = current[0] - previous_cpu[0]
|
||||
idle_delta = current[1] - previous_cpu[1]
|
||||
previous_cpu = current
|
||||
if total_delta <= 0:
|
||||
return 0.0
|
||||
return round(max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100)), 1)
|
||||
|
||||
|
||||
def cpu_info():
|
||||
cpuinfo = read_text(HOST_PROC / "cpuinfo")
|
||||
model = "Unknown CPU"
|
||||
logical_cores = 0
|
||||
for line in cpuinfo.splitlines():
|
||||
if line.startswith("processor"):
|
||||
logical_cores += 1
|
||||
elif line.startswith("model name") and model == "Unknown CPU":
|
||||
model = line.split(":", 1)[1].strip()
|
||||
|
||||
load_values = read_text(HOST_PROC / "loadavg").split()
|
||||
return {
|
||||
"usagePercent": cpu_usage_percent(),
|
||||
"logicalCores": logical_cores,
|
||||
"model": model,
|
||||
"load1": float(load_values[0]),
|
||||
"load5": float(load_values[1]),
|
||||
"load15": float(load_values[2]),
|
||||
}
|
||||
|
||||
|
||||
def memory_info():
|
||||
values = {}
|
||||
for line in read_text(HOST_PROC / "meminfo").splitlines():
|
||||
key, raw_value = line.split(":", 1)
|
||||
values[key] = int(raw_value.strip().split()[0]) * 1024
|
||||
|
||||
total = values["MemTotal"]
|
||||
available = values["MemAvailable"]
|
||||
used = total - available
|
||||
return {
|
||||
"usedBytes": used,
|
||||
"availableBytes": available,
|
||||
"totalBytes": total,
|
||||
"usagePercent": round(used / total * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
def root_device():
|
||||
for line in read_text(HOST_PROC / "1" / "mountinfo").splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) < 10 or fields[4] != "/" or "-" not in fields:
|
||||
continue
|
||||
separator = fields.index("-")
|
||||
if len(fields) > separator + 2:
|
||||
return fields[separator + 2]
|
||||
return "root filesystem"
|
||||
|
||||
|
||||
def disk_info():
|
||||
stats = os.statvfs(DISK_PATH)
|
||||
total = stats.f_blocks * stats.f_frsize
|
||||
free = stats.f_bfree * stats.f_frsize
|
||||
available = stats.f_bavail * stats.f_frsize
|
||||
used = total - free
|
||||
return {
|
||||
"device": root_device(),
|
||||
"mount": "/",
|
||||
"usedBytes": used,
|
||||
"availableBytes": available,
|
||||
"totalBytes": total,
|
||||
"usagePercent": round(used / total * 100, 1),
|
||||
}
|
||||
|
||||
|
||||
def optional_float(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def gpu_info():
|
||||
query = ",".join([
|
||||
"index",
|
||||
"name",
|
||||
"utilization.gpu",
|
||||
"memory.used",
|
||||
"memory.total",
|
||||
"temperature.gpu",
|
||||
"power.draw",
|
||||
"power.limit",
|
||||
])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
f"--query-gpu={query}",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError):
|
||||
return {"available": False, "devices": []}
|
||||
|
||||
devices = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = [field.strip() for field in line.split(",")]
|
||||
if len(fields) != 8:
|
||||
continue
|
||||
devices.append({
|
||||
"index": int(fields[0]),
|
||||
"name": fields[1],
|
||||
"utilizationPercent": optional_float(fields[2]),
|
||||
"memoryUsedMiB": optional_float(fields[3]),
|
||||
"memoryTotalMiB": optional_float(fields[4]),
|
||||
"temperatureCelsius": optional_float(fields[5]),
|
||||
"powerDrawWatts": optional_float(fields[6]),
|
||||
"powerLimitWatts": optional_float(fields[7]),
|
||||
})
|
||||
|
||||
return {"available": bool(devices), "devices": devices}
|
||||
|
||||
|
||||
def host_name():
|
||||
hostname_path = Path("/host/hostname")
|
||||
if hostname_path.exists():
|
||||
return read_text(hostname_path).strip()
|
||||
return "homelab"
|
||||
|
||||
|
||||
def collect_snapshot():
|
||||
global cached_snapshot, cached_at
|
||||
|
||||
now = time.monotonic()
|
||||
with cache_lock:
|
||||
if cached_snapshot is not None and now - cached_at < CACHE_TTL_SECONDS:
|
||||
return cached_snapshot
|
||||
|
||||
cached_snapshot = {
|
||||
"generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"hostname": host_name(),
|
||||
"cpu": cpu_info(),
|
||||
"memory": memory_info(),
|
||||
"disk": disk_info(),
|
||||
"gpu": gpu_info(),
|
||||
}
|
||||
cached_at = time.monotonic()
|
||||
return cached_snapshot
|
||||
|
||||
|
||||
class MetricsHandler(BaseHTTPRequestHandler):
|
||||
def send_json(self, status_code, payload):
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
self.send_response(status_code)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/healthz":
|
||||
self.send_json(200, {"status": "ok"})
|
||||
return
|
||||
if self.path != "/metrics":
|
||||
self.send_json(404, {"error": "Not found"})
|
||||
return
|
||||
|
||||
try:
|
||||
self.send_json(200, collect_snapshot())
|
||||
except Exception as error:
|
||||
print(f"metrics collection failed: {error}", flush=True)
|
||||
self.send_json(503, {"error": "Metrics unavailable"})
|
||||
|
||||
def log_message(self, message_format, *args):
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("0.0.0.0", PORT), MetricsHandler).serve_forever()
|
||||
Reference in New Issue
Block a user