#!/bin/bash set -e ####################### Configuration ###################### ENABLE_NETWORK_STATS="yes" ## Enable or disable network statistics generation using vnStat INTERFACE="eth0" ## Network interface to monitor (e.g., eth0, wlan0) PAGE_TITLE="$HOSTNAME - Chrony Statistics" OUTPUT_DIR="/var/www/html/" ## Output directory for HTML and images HTML_FILENAME="index.html" ## Output HTML file name RRD_DIR="/var/lib/chrony-rrd" RRD_FILE="$RRD_DIR/chrony.rrd" ## RRD file for storing chrony statistics ENABLE_LOGGING="yes" LOG_FILE="/var/log/chrony-network-stats.log" # In-script log truncation (prevents logfile from growing indefinitely) # - LOG_MAX_SIZE_BYTES: when >0 use byte-based truncation (default 10MB) # - LOG_MAX_LINES: when >0 use line-based truncation instead of bytes # - LOG_TRIM_TO_BYTES / LOG_TRIM_TO_LINES: amount to keep when trimming (defaults to half) LOG_MAX_SIZE_BYTES=0 # $((10 * 1024 * 1024)) # 10 MB LOG_MAX_LINES=10000 LOG_TRIM_TO_BYTES=0 # $((LOG_MAX_SIZE_BYTES / 2)) LOG_TRIM_TO_LINES=9000 AUTO_REFRESH_SECONDS=300 ## Auto-refresh interval in seconds (0 = disabled, e.g., 300 for 5 minutes) GITHUB_REPO_LINK_SHOW="yes" ## You can display the link to the repo 'chrony-stats' in the HTML footer | Not required | Default: no ###### Advanced Configuration ###### CHRONY_ALLOW_DNS_LOOKUP="no" ## Yes allow DNS reverse lookups. No to prevent slow DNS reverse lookups DISPLAY_PRESET="default" # Preset for large screens. Options: default | 2k | 4k TIMEOUT_SECONDS=5 SERVER_STATS_UPPER_LIMIT=250000 ## When chrony restarts, it generate abnormally high values (e.g., 12M) | This filters out values above the threshold ############################################################## WIDTH=800 ## These graph sizes are changing with DISPLAY_PRESET HEIGHT=300 ## log_message() { local level="$1" local message="$2" if [[ "$ENABLE_LOGGING" == "yes" ]]; then # ensure logfile stays within configured limits before writing rotate_logfile_if_needed printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$message" >> "$LOG_FILE" fi echo "[$level] $message" } rotate_logfile_if_needed() { # no-op when logging disabled or logfile missing [ "$ENABLE_LOGGING" != "yes" ] && return 0 [ -f "$LOG_FILE" ] || return 0 # Line-based truncation (preferred when configured) if [[ "${LOG_MAX_LINES:-0}" -gt 0 ]]; then local total_lines total_lines=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0) if [[ "$total_lines" -gt "$LOG_MAX_LINES" ]]; then local keep_lines=${LOG_TRIM_TO_LINES:-$(( LOG_MAX_LINES / 2 ))} (( keep_lines <= 0 )) && keep_lines=$(( LOG_MAX_LINES / 2 )) tail -n "$keep_lines" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE" printf '[%s] [INFO] Log truncated to last %d lines (was %d lines)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$keep_lines" "$total_lines" >> "$LOG_FILE" fi return 0 fi # Size-based truncation (bytes) local max_bytes=${LOG_MAX_SIZE_BYTES:-10485760} local filesize if ! filesize=$(stat -c%s "$LOG_FILE" 2>/dev/null); then filesize=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0) fi if [[ "$filesize" -ge "$max_bytes" ]]; then local keep_bytes=${LOG_TRIM_TO_BYTES:-$(( max_bytes / 2 ))} (( keep_bytes <= 0 )) && keep_bytes=$(( max_bytes / 2 )) tail -c "$keep_bytes" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE" printf '[%s] [INFO] Log truncated to last %d bytes (was %d bytes)\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$keep_bytes" "$filesize" >> "$LOG_FILE" fi } configure_display_preset() { local preset="${DISPLAY_PRESET,,}" local scale_pct=100 local container_px=1400 local font_px=16 case "$preset" in 1080p|1080|default) scale_pct=100; container_px=1400; font_px=16 ;; 2k|1440p|qhd) scale_pct=135; container_px=2000; font_px=18 ;; 4k|2160p|uhd) scale_pct=170; container_px=2600; font_px=20 ;; *) scale_pct=100; container_px=1400; font_px=16 ;; esac WIDTH=$(( WIDTH * scale_pct / 100 )) HEIGHT=$(( HEIGHT * scale_pct / 100 )) CSS_CUSTOM_ROOT=$(cat < graph ${WIDTH}x${HEIGHT}, container ${container_px}px, font ${font_px}px" } validate_numeric() { local value="$1" local name="$2" if ! [[ "$value" =~ ^[0-9]+$ ]]; then log_message "ERROR" "Invalid $name: $value. Must be numeric." exit 1 fi } check_commands() { local commands=("rrdtool" "chronyc" "sudo" "timeout") if [[ "$ENABLE_NETWORK_STATS" == "yes" ]]; then commands+=("vnstati") fi for cmd in "${commands[@]}"; do if ! command -v "$cmd" &>/dev/null; then log_message "ERROR" "Command '$cmd' not found in PATH." exit 1 fi done } setup_directories() { log_message "INFO" "Checking and preparing directories..." for dir in "$OUTPUT_DIR" "$RRD_DIR" "$OUTPUT_DIR/img"; do mkdir -p "$dir" || { log_message "ERROR" "Failed to create directory: $dir" exit 1 } if [ ! -w "$dir" ]; then log_message "ERROR" "Directory '$dir' is not writable." exit 1 fi done } generate_vnstat_images() { if [[ "$ENABLE_NETWORK_STATS" != "yes" ]]; then log_message "INFO" "Network stats disabled, skipping vnStat image generation..." return 0 fi log_message "INFO" "Generating vnStat images for interface '$INTERFACE'..." local modes=("s" "d" "t" "h" "m" "y") for mode in "${modes[@]}"; do vnstati -"$mode" -i "$INTERFACE" -o "$OUTPUT_DIR/img/vnstat_${mode}.png" || { log_message "ERROR" "Failed to generate vnstat image for mode $mode Check configuaration section : INTERFACE=\"here\"" exit 1 } done } collect_chrony_data() { log_message "INFO" "Collecting Chrony data..." local CHRONYC_OPTS="" if [[ "$CHRONY_ALLOW_DNS_LOOKUP" == "no" ]]; then CHRONYC_OPTS="-n" log_message "INFO" "Using chronyc -n option to prevent DNS lookups" fi get_html() { timeout "$TIMEOUT_SECONDS"s sudo chronyc $CHRONYC_OPTS "$1" -v 2>&1 | sed 's/&/\&/g;s//\>/g' || { log_message "ERROR" "Failed to collect chronyc $1 data" return 1 } } RAW_TRACKING=$(timeout "$TIMEOUT_SECONDS"s sudo chronyc $CHRONYC_OPTS tracking) || { log_message "ERROR" "Failed to collect chronyc tracking data" exit 1 } CHRONYC_TRACKING_HTML=$(echo "$RAW_TRACKING" | sed 's/&/\&/g;s//\>/g') CHRONYC_SOURCES=$(get_html sources) || exit 1 CHRONYC_SOURCESTATS=$(get_html sourcestats) || exit 1 CHRONYC_SELECTDATA=$(get_html selectdata) || exit 1 } extract_chronyc_values() { extract_val() { echo "$RAW_TRACKING" | awk "/$1/ {print \$($2)}" | grep -E '^[-+]?[0-9.]+$' || echo "U" } OFFSET=$(extract_val "Last offset" "NF-1") local systime_line systime_line=$(echo "$RAW_TRACKING" | grep "System time") if [[ -n "$systime_line" ]]; then local value value=$(echo "$systime_line" | awk '{print $4}') if [[ "$systime_line" == *"slow"* ]]; then SYSTIME="-$value" else SYSTIME="$value" fi else SYSTIME="U" fi FREQ=$(extract_val "Frequency" "NF-2") RESID_FREQ=$(extract_val "Residual freq" "NF-1") SKEW=$(extract_val "Skew" "NF-1") DELAY=$(extract_val "Root delay" "NF-1") DISPERSION=$(extract_val "Root dispersion" "NF-1") STRATUM=$(extract_val "Stratum" "3") local CHRONYC_OPTS="" if [[ "$CHRONY_ALLOW_DNS_LOOKUP" == "no" ]]; then CHRONYC_OPTS="-n" fi RAW_STATS=$(LC_ALL=C sudo chronyc $CHRONYC_OPTS serverstats) || { log_message "ERROR" "Failed to collect chronyc serverstats" exit 1 } get_stat() { echo "$RAW_STATS" | awk -F'[[:space:]]*:[[:space:]]*' "/$1/ {print \$2}" | grep -E '^[0-9]+$' || echo "U" } PKTS_RECV=$(get_stat "NTP packets received") PKTS_DROP=$(get_stat "NTP packets dropped") CMD_RECV=$(get_stat "Command packets received") CMD_DROP=$(get_stat "Command packets dropped") LOG_DROP=$(get_stat "Client log records dropped") INTERLEAVED=$(get_stat "Interleaved NTP packets") TS_HELD=$(get_stat "NTP timestamps held") } create_rrd_database() { if [ ! -f "$RRD_FILE" ]; then log_message "INFO" "Creating new RRD file: $RRD_FILE" LC_ALL=C rrdtool create "$RRD_FILE" --step 300 \ DS:offset:GAUGE:600:U:U DS:frequency:GAUGE:600:U:U DS:resid_freq:GAUGE:600:U:U DS:skew:GAUGE:600:U:U \ DS:delay:GAUGE:600:U:U DS:dispersion:GAUGE:600:U:U DS:stratum:GAUGE:600:0:16 \ DS:systime:GAUGE:600:U:U \ DS:pkts_recv:COUNTER:600:0:U DS:pkts_drop:COUNTER:600:0:U DS:cmd_recv:COUNTER:600:0:U \ DS:cmd_drop:COUNTER:600:0:U DS:log_drop:COUNTER:600:0:U DS:interleaved:COUNTER:600:0:U \ DS:ts_held:GAUGE:600:0:U \ RRA:AVERAGE:0.5:1:576 RRA:AVERAGE:0.5:6:672 RRA:AVERAGE:0.5:24:732 RRA:AVERAGE:0.5:288:730 \ RRA:MAX:0.5:1:576 RRA:MAX:0.5:6:672 RRA:MAX:0.5:24:732 RRA:MAX:0.5:288:730 \ RRA:MIN:0.5:1:576 RRA:MIN:0.5:6:672 RRA:MIN:0.5:24:732 RRA:MIN:0.5:288:730 || { log_message "ERROR" "Failed to create RRD database" exit 1 } fi } update_rrd_database() { log_message "INFO" "Updating RRD database..." UPDATE_STRING="N:$OFFSET:$FREQ:$RESID_FREQ:$SKEW:$DELAY:$DISPERSION:$STRATUM:$SYSTIME:$PKTS_RECV:$PKTS_DROP:$CMD_RECV:$CMD_DROP:$LOG_DROP:$INTERLEAVED:$TS_HELD" LC_ALL=C rrdtool update "$RRD_FILE" "$UPDATE_STRING" || { log_message "ERROR" "Failed to update RRD database" exit 1 } } generate_graphs() { log_message "INFO" "Generating graphs..." local END_TIME=$(date +%s) declare -A time_periods=( ["day"]="end-1d" ["week"]="end-1w" ["month"]="end-1m" ) declare -A period_titles=( ["day"]="by day" ["week"]="by week" ["month"]="by month" ) declare -A graphs=( ["chrony_serverstats"]="--title 'Chrony Server Statistics - PERIOD_TITLE' --vertical-label 'Packets/second' \ --lower-limit 0 --rigid --units-exponent 0 \ DEF:pkts_recv_raw='$RRD_FILE':pkts_recv:AVERAGE \ DEF:pkts_drop_raw='$RRD_FILE':pkts_drop:AVERAGE \ DEF:cmd_recv_raw='$RRD_FILE':cmd_recv:AVERAGE \ DEF:cmd_drop_raw='$RRD_FILE':cmd_drop:AVERAGE \ DEF:log_drop_raw='$RRD_FILE':log_drop:AVERAGE \ CDEF:pkts_recv=pkts_recv_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,pkts_recv_raw,IF \ CDEF:pkts_drop=pkts_drop_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,pkts_drop_raw,IF \ CDEF:cmd_recv=cmd_recv_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,cmd_recv_raw,IF \ CDEF:cmd_drop=cmd_drop_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,cmd_drop_raw,IF \ CDEF:log_drop=log_drop_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,log_drop_raw,IF \ 'COMMENT: \l' \ 'AREA:pkts_recv#C4FFC4:Packets received ' \ 'LINE1:pkts_recv#00E000:' \ 'GPRINT:pkts_recv:LAST:Cur\: %5.2lf%s' \ 'GPRINT:pkts_recv:MIN:Min\: %5.2lf%s' \ 'GPRINT:pkts_recv:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:pkts_recv:MAX:Max\: %5.2lf%s\l' \ 'LINE1:pkts_drop#FF8C00:Packets dropped ' \ 'GPRINT:pkts_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:pkts_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:pkts_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:pkts_drop:MAX:Max\: %5.2lf%s\l' \ 'LINE1:cmd_recv#4169E1:Command packets received ' \ 'GPRINT:cmd_recv:LAST:Cur\: %5.2lf%s' \ 'GPRINT:cmd_recv:MIN:Min\: %5.2lf%s' \ 'GPRINT:cmd_recv:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:cmd_recv:MAX:Max\: %5.2lf%s\l' \ 'LINE1:cmd_drop#FFD700:Command packets dropped ' \ 'GPRINT:cmd_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:cmd_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:cmd_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:cmd_drop:MAX:Max\: %5.2lf%s\l' \ 'LINE1:log_drop#9400D3:Client log records dropped ' \ 'GPRINT:log_drop:LAST:Cur\: %5.2lf%s' \ 'GPRINT:log_drop:MIN:Min\: %5.2lf%s' \ 'GPRINT:log_drop:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:log_drop:MAX:Max\: %5.2lf%s\l'" ["chrony_tracking"]="--title 'Chrony Dispersion + Stratum - PERIOD_TITLE' --vertical-label 'milliseconds' --alt-autoscale \ --units-exponent 0 \ DEF:stratum='$RRD_FILE':stratum:AVERAGE \ DEF:freq='$RRD_FILE':frequency:AVERAGE \ DEF:skew='$RRD_FILE':skew:AVERAGE \ DEF:delay='$RRD_FILE':delay:AVERAGE \ DEF:dispersion='$RRD_FILE':dispersion:AVERAGE \ CDEF:skew_scaled=skew,100,* \ CDEF:delay_scaled=delay,1000,* \ CDEF:disp_scaled=dispersion,1000,* \ 'COMMENT: \l' \ 'LINE1:stratum#00ff00:Stratum ' \ 'GPRINT:stratum:LAST: Cur\: %5.2lf%s' \ 'GPRINT:stratum:MIN:Min\: %5.2lf%s' \ 'GPRINT:stratum:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:stratum:MAX:Max\: %5.2lf%s\l' \ 'LINE1:disp_scaled#9400D3:Root dispersion [Root dispersion] ' \ 'GPRINT:disp_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:disp_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:disp_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:disp_scaled:MAX:Max\: %5.2lf%s\l'" ["chrony_offset"]="--title 'Chrony System Time Offset - PERIOD_TITLE' --vertical-label 'milliseconds' \ DEF:offset='$RRD_FILE':offset:AVERAGE \ DEF:systime='$RRD_FILE':systime:AVERAGE \ CDEF:systime_scaled=systime,1000,* \ CDEF:offset_ms=offset,1000,* \ 'LINE2:offset_ms#00ff00:Actual Offset from NTP Source [Last Offset] ' \ 'GPRINT:offset_ms:LAST: Cur\: %5.2lf%s' \ 'GPRINT:offset_ms:MIN:Min\: %5.2lf%s' \ 'GPRINT:offset_ms:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:offset_ms:MAX:Max\: %5.2lf%s\l' \ 'LINE1:systime_scaled#4169E1:System Clock Adjustment [System Time] ' \ 'GPRINT:systime_scaled:LAST: Cur\: %5.2lf%s' \ 'GPRINT:systime_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:systime_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:systime_scaled:MAX:Max\: %5.2lf%s\l'" ["chrony_delay"]="--title 'Chrony Root Delay - PERIOD_TITLE' --vertical-label 'milliseconds' --units-exponent 0 \ DEF:delay='$RRD_FILE':delay:AVERAGE \ CDEF:delay_ms=delay,1000,* \ LINE2:delay_ms#00ff00:'Network Delay to Root Source [Root Delay] ' \ 'GPRINT:delay_ms:LAST:Cur\: %5.2lf%s' \ 'GPRINT:delay_ms:MIN:Min\: %5.2lf%s' \ 'GPRINT:delay_ms:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:delay_ms:MAX:Max\: %5.2lf%s\l'" ["chrony_frequency"]="--title 'Chrony Clock Frequency Error - PERIOD_TITLE' --vertical-label 'ppm'\ DEF:freq='$RRD_FILE':frequency:AVERAGE \ DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \ CDEF:resfreq_scaled=resid_freq,100,* \ CDEF:freq_scaled=freq,1,* \ 'LINE2:freq_scaled#00ff00:Natural Clock Drift [Frequency] ' \ 'GPRINT:freq_scaled:LAST:Cur\: %5.2lf%s' \ 'GPRINT:freq_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:freq_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:freq_scaled:MAX:Max\: %5.2lf%s\n' \ 'LINE1:resfreq_scaled#4169E1:Residual Drift (x100) [Residual freq] ' \ 'GPRINT:resfreq_scaled:LAST:Cur\: %5.2lf%s' \ 'GPRINT:resfreq_scaled:MIN:Min\: %5.2lf%s' \ 'GPRINT:resfreq_scaled:AVERAGE:Avg\: %5.2lf%s' \ 'GPRINT:resfreq_scaled:MAX:Max\: %5.2lf%s\l'" ["chrony_drift"]="--title 'Chrony Drift Margin Error - PERIOD_TITLE' --vertical-label 'ppm' \ --units-exponent 0 \ DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \ DEF:skew_raw='$RRD_FILE':skew:AVERAGE \ CDEF:resfreq_scaled=resid_freq,100,* \ CDEF:skew_scaled=skew_raw,100,* \ 'COMMENT: \l' \ 'LINE1:skew_scaled#00ff00:Estimate Drift Error Margin (x100) [Skew] ' \ 'GPRINT:skew_scaled:LAST:Cur\: %5.2lf' \ 'GPRINT:skew_scaled:MIN:Min\: %5.2lf' \ 'GPRINT:skew_scaled:AVERAGE:Avg\: %5.2lf' \ 'GPRINT:skew_scaled:MAX:Max\: %5.2lf\l'" ) for period in "${!time_periods[@]}"; do for graph in "${!graphs[@]}"; do local graph_title="${graphs[$graph]//PERIOD_TITLE/${period_titles[$period]}}" local output_file="$OUTPUT_DIR/img/${graph}_${period}.png" local time_range="${time_periods[$period]}" local cmd="LC_ALL=C rrdtool graph '$output_file' --width '$WIDTH' --height '$HEIGHT' --start $time_range --end now-180s $graph_title" eval "$cmd" || { log_message "ERROR" "Failed to generate graph: ${graph}_${period}" exit 1 } done done } generate_html() { log_message "INFO" "Generating HTML report..." local GENERATED_TIMESTAMP=$(date) local CHRONYC_DISPLAY_OPTS="" if [[ "$CHRONY_ALLOW_DNS_LOOKUP" == "no" ]]; then CHRONYC_DISPLAY_OPTS=" -n" fi local AUTO_REFRESH_META="" if [[ "$AUTO_REFRESH_SECONDS" -gt 0 ]]; then AUTO_REFRESH_META=" " fi cat >"$OUTPUT_DIR/$HTML_FILENAME" < $AUTO_REFRESH_META ${PAGE_TITLE} - Server Status

Chrony Graphs [Data Legend]

Day
Week
Month
Chrony server statistics graph - day
Chrony system clock offset graph - day
Chrony system clock tracking graph - day
Chrony sync delay graph - day
Chrony clock frequency graph - day
Chrony clock frequency drift graph - day
Chrony server statistics graph - week
Chrony system clock offset graph - week
Chrony system clock tracking graph - week
Chrony sync delay graph - week
Chrony clock frequency graph - week
Chrony clock frequency drift graph - week
Chrony server statistics graph - month
Chrony system clock offset graph - month
Chrony system clock tracking graph - month
Chrony sync delay graph - month
Chrony clock frequency graph - month
Chrony clock frequency drift graph - month
EOF if [[ "$ENABLE_NETWORK_STATS" == "yes" ]]; then cat >>"$OUTPUT_DIR/$HTML_FILENAME" <

vnStati Graphs

vnStat summary
vnStat daily
vnStat top 10
vnStat hourly
vnStat monthly
vnStat yearly
EOF fi cat >>"$OUTPUT_DIR/$HTML_FILENAME" <

Chrony - NTP Statistics

Command: chronyc${CHRONYC_DISPLAY_OPTS} sources -v

${CHRONYC_SOURCES}

Command: chronyc${CHRONYC_DISPLAY_OPTS} selectdata -v

${CHRONYC_SELECTDATA}

Command: chronyc${CHRONYC_DISPLAY_OPTS} sourcestats -v

${CHRONYC_SOURCESTATS}

Command: chronyc${CHRONYC_DISPLAY_OPTS} tracking

${CHRONYC_TRACKING_HTML}

Page generated on: ${GENERATED_TIMESTAMP}

EOF if [[ "$GITHUB_REPO_LINK_SHOW" == "yes" ]]; then cat >>"$OUTPUT_DIR/$HTML_FILENAME" <Made with ❤️ by TheHuman00 | View on GitHub

Modified from original script by Medowar (https://www.medowar.de)

EOF fi cat >>"$OUTPUT_DIR/$HTML_FILENAME" <
EOF } main() { log_message "INFO" "Starting chrony-network-stats script..." validate_numeric "$WIDTH" "WIDTH" validate_numeric "$HEIGHT" "HEIGHT" validate_numeric "$TIMEOUT_SECONDS" "TIMEOUT_SECONDS" validate_numeric "$SERVER_STATS_UPPER_LIMIT" "SERVER_STATS_UPPER_LIMIT" validate_numeric "$AUTO_REFRESH_SECONDS" "AUTO_REFRESH_SECONDS" configure_display_preset check_commands setup_directories generate_vnstat_images collect_chrony_data extract_chronyc_values create_rrd_database update_rrd_database generate_graphs generate_html log_message "INFO" "HTML page and graphs generated in: $OUTPUT_DIR/$HTML_FILENAME" echo "✅ Successfully generated report" } main