diff --git a/hosts/lab/default.nix b/hosts/lab/default.nix index 2900ad1..a91450d 100644 --- a/hosts/lab/default.nix +++ b/hosts/lab/default.nix @@ -18,15 +18,36 @@ services.uptime = { enable = true; - interval = "1min"; displayDays = 90; - services = { - website = "https://ily.rs"; - forgejo = "https://git.ily.rs"; - foundry = "https://foundry.ily.rs"; - wiki = "https://wiki.ily.rs/health-ping"; - penfield = "https://penfield.ily.rs"; - }; + intro = '' + This status page is written in pure bash. It tracks 90 days of + historical data per service. Each category may probe at its own + interval; that's noted next to the category description. + ''; + categories = [ + { + description = "These first sites are all hosted personally."; + intervalSeconds = 60; + services = [ + { name = "website"; url = "https://ily.rs"; } + { name = "guestbook"; url = "https://ily.rs/guestbook"; } + { name = "git"; url = "https://git.ily.rs"; } + { name = "records"; url = "https://c.ily.rs"; } + { name = "penfield"; url = "https://penfield.ily.rs"; } + { name = "wiki"; url = "https://wiki.ily.rs/health-ping"; } + { name = "foundry"; url = "https://foundry.ily.rs"; } + ]; + } + { + description = "Other services I like to keep track of."; + intervalSeconds = 300; + hideUrls = true; + services = [ + { name = "co-surf"; url = "https://co-surf.com"; } + { name = "frontline"; url = "https://essexfrontline.org.uk"; } + ]; + } + ]; }; networking.hostName = "lab"; diff --git a/modules/uptime/default.nix b/modules/uptime/default.nix index 287153e..e8ccca3 100644 --- a/modules/uptime/default.nix +++ b/modules/uptime/default.nix @@ -2,8 +2,62 @@ let cfg = config.services.uptime; - servicesEnv = lib.concatStringsSep "\n" - (lib.mapAttrsToList (name: url: "${name} ${url}") cfg.services); + serviceSubmodule = lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Service name. Used as the log filename and the row label."; + }; + url = lib.mkOption { + type = lib.types.str; + description = "URL to probe."; + }; + }; + }; + + categorySubmodule = lib.types.submodule { + options = { + description = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Free-form text shown above this category's table."; + }; + intervalSeconds = lib.mkOption { + type = lib.types.int; + default = 60; + description = "Minimum interval between probes for services in this category."; + }; + hideUrls = lib.mkOption { + type = lib.types.bool; + default = false; + description = "If true, omit the URL column when rendering services in this category."; + }; + services = lib.mkOption { + type = lib.types.listOf serviceSubmodule; + default = []; + example = [{ name = "git"; url = "https://git.ily.rs"; }]; + description = "Ordered list of services to probe. Render order matches list order."; + }; + }; + }; + + configFile = pkgs.writeText "uptime-config" (lib.concatStringsSep "\n" ( + (lib.imap0 (i: cat: + "CAT\t${toString i}\t${toString cat.intervalSeconds}\t${if cat.hideUrls then "1" else "0"}\t${cat.description}" + ) cfg.categories) + ++ + (lib.concatLists (lib.imap0 (i: cat: + map (svc: "SVC\t${toString i}\t${svc.name}\t${svc.url}") cat.services + ) cfg.categories)) + )); + + introFile = pkgs.writeText "uptime-intro" cfg.intro; + + catIntervals = map (c: c.intervalSeconds) cfg.categories; + timerSeconds = + if catIntervals == [] + then 60 + else lib.foldl' (a: b: if a < b then a else b) 86400 catIntervals; runScript = pkgs.writeShellApplication { name = "uptime-run"; @@ -15,12 +69,6 @@ in options.services.uptime = { enable = lib.mkEnableOption "minimal text-only uptime status page"; - interval = lib.mkOption { - type = lib.types.str; - default = "5min"; - description = "Probe interval, passed to the timer's OnUnitActiveSec."; - }; - outputPath = lib.mkOption { type = lib.types.str; default = "/var/lib/uptime/status.txt"; @@ -31,11 +79,16 @@ in ''; }; - services = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - default = {}; - example = { forgejo = "https://git.ily.rs"; }; - description = "Map of service name to URL to probe."; + intro = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Free-form text shown at the top of the status page (before any categories)."; + }; + + categories = lib.mkOption { + type = lib.types.listOf categorySubmodule; + default = []; + description = "Ordered list of service categories. Each is rendered as its own table."; }; retentionDays = lib.mkOption { @@ -47,7 +100,7 @@ in displayDays = lib.mkOption { type = lib.types.int; default = 30; - description = "How many days of history to render in the long-term bar (1 cell = 1 day)."; + description = "How many days of history to render in the long-term bar."; }; displayHours = lib.mkOption { @@ -75,7 +128,8 @@ in after = [ "network-online.target" ]; wants = [ "network-online.target" ]; environment = { - SERVICES = servicesEnv; + CONFIG_PATH = "${configFile}"; + INTRO_PATH = "${introFile}"; OUTPUT_PATH = cfg.outputPath; RETENTION_DAYS = toString cfg.retentionDays; DISPLAY_DAYS = toString cfg.displayDays; @@ -97,7 +151,7 @@ in wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = "1min"; - OnUnitActiveSec = cfg.interval; + OnUnitActiveSec = "${toString timerSeconds}s"; Unit = "uptime.service"; }; }; diff --git a/modules/uptime/run.sh b/modules/uptime/run.sh index e01e3fb..7168a76 100644 --- a/modules/uptime/run.sh +++ b/modules/uptime/run.sh @@ -1,4 +1,5 @@ -: "${SERVICES:?must be set}" +: "${CONFIG_PATH:?must be set}" +: "${INTRO_PATH:?must be set}" : "${OUTPUT_PATH:?must be set}" : "${RETENTION_DAYS:?must be set}" : "${DISPLAY_DAYS:?must be set}" @@ -10,21 +11,38 @@ mkdir -p "$state_dir" now=$(date -u +%s) retention_cutoff=$(( now - RETENTION_DAYS * 86400 )) +# Parse categories into parallel arrays indexed by category number. +cat_intervals=() +cat_hideurls=() +cat_descriptions=() +while IFS=$'\t' read -r tag idx interval hideurls desc; do + if [ "$tag" = "CAT" ]; then + cat_intervals[$idx]=$interval + cat_hideurls[$idx]=$hideurls + cat_descriptions[$idx]=$desc + fi +done < "$CONFIG_PATH" + +# Compute name column width across all services. max_name_len=0 -while IFS= read -r line; do - [ -z "$line" ] && continue - n=${line%% *} - (( ${#n} > max_name_len )) && max_name_len=${#n} -done <<< "$SERVICES" +while IFS=$'\t' read -r tag _ name _; do + [ "$tag" != "SVC" ] && continue + (( ${#name} > max_name_len )) && max_name_len=${#name} +done < "$CONFIG_PATH" name_col=$(( max_name_len + 2 )) -# Probe each service and rotate its log. -while IFS= read -r line; do - [ -z "$line" ] && continue - name=${line%% *} - url=${line#* } +# Probe phase: gate per-service by the owning category's intervalSeconds. +while IFS=$'\t' read -r tag cat_idx name url; do + [ "$tag" != "SVC" ] && continue + interval=${cat_intervals[$cat_idx]} log="$state_dir/$name.log" + if [ -s "$log" ]; then + last_ts=$(tail -n 1 "$log" | awk '{print $1}') + age=$(( now - last_ts )) + (( age < interval )) && continue + fi + code=$(curl -fsS --max-time 10 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true) if [ -z "$code" ] || [ "$code" = "000" ]; then code="000" @@ -38,15 +56,11 @@ while IFS= read -r line; do awk -v cutoff="$retention_cutoff" '$1 >= cutoff' "$log" > "$log.tmp" mv "$log.tmp" "$log" -done <<< "$SERVICES" +done < "$CONFIG_PATH" -# Render. bucket_size and cell_count are parameters so we can add -# hour/minute granularity rows later without restructuring the awk. +# Render helpers. render_row() { - local log_file="$1" - local now_arg="$2" - local bucket_size="$3" - local cells="$4" + local log_file="$1" now_arg="$2" bucket_size="$3" cells="$4" if [ ! -s "$log_file" ]; then local pad @@ -89,18 +103,6 @@ render_row() { }' "$log_file" } -day_bar_cells=30 -day_bucket=$(( DISPLAY_DAYS * 86400 / day_bar_cells )) -days_per_cell=$(( DISPLAY_DAYS / day_bar_cells )) -if (( days_per_cell == 1 )); then - day_unit="1 day" -else - day_unit="$days_per_cell days" -fi - -hour_bar_cells="$DISPLAY_HOURS" -hour_bucket=3600 - scale_bar() { local cells="$1" left="$2" right="$3" local fill=$(( cells - ${#left} - ${#right} )) @@ -123,28 +125,71 @@ center() { fi } +human_interval() { + local s="$1" + if (( s < 60 )); then printf '%d seconds' "$s" + elif (( s == 60 )); then printf '1 minute' + elif (( s < 3600 )); then printf '%d minutes' "$(( s / 60 ))" + elif (( s == 3600 )); then printf '1 hour' + else printf '%d hours' "$(( s / 3600 ))" + fi +} + +day_bar_cells=30 +day_bucket=$(( DISPLAY_DAYS * 86400 / day_bar_cells )) +days_per_cell=$(( DISPLAY_DAYS / day_bar_cells )) +if (( days_per_cell == 1 )); then + day_unit="1 day" +else + day_unit="$days_per_cell days" +fi + +hour_bar_cells="$DISPLAY_HOURS" +hour_bucket=3600 + day_scale=$(scale_bar "$day_bar_cells" "<-${DISPLAY_DAYS}d" "now->") hour_scale=$(scale_bar "$hour_bar_cells" "<-${DISPLAY_HOURS}h" "now->") +day_label=$(center "$day_bar_cells" "1 cell = $day_unit") +hour_label=$(center "$hour_bar_cells" "1 cell = 1 hour") tmp="$OUTPUT_PATH.tmp" { - printf '# updated %s\n\n' "$(date -u -d "@$now" '+%Y-%m-%d %H:%M:%S UTC')" + printf '# updated %s\n' "$(date -u -d "@$now" '+%Y-%m-%d %H:%M:%S UTC')" - printf "%-${name_col}s%s %s\n" '' "$day_scale" "$hour_scale" - while IFS= read -r line; do - [ -z "$line" ] && continue - name=${line%% *} - url=${line#* } - log="$state_dir/$name.log" - read -r day_bar day_pct _ < <(render_row "$log" "$now" "$day_bucket" "$day_bar_cells") - read -r hour_bar _ state < <(render_row "$log" "$now" "$hour_bucket" "$hour_bar_cells") - printf "%-${name_col}s%s %s %s %s%% %s\n" \ - "$name" "$day_bar" "$hour_bar" "$state" "$day_pct" "$url" - done <<< "$SERVICES" + if [ -s "$INTRO_PATH" ]; then + intro_content=$(cat "$INTRO_PATH") + printf '\n%s\n' "$intro_content" + fi - printf "%-${name_col}s%s %s\n" '' \ - "$(center "$day_bar_cells" "1 cell = $day_unit")" \ - "$(center "$hour_bar_cells" "1 cell = 1 hour")" + n_cats=${#cat_intervals[@]} + for (( cat_idx = 0; cat_idx < n_cats; cat_idx++ )); do + desc=${cat_descriptions[$cat_idx]} + interval=${cat_intervals[$cat_idx]} + + printf '\n' + if [ -n "$desc" ]; then + printf '%s (probed every %s)\n\n' "$desc" "$(human_interval "$interval")" + fi + + printf "%-${name_col}s%s %s\n" '' "$day_scale" "$hour_scale" + + while IFS=$'\t' read -r tag c_idx name url; do + [ "$tag" != "SVC" ] && continue + [ "$c_idx" != "$cat_idx" ] && continue + log="$state_dir/$name.log" + read -r day_bar day_pct _ < <(render_row "$log" "$now" "$day_bucket" "$day_bar_cells") + read -r hour_bar _ state < <(render_row "$log" "$now" "$hour_bucket" "$hour_bar_cells") + if [ "${cat_hideurls[$cat_idx]}" = "1" ]; then + printf "%-${name_col}s%s %s %s %s%%\n" \ + "$name" "$day_bar" "$hour_bar" "$state" "$day_pct" + else + printf "%-${name_col}s%s %s %s %s%% %s\n" \ + "$name" "$day_bar" "$hour_bar" "$state" "$day_pct" "$url" + fi + done < "$CONFIG_PATH" + + printf "%-${name_col}s%s %s\n" '' "$day_label" "$hour_label" + done printf '\nlegend: = up - degraded _ down . no data\n' } > "$tmp"