chrony-network-stats.sh 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824
  1. #!/bin/bash
  2. set -e
  3. ####################### Configuration ######################
  4. ENABLE_NETWORK_STATS="yes" ## Enable or disable network statistics generation using vnStat
  5. INTERFACE="eth0" ## Network interface to monitor (e.g., eth0, wlan0)
  6. PAGE_TITLE="$HOSTNAME - Chrony Statistics"
  7. OUTPUT_DIR="/var/www/html/" ## Output directory for HTML and images
  8. HTML_FILENAME="index.html" ## Output HTML file name
  9. RRD_DIR="/var/lib/chrony-rrd"
  10. RRD_FILE="$RRD_DIR/chrony.rrd" ## RRD file for storing chrony statistics
  11. ENABLE_LOGGING="yes"
  12. LOG_FILE="/var/log/chrony-network-stats.log"
  13. # In-script log truncation (prevents logfile from growing indefinitely)
  14. # - LOG_MAX_SIZE_BYTES: when >0 use byte-based truncation (default 10MB)
  15. # - LOG_MAX_LINES: when >0 use line-based truncation instead of bytes
  16. # - LOG_TRIM_TO_BYTES / LOG_TRIM_TO_LINES: amount to keep when trimming (defaults to half)
  17. LOG_MAX_SIZE_BYTES=0 # $((10 * 1024 * 1024)) # 10 MB
  18. LOG_MAX_LINES=10000
  19. LOG_TRIM_TO_BYTES=0 # $((LOG_MAX_SIZE_BYTES / 2))
  20. LOG_TRIM_TO_LINES=9000
  21. AUTO_REFRESH_SECONDS=300 ## Auto-refresh interval in seconds (0 = disabled, e.g., 300 for 5 minutes)
  22. GITHUB_REPO_LINK_SHOW="yes" ## You can display the link to the repo 'chrony-stats' in the HTML footer | Not required | Default: no
  23. ###### Advanced Configuration ######
  24. CHRONY_ALLOW_DNS_LOOKUP="no" ## Yes allow DNS reverse lookups. No to prevent slow DNS reverse lookups
  25. DISPLAY_PRESET="default" # Preset for large screens. Options: default | 2k | 4k
  26. TIMEOUT_SECONDS=5
  27. SERVER_STATS_UPPER_LIMIT=250000 ## When chrony restarts, it generate abnormally high values (e.g., 12M) | This filters out values above the threshold
  28. ##############################################################
  29. WIDTH=800 ## These graph sizes are changing with DISPLAY_PRESET
  30. HEIGHT=300 ##
  31. log_message() {
  32. local level="$1"
  33. local message="$2"
  34. if [[ "$ENABLE_LOGGING" == "yes" ]]; then
  35. # ensure logfile stays within configured limits before writing
  36. rotate_logfile_if_needed
  37. printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$message" >> "$LOG_FILE"
  38. fi
  39. echo "[$level] $message"
  40. }
  41. rotate_logfile_if_needed() {
  42. # no-op when logging disabled or logfile missing
  43. [ "$ENABLE_LOGGING" != "yes" ] && return 0
  44. [ -f "$LOG_FILE" ] || return 0
  45. # Line-based truncation (preferred when configured)
  46. if [[ "${LOG_MAX_LINES:-0}" -gt 0 ]]; then
  47. local total_lines
  48. total_lines=$(wc -l < "$LOG_FILE" 2>/dev/null || echo 0)
  49. if [[ "$total_lines" -gt "$LOG_MAX_LINES" ]]; then
  50. local keep_lines=${LOG_TRIM_TO_LINES:-$(( LOG_MAX_LINES / 2 ))}
  51. (( keep_lines <= 0 )) && keep_lines=$(( LOG_MAX_LINES / 2 ))
  52. tail -n "$keep_lines" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
  53. 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"
  54. fi
  55. return 0
  56. fi
  57. # Size-based truncation (bytes)
  58. local max_bytes=${LOG_MAX_SIZE_BYTES:-10485760}
  59. local filesize
  60. if ! filesize=$(stat -c%s "$LOG_FILE" 2>/dev/null); then
  61. filesize=$(wc -c < "$LOG_FILE" 2>/dev/null || echo 0)
  62. fi
  63. if [[ "$filesize" -ge "$max_bytes" ]]; then
  64. local keep_bytes=${LOG_TRIM_TO_BYTES:-$(( max_bytes / 2 ))}
  65. (( keep_bytes <= 0 )) && keep_bytes=$(( max_bytes / 2 ))
  66. tail -c "$keep_bytes" "$LOG_FILE" > "${LOG_FILE}.tmp" && mv "${LOG_FILE}.tmp" "$LOG_FILE"
  67. 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"
  68. fi
  69. }
  70. configure_display_preset() {
  71. local preset="${DISPLAY_PRESET,,}"
  72. local scale_pct=100
  73. local container_px=1400
  74. local font_px=16
  75. case "$preset" in
  76. 1080p|1080|default)
  77. scale_pct=100; container_px=1400; font_px=16 ;;
  78. 2k|1440p|qhd)
  79. scale_pct=135; container_px=2000; font_px=18 ;;
  80. 4k|2160p|uhd)
  81. scale_pct=170; container_px=2600; font_px=20 ;;
  82. *)
  83. scale_pct=100; container_px=1400; font_px=16 ;;
  84. esac
  85. WIDTH=$(( WIDTH * scale_pct / 100 ))
  86. HEIGHT=$(( HEIGHT * scale_pct / 100 ))
  87. CSS_CUSTOM_ROOT=$(cat <<EOF
  88. :root {
  89. --container-max: ${container_px}px;
  90. --font-size-base: ${font_px}px;
  91. }
  92. EOF
  93. )
  94. log_message "INFO" "Preset '${DISPLAY_PRESET}' -> graph ${WIDTH}x${HEIGHT}, container ${container_px}px, font ${font_px}px"
  95. }
  96. validate_numeric() {
  97. local value="$1"
  98. local name="$2"
  99. if ! [[ "$value" =~ ^[0-9]+$ ]]; then
  100. log_message "ERROR" "Invalid $name: $value. Must be numeric."
  101. exit 1
  102. fi
  103. }
  104. check_commands() {
  105. local commands=("rrdtool" "chronyc" "sudo" "timeout")
  106. if [[ "$ENABLE_NETWORK_STATS" == "yes" ]]; then
  107. commands+=("vnstati")
  108. fi
  109. for cmd in "${commands[@]}"; do
  110. if ! command -v "$cmd" &>/dev/null; then
  111. log_message "ERROR" "Command '$cmd' not found in PATH."
  112. exit 1
  113. fi
  114. done
  115. }
  116. setup_directories() {
  117. log_message "INFO" "Checking and preparing directories..."
  118. for dir in "$OUTPUT_DIR" "$RRD_DIR" "$OUTPUT_DIR/img"; do
  119. mkdir -p "$dir" || {
  120. log_message "ERROR" "Failed to create directory: $dir"
  121. exit 1
  122. }
  123. if [ ! -w "$dir" ]; then
  124. log_message "ERROR" "Directory '$dir' is not writable."
  125. exit 1
  126. fi
  127. done
  128. }
  129. generate_vnstat_images() {
  130. if [[ "$ENABLE_NETWORK_STATS" != "yes" ]]; then
  131. log_message "INFO" "Network stats disabled, skipping vnStat image generation..."
  132. return 0
  133. fi
  134. log_message "INFO" "Generating vnStat images for interface '$INTERFACE'..."
  135. local modes=("s" "d" "t" "h" "m" "y")
  136. for mode in "${modes[@]}"; do
  137. vnstati -"$mode" -i "$INTERFACE" -o "$OUTPUT_DIR/img/vnstat_${mode}.png" || {
  138. log_message "ERROR" "Failed to generate vnstat image for mode $mode Check configuaration section : INTERFACE=\"here\""
  139. exit 1
  140. }
  141. done
  142. }
  143. collect_chrony_data() {
  144. log_message "INFO" "Collecting Chrony data..."
  145. local CHRONYC_OPTS=""
  146. if [[ "$CHRONY_ALLOW_DNS_LOOKUP" == "no" ]]; then
  147. CHRONYC_OPTS="-n"
  148. log_message "INFO" "Using chronyc -n option to prevent DNS lookups"
  149. fi
  150. get_html() {
  151. timeout "$TIMEOUT_SECONDS"s sudo chronyc $CHRONYC_OPTS "$1" -v 2>&1 | sed 's/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g' || {
  152. log_message "ERROR" "Failed to collect chronyc $1 data"
  153. return 1
  154. }
  155. }
  156. RAW_TRACKING=$(timeout "$TIMEOUT_SECONDS"s sudo chronyc $CHRONYC_OPTS tracking) || {
  157. log_message "ERROR" "Failed to collect chronyc tracking data"
  158. exit 1
  159. }
  160. CHRONYC_TRACKING_HTML=$(echo "$RAW_TRACKING" | sed 's/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g')
  161. CHRONYC_SOURCES=$(get_html sources) || exit 1
  162. CHRONYC_SOURCESTATS=$(get_html sourcestats) || exit 1
  163. CHRONYC_SELECTDATA=$(get_html selectdata) || exit 1
  164. }
  165. extract_chronyc_values() {
  166. extract_val() {
  167. echo "$RAW_TRACKING" | awk "/$1/ {print \$($2)}" | grep -E '^[-+]?[0-9.]+$' || echo "U"
  168. }
  169. OFFSET=$(extract_val "Last offset" "NF-1")
  170. local systime_line
  171. systime_line=$(echo "$RAW_TRACKING" | grep "System time")
  172. if [[ -n "$systime_line" ]]; then
  173. local value
  174. value=$(echo "$systime_line" | awk '{print $4}')
  175. if [[ "$systime_line" == *"slow"* ]]; then
  176. SYSTIME="-$value"
  177. else
  178. SYSTIME="$value"
  179. fi
  180. else
  181. SYSTIME="U"
  182. fi
  183. FREQ=$(extract_val "Frequency" "NF-2")
  184. RESID_FREQ=$(extract_val "Residual freq" "NF-1")
  185. SKEW=$(extract_val "Skew" "NF-1")
  186. DELAY=$(extract_val "Root delay" "NF-1")
  187. DISPERSION=$(extract_val "Root dispersion" "NF-1")
  188. STRATUM=$(extract_val "Stratum" "3")
  189. local CHRONYC_OPTS=""
  190. if [[ "$CHRONY_ALLOW_DNS_LOOKUP" == "no" ]]; then
  191. CHRONYC_OPTS="-n"
  192. fi
  193. RAW_STATS=$(LC_ALL=C sudo chronyc $CHRONYC_OPTS serverstats) || {
  194. log_message "ERROR" "Failed to collect chronyc serverstats"
  195. exit 1
  196. }
  197. get_stat() {
  198. echo "$RAW_STATS" | awk -F'[[:space:]]*:[[:space:]]*' "/$1/ {print \$2}" | grep -E '^[0-9]+$' || echo "U"
  199. }
  200. PKTS_RECV=$(get_stat "NTP packets received")
  201. PKTS_DROP=$(get_stat "NTP packets dropped")
  202. CMD_RECV=$(get_stat "Command packets received")
  203. CMD_DROP=$(get_stat "Command packets dropped")
  204. LOG_DROP=$(get_stat "Client log records dropped")
  205. INTERLEAVED=$(get_stat "Interleaved NTP packets")
  206. TS_HELD=$(get_stat "NTP timestamps held")
  207. }
  208. create_rrd_database() {
  209. if [ ! -f "$RRD_FILE" ]; then
  210. log_message "INFO" "Creating new RRD file: $RRD_FILE"
  211. LC_ALL=C rrdtool create "$RRD_FILE" --step 300 \
  212. 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 \
  213. DS:delay:GAUGE:600:U:U DS:dispersion:GAUGE:600:U:U DS:stratum:GAUGE:600:0:16 \
  214. DS:systime:GAUGE:600:U:U \
  215. DS:pkts_recv:COUNTER:600:0:U DS:pkts_drop:COUNTER:600:0:U DS:cmd_recv:COUNTER:600:0:U \
  216. DS:cmd_drop:COUNTER:600:0:U DS:log_drop:COUNTER:600:0:U DS:interleaved:COUNTER:600:0:U \
  217. DS:ts_held:GAUGE:600:0:U \
  218. RRA:AVERAGE:0.5:1:576 RRA:AVERAGE:0.5:6:672 RRA:AVERAGE:0.5:24:732 RRA:AVERAGE:0.5:288:730 \
  219. RRA:MAX:0.5:1:576 RRA:MAX:0.5:6:672 RRA:MAX:0.5:24:732 RRA:MAX:0.5:288:730 \
  220. RRA:MIN:0.5:1:576 RRA:MIN:0.5:6:672 RRA:MIN:0.5:24:732 RRA:MIN:0.5:288:730 || {
  221. log_message "ERROR" "Failed to create RRD database"
  222. exit 1
  223. }
  224. fi
  225. }
  226. update_rrd_database() {
  227. log_message "INFO" "Updating RRD database..."
  228. 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"
  229. LC_ALL=C rrdtool update "$RRD_FILE" "$UPDATE_STRING" || {
  230. log_message "ERROR" "Failed to update RRD database"
  231. exit 1
  232. }
  233. }
  234. generate_graphs() {
  235. log_message "INFO" "Generating graphs..."
  236. local END_TIME=$(date +%s)
  237. declare -A time_periods=(
  238. ["day"]="end-1d"
  239. ["week"]="end-1w"
  240. ["month"]="end-1m"
  241. )
  242. declare -A period_titles=(
  243. ["day"]="by day"
  244. ["week"]="by week"
  245. ["month"]="by month"
  246. )
  247. declare -A graphs=(
  248. ["chrony_serverstats"]="--title 'Chrony Server Statistics - PERIOD_TITLE' --vertical-label 'Packets/second' \
  249. --lower-limit 0 --rigid --units-exponent 0 \
  250. DEF:pkts_recv_raw='$RRD_FILE':pkts_recv:AVERAGE \
  251. DEF:pkts_drop_raw='$RRD_FILE':pkts_drop:AVERAGE \
  252. DEF:cmd_recv_raw='$RRD_FILE':cmd_recv:AVERAGE \
  253. DEF:cmd_drop_raw='$RRD_FILE':cmd_drop:AVERAGE \
  254. DEF:log_drop_raw='$RRD_FILE':log_drop:AVERAGE \
  255. CDEF:pkts_recv=pkts_recv_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,pkts_recv_raw,IF \
  256. CDEF:pkts_drop=pkts_drop_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,pkts_drop_raw,IF \
  257. CDEF:cmd_recv=cmd_recv_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,cmd_recv_raw,IF \
  258. CDEF:cmd_drop=cmd_drop_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,cmd_drop_raw,IF \
  259. CDEF:log_drop=log_drop_raw,$SERVER_STATS_UPPER_LIMIT,GT,UNKN,log_drop_raw,IF \
  260. 'COMMENT: \l' \
  261. 'AREA:pkts_recv#C4FFC4:Packets received ' \
  262. 'LINE1:pkts_recv#00E000:' \
  263. 'GPRINT:pkts_recv:LAST:Cur\: %5.2lf%s' \
  264. 'GPRINT:pkts_recv:MIN:Min\: %5.2lf%s' \
  265. 'GPRINT:pkts_recv:AVERAGE:Avg\: %5.2lf%s' \
  266. 'GPRINT:pkts_recv:MAX:Max\: %5.2lf%s\l' \
  267. 'LINE1:pkts_drop#FF8C00:Packets dropped ' \
  268. 'GPRINT:pkts_drop:LAST:Cur\: %5.2lf%s' \
  269. 'GPRINT:pkts_drop:MIN:Min\: %5.2lf%s' \
  270. 'GPRINT:pkts_drop:AVERAGE:Avg\: %5.2lf%s' \
  271. 'GPRINT:pkts_drop:MAX:Max\: %5.2lf%s\l' \
  272. 'LINE1:cmd_recv#4169E1:Command packets received ' \
  273. 'GPRINT:cmd_recv:LAST:Cur\: %5.2lf%s' \
  274. 'GPRINT:cmd_recv:MIN:Min\: %5.2lf%s' \
  275. 'GPRINT:cmd_recv:AVERAGE:Avg\: %5.2lf%s' \
  276. 'GPRINT:cmd_recv:MAX:Max\: %5.2lf%s\l' \
  277. 'LINE1:cmd_drop#FFD700:Command packets dropped ' \
  278. 'GPRINT:cmd_drop:LAST:Cur\: %5.2lf%s' \
  279. 'GPRINT:cmd_drop:MIN:Min\: %5.2lf%s' \
  280. 'GPRINT:cmd_drop:AVERAGE:Avg\: %5.2lf%s' \
  281. 'GPRINT:cmd_drop:MAX:Max\: %5.2lf%s\l' \
  282. 'LINE1:log_drop#9400D3:Client log records dropped ' \
  283. 'GPRINT:log_drop:LAST:Cur\: %5.2lf%s' \
  284. 'GPRINT:log_drop:MIN:Min\: %5.2lf%s' \
  285. 'GPRINT:log_drop:AVERAGE:Avg\: %5.2lf%s' \
  286. 'GPRINT:log_drop:MAX:Max\: %5.2lf%s\l'"
  287. ["chrony_tracking"]="--title 'Chrony Dispersion + Stratum - PERIOD_TITLE' --vertical-label 'milliseconds' --alt-autoscale \
  288. --units-exponent 0 \
  289. DEF:stratum='$RRD_FILE':stratum:AVERAGE \
  290. DEF:freq='$RRD_FILE':frequency:AVERAGE \
  291. DEF:skew='$RRD_FILE':skew:AVERAGE \
  292. DEF:delay='$RRD_FILE':delay:AVERAGE \
  293. DEF:dispersion='$RRD_FILE':dispersion:AVERAGE \
  294. CDEF:skew_scaled=skew,100,* \
  295. CDEF:delay_scaled=delay,1000,* \
  296. CDEF:disp_scaled=dispersion,1000,* \
  297. 'COMMENT: \l' \
  298. 'LINE1:stratum#00ff00:Stratum ' \
  299. 'GPRINT:stratum:LAST: Cur\: %5.2lf%s' \
  300. 'GPRINT:stratum:MIN:Min\: %5.2lf%s' \
  301. 'GPRINT:stratum:AVERAGE:Avg\: %5.2lf%s' \
  302. 'GPRINT:stratum:MAX:Max\: %5.2lf%s\l' \
  303. 'LINE1:disp_scaled#9400D3:Root dispersion [Root dispersion] ' \
  304. 'GPRINT:disp_scaled:LAST: Cur\: %5.2lf%s' \
  305. 'GPRINT:disp_scaled:MIN:Min\: %5.2lf%s' \
  306. 'GPRINT:disp_scaled:AVERAGE:Avg\: %5.2lf%s' \
  307. 'GPRINT:disp_scaled:MAX:Max\: %5.2lf%s\l'"
  308. ["chrony_offset"]="--title 'Chrony System Time Offset - PERIOD_TITLE' --vertical-label 'milliseconds' \
  309. DEF:offset='$RRD_FILE':offset:AVERAGE \
  310. DEF:systime='$RRD_FILE':systime:AVERAGE \
  311. CDEF:systime_scaled=systime,1000,* \
  312. CDEF:offset_ms=offset,1000,* \
  313. 'LINE2:offset_ms#00ff00:Actual Offset from NTP Source [Last Offset] ' \
  314. 'GPRINT:offset_ms:LAST: Cur\: %5.2lf%s' \
  315. 'GPRINT:offset_ms:MIN:Min\: %5.2lf%s' \
  316. 'GPRINT:offset_ms:AVERAGE:Avg\: %5.2lf%s' \
  317. 'GPRINT:offset_ms:MAX:Max\: %5.2lf%s\l' \
  318. 'LINE1:systime_scaled#4169E1:System Clock Adjustment [System Time] ' \
  319. 'GPRINT:systime_scaled:LAST: Cur\: %5.2lf%s' \
  320. 'GPRINT:systime_scaled:MIN:Min\: %5.2lf%s' \
  321. 'GPRINT:systime_scaled:AVERAGE:Avg\: %5.2lf%s' \
  322. 'GPRINT:systime_scaled:MAX:Max\: %5.2lf%s\l'"
  323. ["chrony_delay"]="--title 'Chrony Root Delay - PERIOD_TITLE' --vertical-label 'milliseconds' --units-exponent 0 \
  324. DEF:delay='$RRD_FILE':delay:AVERAGE \
  325. CDEF:delay_ms=delay,1000,* \
  326. LINE2:delay_ms#00ff00:'Network Delay to Root Source [Root Delay] ' \
  327. 'GPRINT:delay_ms:LAST:Cur\: %5.2lf%s' \
  328. 'GPRINT:delay_ms:MIN:Min\: %5.2lf%s' \
  329. 'GPRINT:delay_ms:AVERAGE:Avg\: %5.2lf%s' \
  330. 'GPRINT:delay_ms:MAX:Max\: %5.2lf%s\l'"
  331. ["chrony_frequency"]="--title 'Chrony Clock Frequency Error - PERIOD_TITLE' --vertical-label 'ppm'\
  332. DEF:freq='$RRD_FILE':frequency:AVERAGE \
  333. DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \
  334. CDEF:resfreq_scaled=resid_freq,100,* \
  335. CDEF:freq_scaled=freq,1,* \
  336. 'LINE2:freq_scaled#00ff00:Natural Clock Drift [Frequency] ' \
  337. 'GPRINT:freq_scaled:LAST:Cur\: %5.2lf%s' \
  338. 'GPRINT:freq_scaled:MIN:Min\: %5.2lf%s' \
  339. 'GPRINT:freq_scaled:AVERAGE:Avg\: %5.2lf%s' \
  340. 'GPRINT:freq_scaled:MAX:Max\: %5.2lf%s\n' \
  341. 'LINE1:resfreq_scaled#4169E1:Residual Drift (x100) [Residual freq] ' \
  342. 'GPRINT:resfreq_scaled:LAST:Cur\: %5.2lf%s' \
  343. 'GPRINT:resfreq_scaled:MIN:Min\: %5.2lf%s' \
  344. 'GPRINT:resfreq_scaled:AVERAGE:Avg\: %5.2lf%s' \
  345. 'GPRINT:resfreq_scaled:MAX:Max\: %5.2lf%s\l'"
  346. ["chrony_drift"]="--title 'Chrony Drift Margin Error - PERIOD_TITLE' --vertical-label 'ppm' \
  347. --units-exponent 0 \
  348. DEF:resid_freq='$RRD_FILE':resid_freq:AVERAGE \
  349. DEF:skew_raw='$RRD_FILE':skew:AVERAGE \
  350. CDEF:resfreq_scaled=resid_freq,100,* \
  351. CDEF:skew_scaled=skew_raw,100,* \
  352. 'COMMENT: \l' \
  353. 'LINE1:skew_scaled#00ff00:Estimate Drift Error Margin (x100) [Skew] ' \
  354. 'GPRINT:skew_scaled:LAST:Cur\: %5.2lf' \
  355. 'GPRINT:skew_scaled:MIN:Min\: %5.2lf' \
  356. 'GPRINT:skew_scaled:AVERAGE:Avg\: %5.2lf' \
  357. 'GPRINT:skew_scaled:MAX:Max\: %5.2lf\l'"
  358. )
  359. for period in "${!time_periods[@]}"; do
  360. for graph in "${!graphs[@]}"; do
  361. local graph_title="${graphs[$graph]//PERIOD_TITLE/${period_titles[$period]}}"
  362. local output_file="$OUTPUT_DIR/img/${graph}_${period}.png"
  363. local time_range="${time_periods[$period]}"
  364. local cmd="LC_ALL=C rrdtool graph '$output_file' --width '$WIDTH' --height '$HEIGHT' --start $time_range --end now-180s $graph_title"
  365. eval "$cmd" || {
  366. log_message "ERROR" "Failed to generate graph: ${graph}_${period}"
  367. exit 1
  368. }
  369. done
  370. done
  371. }
  372. generate_html() {
  373. log_message "INFO" "Generating HTML report..."
  374. local GENERATED_TIMESTAMP=$(date)
  375. local CHRONYC_DISPLAY_OPTS=""
  376. if [[ "$CHRONY_ALLOW_DNS_LOOKUP" == "no" ]]; then
  377. CHRONYC_DISPLAY_OPTS=" -n"
  378. fi
  379. local AUTO_REFRESH_META=""
  380. if [[ "$AUTO_REFRESH_SECONDS" -gt 0 ]]; then
  381. AUTO_REFRESH_META=" <meta http-equiv=\"refresh\" content=\"$AUTO_REFRESH_SECONDS\">"
  382. fi
  383. cat >"$OUTPUT_DIR/$HTML_FILENAME" <<EOF
  384. <!DOCTYPE html>
  385. <html lang="en">
  386. <head>
  387. <meta charset="utf-8">
  388. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  389. $AUTO_REFRESH_META
  390. <title>${PAGE_TITLE} - Server Status</title>
  391. <style>
  392. :root {
  393. --primary-text: #212529;
  394. --secondary-text: #6c757d;
  395. --background-color: #f8f9fa;
  396. --content-background: #ffffff;
  397. --border-color: #787879;
  398. --code-background: #e1e1e1;
  399. --code-text: #000000;
  400. --container-max: 1400px;
  401. --font-size-base: 16px;
  402. }
  403. $CSS_CUSTOM_ROOT
  404. body {
  405. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  406. margin: 0;
  407. padding: 20px;
  408. background-color: var(--background-color);
  409. color: var(--primary-text);
  410. line-height: 1.6;
  411. font-size: var(--font-size-base);
  412. }
  413. .container {
  414. max-width: var(--container-max);
  415. margin: 0 auto;
  416. background-color: var(--content-background);
  417. padding: 20px 20px;
  418. border-radius: 8px;
  419. box-shadow: 0 4px 8px rgba(0,0,0,0.05);
  420. }
  421. header {
  422. text-align: center;
  423. border-bottom: 1px solid var(--border-color);
  424. padding-bottom: 20px;
  425. margin-bottom: 30px;
  426. }
  427. header h1 {
  428. margin: 0;
  429. font-size: 2.5em;
  430. color: var(--primary-text);
  431. }
  432. section {
  433. margin-bottom: 40px;
  434. }
  435. h2 {
  436. font-size: 1.8em;
  437. color: var(--primary-text);
  438. border-bottom: 1px solid var(--border-color);
  439. padding-bottom: 10px;
  440. margin-top: 0;
  441. margin-bottom: 20px;
  442. }
  443. h2 a {
  444. font-size: 0.8em;
  445. font-weight: normal;
  446. vertical-align: middle;
  447. margin-left: 10px;
  448. }
  449. h3 {
  450. font-size: 1.3em;
  451. color: var(--primary-text);
  452. margin-top: 25px;
  453. }
  454. @media (max-width: 767px) {
  455. #vnstat-graphs table,
  456. #vnstat-graphs tbody,
  457. #vnstat-graphs tr,
  458. #vnstat-graphs td {
  459. display: block;
  460. width: 100%;
  461. }
  462. #vnstat-graphs td {
  463. padding-left: 0;
  464. padding-right: 0;
  465. text-align: center;
  466. }
  467. }
  468. .graph-grid {
  469. display: grid;
  470. grid-template-columns: 1fr;
  471. gap: 10px;
  472. text-align: center;
  473. }
  474. @media (min-width: 768px) {
  475. .graph-grid {
  476. grid-template-columns: repeat(2, 1fr);
  477. }
  478. }
  479. figure {
  480. margin: 0;
  481. padding: 0;
  482. }
  483. img {
  484. max-width: 100%;
  485. height: auto;
  486. border: 1px solid var(--border-color);
  487. border-radius: 4px;
  488. box-shadow: 0 2px 4px rgba(0,0,0,0.05);
  489. cursor: zoom-in;
  490. }
  491. .lightbox-overlay {
  492. position: fixed;
  493. inset: 0;
  494. background: rgba(0, 0, 0, 0.85);
  495. display: none;
  496. align-items: center;
  497. justify-content: center;
  498. z-index: 9999;
  499. cursor: zoom-out;
  500. }
  501. .lightbox-overlay.open {
  502. display: flex;
  503. }
  504. .lightbox-img {
  505. width: 96vw;
  506. height: 94vh;
  507. object-fit: contain;
  508. border: 0;
  509. cursor: zoom-out;
  510. }
  511. pre {
  512. background-color: var(--code-background);
  513. color: var(--code-text);
  514. padding: 10px;
  515. border: 1px solid #c3bebe;
  516. border-radius: 4px;
  517. overflow-x: auto;
  518. white-space: pre-wrap;
  519. word-wrap: break-word;
  520. font-size: 0.8em;
  521. }
  522. footer {
  523. text-align: center;
  524. margin-top: 40px;
  525. padding-top: 20px;
  526. border-top: 1px solid var(--border-color);
  527. font-size: 0.9em;
  528. color: var(--secondary-text);
  529. }
  530. .tabs {
  531. display: flex;
  532. border-bottom: 1px solid var(--border-color);
  533. margin-bottom: 20px;
  534. }
  535. .tab {
  536. padding: 10px 20px;
  537. cursor: pointer;
  538. background-color: var(--background-color);
  539. border: 1px solid var(--border-color);
  540. border-bottom: none;
  541. margin-right: 2px;
  542. transition: background-color 0.3s;
  543. }
  544. .tab:hover {
  545. background-color: #e9ecef;
  546. }
  547. .tab.active {
  548. background-color: var(--content-background);
  549. border-bottom: 1px solid var(--content-background);
  550. margin-bottom: -1px;
  551. }
  552. .tab-content {
  553. display: none;
  554. }
  555. .tab-content.active {
  556. display: block;
  557. }
  558. </style>
  559. </head>
  560. <body>
  561. <div class="container">
  562. <main>
  563. <section id="chrony-graphs">
  564. <h2>Chrony Graphs <a target="_blank" href="https://chrony-project.org/doc/4.3/chronyc.html#:~:text=System%20clock-,tracking,-The%20tracking%20command">[Data Legend]</a></h2>
  565. <div class="tabs">
  566. <div class="tab active" onclick="showTab('day')">Day</div>
  567. <div class="tab" onclick="showTab('week')">Week</div>
  568. <div class="tab" onclick="showTab('month')">Month</div>
  569. </div>
  570. <div id="day-content" class="tab-content active">
  571. <div class="graph-grid">
  572. <figure>
  573. <img src="img/chrony_serverstats_day.png" alt="Chrony server statistics graph - day">
  574. </figure>
  575. <figure>
  576. <img src="img/chrony_offset_day.png" alt="Chrony system clock offset graph - day">
  577. </figure>
  578. <figure>
  579. <img src="img/chrony_tracking_day.png" alt="Chrony system clock tracking graph - day">
  580. </figure>
  581. <figure>
  582. <img src="img/chrony_delay_day.png" alt="Chrony sync delay graph - day">
  583. </figure>
  584. <figure>
  585. <img src="img/chrony_frequency_day.png" alt="Chrony clock frequency graph - day">
  586. </figure>
  587. <figure>
  588. <img src="img/chrony_drift_day.png" alt="Chrony clock frequency drift graph - day">
  589. </figure>
  590. </div>
  591. </div>
  592. <div id="week-content" class="tab-content">
  593. <div class="graph-grid">
  594. <figure>
  595. <img src="img/chrony_serverstats_week.png" alt="Chrony server statistics graph - week">
  596. </figure>
  597. <figure>
  598. <img src="img/chrony_offset_week.png" alt="Chrony system clock offset graph - week">
  599. </figure>
  600. <figure>
  601. <img src="img/chrony_tracking_week.png" alt="Chrony system clock tracking graph - week">
  602. </figure>
  603. <figure>
  604. <img src="img/chrony_delay_week.png" alt="Chrony sync delay graph - week">
  605. </figure>
  606. <figure>
  607. <img src="img/chrony_frequency_week.png" alt="Chrony clock frequency graph - week">
  608. </figure>
  609. <figure>
  610. <img src="img/chrony_drift_week.png" alt="Chrony clock frequency drift graph - week">
  611. </figure>
  612. </div>
  613. </div>
  614. <div id="month-content" class="tab-content">
  615. <div class="graph-grid">
  616. <figure>
  617. <img src="img/chrony_serverstats_month.png" alt="Chrony server statistics graph - month">
  618. </figure>
  619. <figure>
  620. <img src="img/chrony_offset_month.png" alt="Chrony system clock offset graph - month">
  621. </figure>
  622. <figure>
  623. <img src="img/chrony_tracking_month.png" alt="Chrony system clock tracking graph - month">
  624. </figure>
  625. <figure>
  626. <img src="img/chrony_delay_month.png" alt="Chrony sync delay graph - month">
  627. </figure>
  628. <figure>
  629. <img src="img/chrony_frequency_month.png" alt="Chrony clock frequency graph - month">
  630. </figure>
  631. <figure>
  632. <img src="img/chrony_drift_month.png" alt="Chrony clock frequency drift graph - month">
  633. </figure>
  634. </div>
  635. </div>
  636. </section>
  637. EOF
  638. if [[ "$ENABLE_NETWORK_STATS" == "yes" ]]; then
  639. cat >>"$OUTPUT_DIR/$HTML_FILENAME" <<EOF
  640. <section id="vnstat-graphs">
  641. <h2>vnStati Graphs</h2>
  642. <table border="0" style="margin-left: auto; margin-right: auto;">
  643. <tbody>
  644. <tr>
  645. <td valign="top" style="padding: 0 10px;">
  646. <img src="img/vnstat_s.png" alt="vnStat summary"><br>
  647. <img src="img/vnstat_d.png" alt="vnStat daily" style="margin-top: 4px;"><br>
  648. <img src="img/vnstat_t.png" alt="vnStat top 10" style="margin-top: 4px;"><br>
  649. </td>
  650. <td valign="top" style="padding: 0 10px;">
  651. <img src="img/vnstat_h.png" alt="vnStat hourly"><br>
  652. <img src="img/vnstat_m.png" alt="vnStat monthly" style="margin-top: 4px;"><br>
  653. <img src="img/vnstat_y.png" alt="vnStat yearly" style="margin-top: 4px;"><br>
  654. </td>
  655. </tr>
  656. </tbody>
  657. </table>
  658. </section>
  659. EOF
  660. fi
  661. cat >>"$OUTPUT_DIR/$HTML_FILENAME" <<EOF
  662. <section id="chrony-stats">
  663. <h2>Chrony - NTP Statistics</h2>
  664. <h3>Command: <code>chronyc${CHRONYC_DISPLAY_OPTS} sources -v</code></h3>
  665. <pre><code>${CHRONYC_SOURCES}</code></pre>
  666. <h3>Command: <code>chronyc${CHRONYC_DISPLAY_OPTS} selectdata -v</code></h3>
  667. <pre><code>${CHRONYC_SELECTDATA}</code></pre>
  668. <h3>Command: <code>chronyc${CHRONYC_DISPLAY_OPTS} sourcestats -v</code></h3>
  669. <pre><code>${CHRONYC_SOURCESTATS}</code></pre>
  670. <h3>Command: <code>chronyc${CHRONYC_DISPLAY_OPTS} tracking</code></h3>
  671. <pre><code>${CHRONYC_TRACKING_HTML}</code></pre>
  672. </section>
  673. </main>
  674. <footer>
  675. <p>Page generated on: ${GENERATED_TIMESTAMP}</p>
  676. EOF
  677. if [[ "$GITHUB_REPO_LINK_SHOW" == "yes" ]]; then
  678. cat >>"$OUTPUT_DIR/$HTML_FILENAME" <<EOF
  679. <p>Made with ❤️ by TheHuman00 | <a href="https://github.com/TheHuman00/chrony-stats" target="_blank">View on GitHub</a></p>
  680. <p>Modified from original script by Medowar<a href="https://www.medowar.de>" target="_blank"> (https://www.medowar.de)</a></p>
  681. EOF
  682. fi
  683. cat >>"$OUTPUT_DIR/$HTML_FILENAME" <<EOF
  684. </footer>
  685. </div>
  686. <div id="lightbox" class="lightbox-overlay" aria-hidden="true" role="dialog">
  687. <img id="lightbox-img" class="lightbox-img" alt="Expanded graph">
  688. </div>
  689. <script>
  690. function showTab(period) {
  691. const contents = document.querySelectorAll('.tab-content');
  692. contents.forEach(content => content.classList.remove('active'));
  693. const tabs = document.querySelectorAll('.tab');
  694. tabs.forEach(tab => tab.classList.remove('active'));
  695. document.getElementById(period + '-content').classList.add('active');
  696. const evt = event || window.event; // works with inline onclick
  697. if (evt && evt.target) {
  698. }
  699. evt.target.classList.add('active');
  700. }
  701. (function enableImageLightbox() {
  702. const overlay = document.getElementById('lightbox');
  703. const overlayImg = document.getElementById('lightbox-img');
  704. if (!overlay || !overlayImg) return;
  705. const open = (src, alt) => {
  706. overlayImg.src = src;
  707. overlayImg.alt = alt || 'Expanded image';
  708. overlay.classList.add('open');
  709. overlay.setAttribute('aria-hidden', 'false');
  710. // Prevent background scroll
  711. document.body.style.overflow = 'hidden';
  712. };
  713. const close = () => {
  714. overlay.classList.remove('open');
  715. overlay.setAttribute('aria-hidden', 'true');
  716. overlayImg.src = '';
  717. document.body.style.overflow = '';
  718. };
  719. document.querySelectorAll('.container img').forEach(img => {
  720. img.addEventListener('click', () => open(img.src, img.alt));
  721. });
  722. overlay.addEventListener('click', close);
  723. overlayImg.addEventListener('click', close);
  724. document.addEventListener('keydown', (e) => {
  725. if (e.key === 'Escape' && overlay.classList.contains('open')) close();
  726. });
  727. })();
  728. </script>
  729. </body>
  730. </html>
  731. EOF
  732. }
  733. main() {
  734. log_message "INFO" "Starting chrony-network-stats script..."
  735. validate_numeric "$WIDTH" "WIDTH"
  736. validate_numeric "$HEIGHT" "HEIGHT"
  737. validate_numeric "$TIMEOUT_SECONDS" "TIMEOUT_SECONDS"
  738. validate_numeric "$SERVER_STATS_UPPER_LIMIT" "SERVER_STATS_UPPER_LIMIT"
  739. validate_numeric "$AUTO_REFRESH_SECONDS" "AUTO_REFRESH_SECONDS"
  740. configure_display_preset
  741. check_commands
  742. setup_directories
  743. generate_vnstat_images
  744. collect_chrony_data
  745. extract_chronyc_values
  746. create_rrd_database
  747. update_rrd_database
  748. generate_graphs
  749. generate_html
  750. log_message "INFO" "HTML page and graphs generated in: $OUTPUT_DIR/$HTML_FILENAME"
  751. echo "✅ Successfully generated report"
  752. }
  753. main