#!/usr/bin/env python3 """Generate system-monitor PNG graphs from its RRD database.""" from __future__ import annotations import argparse import configparser import html from pathlib import Path import subprocess PERIODS = {"3hours": "3h", "1day": "1d", "2weeks": "2w", "1year": "1y"} COLORS = ["#2563EB", "#DC2626", "#16A34A", "#9333EA"] LOAD_COLORS = ["#FACC15", "#F59E0B", "#DC2626"] # bottom to top def graph(output: Path, rrd: Path, start: str, title: str, unit: str, series: list[tuple[str, str, str]], logarithmic: bool = False, stacked: bool = False, disk_memory: bool = False, network_mixed: bool = False) -> None: args = ["rrdtool", "graph", str(output), "--start", f"end-{start}", "--end", "now", "--width", "900", "--height", "260", "--title", title, "--vertical-label", unit, "--slope-mode", "--watermark", "cheap-system-monitor"] if logarithmic: args += ["--logarithmic", "--lower-limit", "1"] value_suffix = "%%" if unit == "%" else "" for index, (ds, label, cf) in enumerate(series): var = f"v{index}" if network_mixed and ds == "net_out": drawing = f"AREA:{var}#86EFAC:{label}" elif network_mixed and ds == "net_in": drawing = f"LINE3:{var}#2563EB:{label}" elif disk_memory and ds == "memory": drawing = f"LINE3:{var}#DC2626:{label}" elif disk_memory: # Transparency keeps multiple mountpoints visible when their # percentage areas overlap. drawing = f"AREA:{var}{COLORS[index % len(COLORS)]}80:{label}" elif stacked: stack = ":STACK" if index else "" stacked_colors = LOAD_COLORS if title.startswith("Processor load") else COLORS drawing = f"AREA:{var}{stacked_colors[index]}:{label}{stack}" else: drawing = f"LINE{2 if index < 3 else 1}:{var}{COLORS[index % len(COLORS)]}:{label}" args += [f"DEF:{var}={rrd}:{ds}:{cf}", drawing, f"GPRINT:{var}:LAST:Current\\:%8.2lf%s{value_suffix}", f"GPRINT:{var}:AVERAGE:Average\\:%8.2lf%s{value_suffix}", f"GPRINT:{var}:MAX:Maximum\\:%8.2lf%s{value_suffix}\\n"] subprocess.run(args, check=True) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", default="/opt/cheap_system_monitor/monitor.conf") args = parser.parse_args() config_parser = configparser.ConfigParser() if not config_parser.read(args.config): raise SystemExit(f"Configuration file not found: {args.config}") config = config_parser["monitor"] rrd = Path(config["rrd_file"]) if not rrd.exists(): raise SystemExit(f"RRD file does not exist: {rrd}") output_dir = Path(config.get("graph_dir", "/var/www/html/system-monitor")) output_dir.mkdir(parents=True, exist_ok=True) mounts = [item.strip() for item in config["mountpoints"].split(",") if item.strip()] if not mounts or mounts[0] != "/": mounts.insert(0, "/") definitions = [ ("load", "Processor load", "load", [("load15", "15 minutes", "AVERAGE"), ("load5", "5 minutes", "AVERAGE"), ("load1", "1 minute", "AVERAGE")], False, True, False, False), ("cpu", "CPU usage", "%", [("cpu_user", "user", "AVERAGE"), ("cpu_system", "system", "AVERAGE"), ("cpu_nice", "nice", "AVERAGE")], False, True, False, False), ("network", "Network throughput", "bytes/s", [("net_out", "sent", "AVERAGE"), ("net_in", "received", "AVERAGE")], False, False, False, True), ("disk", "Disk and memory usage", "%", [(f"disk_{'root' if i == 0 else i}", mount, "AVERAGE") for i, mount in enumerate(mounts)] + [("memory", "memory", "AVERAGE")], False, False, True, False), ("uptime", "System uptime", "days", [("uptime", "uptime", "AVERAGE")], False, False, False, False), ] for period_name, start in PERIODS.items(): for filename, title, unit, series, logarithmic, stacked, disk_memory, network_mixed in definitions: graph(output_dir / f"{filename}-{period_name}.png", rrd, start, f"{title} — {period_name}", unit, series, logarithmic, stacked, disk_memory, network_mixed) page_files = { "3hours": "index.html", "1day": "1day.html", "2weeks": "2weeks.html", "1year": "1year.html", } period_labels = { "3hours": "3 hours", "1day": "1 day", "2weeks": "2 weeks", "1year": "1 year", } for period in PERIODS: cards = "\n".join( f'

{html.escape(title)}

' f'
' for filename, title, *_rest in definitions ) navigation = " ".join( f'' f'{period_labels[item]}' for item in PERIODS ) page = f""" System monitor — {period_labels[period]}

System monitor — {period_labels[period]}

{cards}""" (output_dir / page_files[period]).write_text(page) if __name__ == "__main__": main()