2026-08-11 17:18:15 +02:00
#!/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" ]
2026-08-11 19:10:05 +02:00
LOAD_COLORS = [ "#DC2626" , "#F59E0B" , "#FACC15" ] # red, amber, yellow
2026-08-11 17:18:15 +02:00
def graph ( output : Path , rrd : Path , start : str , title : str , unit : str ,
2026-08-11 19:10:05 +02:00
series : list [ tuple [ str , str , str ]], logarithmic : bool = False ,
stacked : bool = False , disk_memory : bool = False ,
network_mixed : bool = False ) -> None :
2026-08-11 17:18:15 +02:00
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" ]
2026-08-11 19:10:05 +02:00
value_suffix = " %% " if unit == "%" else ""
2026-08-11 17:18:15 +02:00
for index , ( ds , label , cf ) in enumerate ( series ):
var = f "v { index } "
2026-08-11 19:10:05 +02:00
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 } "
2026-08-11 17:18:15 +02:00
args += [ f "DEF: { var } = { rrd } : { ds } : { cf } " ,
2026-08-11 19:10:05 +02:00
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" ]
2026-08-11 17:18:15 +02:00
subprocess . run ( args , check = True )
def main () -> None :
parser = argparse . ArgumentParser ( description = __doc__ )
2026-08-11 18:52:41 +02:00
parser . add_argument ( "--config" , default = "/opt/cheap_system_monitor/monitor.conf" )
2026-08-11 17:18:15 +02:00
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 = [
2026-08-11 19:10:05 +02:00
( "load" , "Processor load" , "load" , [( "load1" , "1 minute" , "AVERAGE" ), ( "load5" , "5 minutes" , "AVERAGE" ), ( "load15" , "15 minutes" , "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 ),
2026-08-11 17:18:15 +02:00
]
for period_name , start in PERIODS . items ():
2026-08-11 19:10:05 +02:00
for filename , title , unit , series , logarithmic , stacked , disk_memory , network_mixed in definitions :
2026-08-11 17:18:15 +02:00
graph ( output_dir / f " { filename } - { period_name } .png" , rrd , start ,
2026-08-11 19:10:05 +02:00
f " { title } — { period_name } " , unit , series , logarithmic ,
stacked , disk_memory , network_mixed )
2026-08-11 17:18:15 +02:00
cards = " \n " . join (
f '<section><h2> { html . escape ( title ) } </h2>' + "" . join (
f '<figure><figcaption> { period } </figcaption>'
f '<img src=" { filename } - { period } .png" alt=" { html . escape ( title ) } , { period } "></figure>'
for period in PERIODS
) + "</section>"
2026-08-11 19:10:05 +02:00
for filename , title , _unit , _series , _logarithmic , _stacked , _disk_memory , _network_mixed in definitions
2026-08-11 17:18:15 +02:00
)
page = f """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<meta http-equiv="refresh" content="300"><title>System monitor</title>
<style>body {{ font:16px system-ui;margin:auto;max-width:1100px;padding:1rem;background:#f8fafc;color:#172033 }}
section {{ background:white;padding:1rem;margin:1rem 0;border-radius:.5rem;box-shadow:0 1px 4px #0002 }}
figure {{ margin:1rem 0 }} img {{ width:100%;height:auto }} figcaption {{ font-weight:600 }} </style>
</head><body><h1>System monitor</h1> { cards } </body></html>"""
( output_dir / "index.html" ) . write_text ( page )
if __name__ == "__main__" :
main ()