diff options
Diffstat (limited to 'generate_stats_page.py')
| -rw-r--r-- | generate_stats_page.py | 285 |
1 files changed, 285 insertions, 0 deletions
diff --git a/generate_stats_page.py b/generate_stats_page.py new file mode 100644 index 0000000..efbd423 --- /dev/null +++ b/generate_stats_page.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +"""Generate a retro stats page for Susan. + +90s CS professor homepage aesthetic. Pure HTML, no JS. +Regenerate periodically via cron. +""" + +import datetime +import os +import subprocess +import re + +OUTPUT = "/var/www/webdav/obsidian/stats.html" + +def cmd(c): + try: + return subprocess.check_output(c, shell=True, stderr=subprocess.DEVNULL, timeout=10).decode().strip() + except: + return "" + +def get_uptime(): + up = cmd("uptime -p") + return up.replace("up ", "") if up else "unknown" + +def get_uptime_since(): + return cmd("uptime -s") + +def get_load(): + load = cmd("cat /proc/loadavg") + return load.split()[:3] if load else ["?", "?", "?"] + +def get_memory(): + mem = cmd("free -h | grep Mem") + parts = mem.split() + if len(parts) >= 7: + return {"total": parts[1], "used": parts[2], "free": parts[3], "available": parts[6]} + return {"total": "?", "used": "?", "free": "?", "available": "?"} + +def get_disk(): + disks = [] + for line in cmd("df -h /disks /home 2>/dev/null").split("\n")[1:]: + parts = line.split() + if len(parts) >= 6: + disks.append({"fs": parts[0], "size": parts[1], "used": parts[2], "avail": parts[3], "pct": parts[4], "mount": parts[5]}) + return disks + +def get_services(): + services = [] + lines = cmd("systemctl list-units --type=service --state=running --no-pager --no-legend").split("\n") + targets = ["jellyfin", "navidrome", "qbittorrent", "sonarr", "radarr", "lidarr", + "readarr", "prowlarr", "slskd", "nginx", "audiobookshelf", "openclaw-gateway"] + for line in lines: + for t in targets: + if t in line.lower(): + name = line.split()[0].replace(".service", "") + services.append(name) + return sorted(services) + +def get_media_counts(): + films = int(cmd("find /disks/Plex/Films -maxdepth 1 -type d | wc -l") or 1) - 1 + tv = int(cmd("find /disks/Plex/TV -maxdepth 1 -type d | wc -l") or 1) - 1 + anime = int(cmd("find /disks/Plex/Anime -maxdepth 1 -type d | wc -l") or 1) - 1 + tracks = int(cmd("find /disks/Plex/Music -type f \\( -name '*.flac' -o -name '*.mp3' -o -name '*.ogg' -o -name '*.opus' \\) | wc -l") or 0) + artists = int(cmd("ls -1d /disks/Plex/Music/*/ 2>/dev/null | grep -v -E 'venv|lib|bin|data|include|_|pyvenv' | wc -l") or 0) + return {"films": films, "tv": tv, "anime": anime, "tracks": tracks, "artists": artists} + +def get_cpu(): + model = cmd("grep 'model name' /proc/cpuinfo | head -1 | cut -d: -f2").strip() + cores = cmd("nproc") + return model, cores + +def get_packages(): + return cmd("dpkg -l | grep '^ii' | wc -l") + +def get_kernel(): + return cmd("uname -r") + +def get_os(): + return cmd("grep PRETTY_NAME /etc/os-release | cut -d'\"' -f2") + +def get_install_date(): + d = cmd("stat -c %w /") + if d and d != "-": + return d.split(".")[0] + return "unknown" + +COUNTER_FILE = os.path.join(os.path.dirname(__file__), "..", "data", "visitor_counter.txt") + +def get_and_increment_counter(): + """Read and increment a persistent visitor counter.""" + os.makedirs(os.path.dirname(COUNTER_FILE), exist_ok=True) + count = 0 + if os.path.exists(COUNTER_FILE): + try: + count = int(open(COUNTER_FILE).read().strip()) + except: + count = 0 + count += 1 + with open(COUNTER_FILE, "w") as f: + f.write(str(count)) + return count + +now = datetime.datetime.now() +uptime = get_uptime() +uptime_since = get_uptime_since() +load = get_load() +mem = get_memory() +disks = get_disk() +services = get_services() +media = get_media_counts() +cpu_model, cpu_cores = get_cpu() +packages = get_packages() +kernel = get_kernel() +os_name = get_os() +install_date = get_install_date() +visitor_count = get_and_increment_counter() + +html = f"""<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> +<html> +<head> +<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> +<meta http-equiv="refresh" content="300"> +<title>Susan - System Status</title> +<style type="text/css"> +body {{ + background-color: #e8e8e8; + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAMklEQVQYV2N89+7dfwYGBgZGRkYGBgYmBjIBE7mKR5UOPIUMDIyMjGQrHlU68BQCABPnC/0ZkzYUAAAAAElFTkSuQmCC"); + font-family: "Times New Roman", Times, serif; + color: #333; + margin: 20px 40px; + font-size: 14pt; +}} +h1 {{ + font-size: 22pt; + color: #003366; + border-bottom: 2px solid #003366; + padding-bottom: 4px; +}} +h2 {{ + font-size: 16pt; + color: #003366; + margin-top: 24px; +}} +table {{ + border-collapse: collapse; + margin: 8px 0; +}} +td, th {{ + border: 2px solid #003366; + padding: 4px 10px; + text-align: left; + background-color: #f5f5f0; +}} +th {{ + background-color: #d0d0c8; + font-weight: bold; + color: #003366; +}} +a {{ + color: #003366; +}} +hr {{ + border: none; + border-top: 2px solid #003366; + margin: 16px 0; +}} +.footer {{ + margin-top: 30px; + font-size: 10pt; + color: #666; + border-top: 1px solid #999; + padding-top: 6px; +}} +.status-ok {{ + color: green; + font-weight: bold; +}} +.status-warn {{ + color: #cc7700; + font-weight: bold; +}} +.counter {{ + font-family: "Courier New", monospace; + background-color: #000; + color: #0f0; + padding: 2px 6px; + font-size: 10pt; +}} +</style> +</head> +<body> + +<h1>Susan — System Status</h1> + +<hr> + +<h2>General Information</h2> + +<table> +<tr><th>Hostname</th><td>susan</td></tr> +<tr><th>Operating System</th><td>{os_name}</td></tr> +<tr><th>Kernel</th><td>{kernel}</td></tr> +<tr><th>Processor</th><td>{cpu_model} ({cpu_cores} cores)</td></tr> +<tr><th>Installed Packages</th><td>{packages}</td></tr> +<tr><th>First Installed</th><td>{install_date}</td></tr> +</table> + +<h2>Uptime & Load</h2> + +<table> +<tr><th>Uptime</th><td>{uptime}</td></tr> +<tr><th>Up Since</th><td>{uptime_since}</td></tr> +<tr><th>Load Average</th><td>{load[0]}, {load[1]}, {load[2]} (1, 5, 15 min)</td></tr> +</table> + +<h2>Memory</h2> + +<table> +<tr><th>Total</th><th>Used</th><th>Free</th><th>Available</th></tr> +<tr><td>{mem['total']}</td><td>{mem['used']}</td><td>{mem['free']}</td><td>{mem['available']}</td></tr> +</table> + +<h2>Disk Usage</h2> + +<table> +<tr><th>Mount</th><th>Size</th><th>Used</th><th>Available</th><th>Use%</th></tr> +""" + +for d in disks: + pct = int(d["pct"].replace("%", "")) if d["pct"].replace("%", "").isdigit() else 0 + cls = "status-warn" if pct > 85 else "status-ok" + html += f'<tr><td>{d["mount"]}</td><td>{d["size"]}</td><td>{d["used"]}</td><td>{d["avail"]}</td><td class="{cls}">{d["pct"]}</td></tr>\n' + +html += f"""</table> + +<h2>Running Services</h2> + +<table> +<tr><th>Service</th><th>Status</th></tr> +""" + +for s in services: + html += f'<tr><td>{s}</td><td class="status-ok">running</td></tr>\n' + +html += f"""</table> + +<h2>Media Library</h2> + +<table> +<tr><th>Category</th><th>Count</th></tr> +<tr><td>Films</td><td>{media['films']}</td></tr> +<tr><td>TV Shows</td><td>{media['tv']}</td></tr> +<tr><td>Anime</td><td>{media['anime']}</td></tr> +<tr><td>Music Artists</td><td>{media['artists']}</td></tr> +<tr><td>Music Tracks</td><td>{media['tracks']}</td></tr> +</table> + +<hr> + +<div class="footer"> +<p> +You are visitor number <span class="counter">{visitor_count:06,}</span> since December 2023. +</p> +<p> +This page was last generated on <b>{now.strftime("%A, %d %B %Y at %H:%M:%S")}</b>. +<br> +No JavaScript was harmed in the making of this page. +<br> +Best viewed with any browser. Optimised for 800×600. +</p> +</div> + +</body> +</html> +""" + +os.makedirs(os.path.dirname(OUTPUT), exist_ok=True) +with open(OUTPUT, "w") as f: + f.write(html) + +print(f"Generated stats page: {OUTPUT}") +print(f" Uptime: {uptime}") +print(f" Services: {len(services)}") +print(f" Films: {media['films']}, TV: {media['tv']}, Anime: {media['anime']}") +print(f" Music: {media['tracks']} tracks, {media['artists']} artists") |
