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:28:44 +02:00
LOAD_COLORS = [ "#FACC15" , "#F59E0B" , "#DC2626" ] # bottom to top
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 ,
2026-08-12 07:59:29 +02:00
network_mixed : bool = False , thermal_mixed : bool = False ,
2026-08-12 08:14:38 +02:00
trouble_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-12 07:59:29 +02:00
if thermal_mixed :
args += [ "--lower-limit" , "0" , "--upper-limit" , "100" , "--rigid" ]
2026-08-12 08:14:38 +02:00
if any ( ds . startswith ( "health_" ) for ds , _label , _cf in series ):
2026-08-12 08:18:15 +02:00
args += [ "--lower-limit" , "0" , "--upper-limit" , "10" , "--rigid" ,
"HRULE:5#9CA3AF:action threshold" ,
"HRULE:3#DC2626:urgent threshold" ]
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-12 07:59:29 +02:00
if thermal_mixed and ds == "throttled" :
args += [ f "DEF: { var } _raw= { rrd } : { ds } : { cf } " ,
f "CDEF: { var } = { var } _raw,100,*" ,
f "AREA: { var } #DC262640: { label } " ,
f "GPRINT: { var } _raw:LAST:Current \\ :%8.0lf" ,
f "GPRINT: { var } _raw:AVERAGE:Average \\ :%8.2lf" ,
f "GPRINT: { var } _raw:MAX:Maximum \\ :%8.0lf \\ n" ]
continue
if trouble_mixed and ds == "oom_kills" :
args += [ f "DEF: { var } _raw= { rrd } : { ds } : { cf } " ,
f "CDEF: { var } = { var } _raw,60,*" ,
f "AREA: { var } #DC262680: { label } " ,
f "GPRINT: { var } :LAST:Current \\ :%8.2lf" ,
f "GPRINT: { var } :AVERAGE:Average \\ :%8.2lf" ,
f "GPRINT: { var } :MAX:Maximum \\ :%8.2lf \\ n" ]
continue
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-12 08:14:38 +02:00
( "load" , "Processor load" , "load" , [( "load15" , "15 minutes" , "AVERAGE" ), ( "load5" , "5 minutes" , "AVERAGE" ), ( "load1" , "1 minute" , "AVERAGE" )], False , True , False , False , False , False ),
( "cpu" , "CPU usage" , "%" , [( "cpu_user" , "user" , "AVERAGE" ), ( "cpu_system" , "system" , "AVERAGE" ), ( "cpu_nice" , "nice" , "AVERAGE" ), ( "io_wait" , "I/O wait" , "AVERAGE" )], False , True , False , False , False , False ),
( "thermal" , "CPU temperature and throttling" , "°C" , [( "temperature" , "temperature" , "AVERAGE" ), ( "throttled" , "throttled" , "MAX" )], False , False , False , False , True , False ),
( "trouble" , "Failed services and OOM kills" , "count" , [( "failed_services" , "failed services" , "MAX" ), ( "oom_kills" , "OOM kills/min" , "MAX" )], False , False , False , False , False , True ),
( "network" , "Network throughput" , "bytes/s" , [( "net_out" , "sent" , "AVERAGE" ), ( "net_in" , "received" , "AVERAGE" )], False , False , False , True , False , False ),
( "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 , False , False ),
( "storage-health" , "Storage operational health" , "0– 10" , [( f "health_ { 'root' if i == 0 else i } " , mount , "MIN" ) for i , mount in enumerate ( mounts )], False , False , False , False , False , False ),
( "uptime" , "System uptime" , "days" , [( "uptime" , "uptime" , "AVERAGE" )], False , False , False , False , False , False ),
2026-08-11 17:18:15 +02:00
]
for period_name , start in PERIODS . items ():
2026-08-12 08:14:38 +02:00
for filename , title , unit , series , logarithmic , stacked , disk_memory , network_mixed , thermal_mixed , trouble_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 ,
2026-08-12 07:59:29 +02:00
stacked , disk_memory , network_mixed , thermal_mixed ,
2026-08-12 08:14:38 +02:00
trouble_mixed )
2026-08-11 19:11:40 +02:00
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 '<section><h2> { html . escape ( title ) } </h2>'
f '<img src=" { filename } - { period } .png" '
f 'alt=" { html . escape ( title ) } , { period_labels [ period ] } "></section>'
for filename , title , * _rest in definitions
)
navigation = " " . join (
f '<a href=" { page_files [ item ] } "'
f ' { " class=active" if item == period else "" } >'
f ' { period_labels [ item ] } </a>'
for item in PERIODS
)
page = f """<!doctype html>
2026-08-11 17:18:15 +02:00
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
2026-08-11 19:11:40 +02:00
<meta http-equiv="refresh" content="300"><title>System monitor — { period_labels [ period ] } </title>
2026-08-11 17:18:15 +02:00
<style>body {{ font:16px system-ui;margin:auto;max-width:1100px;padding:1rem;background:#f8fafc;color:#172033 }}
2026-08-11 19:11:40 +02:00
nav {{ display:flex;gap:.5rem;flex-wrap:wrap }} nav a {{ padding:.5rem .75rem;background:#e2e8f0;color:#172033;text-decoration:none;border-radius:.35rem }}
nav a.active {{ background:#2563eb;color:white }} section {{ background:white;padding:1rem;margin:1rem 0;border-radius:.5rem;box-shadow:0 1px 4px #0002 }}
img {{ width:100%;height:auto }} </style>
</head><body><h1>System monitor — { period_labels [ period ] } </h1><nav> { navigation } </nav> { cards } </body></html>"""
( output_dir / page_files [ period ]) . write_text ( page )
2026-08-11 17:18:15 +02:00
if __name__ == "__main__" :
main ()