From c91dc88dc0d60805ae460100b624f9c5a5d342c1 Mon Sep 17 00:00:00 2001 From: "gfargo-horizon-agent[bot]" <294710345+gfargo-horizon-agent[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:17:11 +0000 Subject: [PATCH 1/2] feat(dashboard): add read-only fleet status HTTP dashboard Adds `strut dashboard [--port N] [--bind addr] [--json]`, a zero-dependency (socat) HTTP server exposing fleet + stack health as an auto-refreshing HTML page or JSON, so operators can check "is my fleet healthy" without SSHing into each host. Binds to 127.0.0.1 by default. Reuses `fleet status --json` and `status-all --json`, with per-stack drift aggregated fresh at /api/drift, and caches each 30s so rapid refreshes don't re-run SSH probes. --- README.md | 2 + completions/bash.sh | 2 +- completions/fish.fish | 2 +- completions/zsh.sh | 2 +- lib/cmd_dashboard.sh | 285 ++++++++++++++++++++++++++++++++++ strut | 7 + tests/test_cmd_dashboard.bats | 206 ++++++++++++++++++++++++ 7 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 lib/cmd_dashboard.sh create mode 100644 tests/test_cmd_dashboard.bats diff --git a/README.md b/README.md index f5fc337..04bc0c7 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ strut [--env ] [options] | `local` | Local development environment | | `debug` | Container debugging tools | | `fleet` | Multi-host status and sync | +| `dashboard` | Read-only HTTP fleet status page (HTML + JSON) | | `mcp` | Expose strut as an MCP server for AI agents | | `webhook` | Push-to-deploy via polling or an HTTP receiver | | `list` / `scaffold` / `init` | Stack and project management | @@ -161,6 +162,7 @@ strut my-app drift images --env prod # Check for stale strut my-app keys db:rotate postgres --env prod # Rotate credentials strut my-app domain api.example.com admin@example.com --env prod # SSL setup strut fleet status # Git sync state across all hosts +strut dashboard --port 8484 # Read-only HTTP fleet status page ``` --- diff --git a/completions/bash.sh b/completions/bash.sh index 6d6d59a..7a88f45 100644 --- a/completions/bash.sh +++ b/completions/bash.sh @@ -68,7 +68,7 @@ _strut_completions() { prev="${COMP_WORDS[COMP_CWORD-1]}" cword=$COMP_CWORD - local top_cmds="init list scaffold upgrade doctor status-all posture group monitoring audit audit:list audit:diff audit:generate migrate migrate:status notify sync fleet webhook mcp skills help completions --version -v --help -h" + local top_cmds="init list scaffold upgrade doctor status-all dashboard posture group monitoring audit audit:list audit:diff audit:generate migrate migrate:status notify sync fleet webhook mcp skills help completions --version -v --help -h" local per_stack_cmds="update release deploy rebuild ship stop diff lock health logs logs:download logs:rotate backup drift migrate restore db:pull db:push db:schema shell exec remote:init adopt provision ssh:keygen ci:init cert:renew cert:status init-secrets secrets status volumes prune local prod staging dev debug keys validate rollback history domain --help" local profiles="messaging ui full" diff --git a/completions/fish.fish b/completions/fish.fish index 9cef5b7..57d5d29 100644 --- a/completions/fish.fish +++ b/completions/fish.fish @@ -60,7 +60,7 @@ function __strut_tok end end -set -l top_cmds init list scaffold upgrade doctor status-all posture group monitoring audit audit:list audit:diff audit:generate migrate migrate:status notify sync fleet webhook mcp skills help completions +set -l top_cmds init list scaffold upgrade doctor status-all dashboard posture group monitoring audit audit:list audit:diff audit:generate migrate migrate:status notify sync fleet webhook mcp skills help completions set -l per_stack_cmds update release deploy rebuild ship stop diff lock health logs logs:download logs:rotate backup drift migrate restore db:pull db:push db:schema shell exec remote:init adopt provision ssh:keygen ci:init cert:renew cert:status init-secrets secrets status volumes prune local prod staging dev debug keys validate rollback history domain # Disable file completion by default; re-enable where relevant diff --git a/completions/zsh.sh b/completions/zsh.sh index cc747b9..a47822c 100644 --- a/completions/zsh.sh +++ b/completions/zsh.sh @@ -49,7 +49,7 @@ _strut_groups() { _strut() { local -a top_cmds per_stack_cmds profiles - top_cmds=(init list scaffold upgrade doctor status-all posture group monitoring audit audit:list audit:diff audit:generate migrate migrate:status notify sync fleet webhook mcp skills help completions --version --help) + top_cmds=(init list scaffold upgrade doctor status-all dashboard posture group monitoring audit audit:list audit:diff audit:generate migrate migrate:status notify sync fleet webhook mcp skills help completions --version --help) per_stack_cmds=(update release deploy rebuild ship stop diff lock health logs logs:download logs:rotate backup drift migrate restore db:pull db:push db:schema shell exec remote:init adopt provision ssh:keygen ci:init cert:renew cert:status init-secrets secrets status volumes prune local prod staging dev debug keys validate rollback history domain --help) profiles=(messaging ui full) diff --git a/lib/cmd_dashboard.sh b/lib/cmd_dashboard.sh new file mode 100644 index 0000000..851b3a4 --- /dev/null +++ b/lib/cmd_dashboard.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# ================================================== +# lib/cmd_dashboard.sh — Lightweight fleet status dashboard +# ================================================== +# Provides: +# cmd_dashboard [--port ] [--bind ] [--json] [--help] +# +# Starts a zero-dependency (socat) HTTP server exposing fleet + stack +# health as an auto-refreshing HTML page (default) or as JSON. Binds to +# 127.0.0.1 by default — no auth, meant to be fronted by a reverse proxy +# if exposed beyond localhost. +# +# Endpoints: +# GET / HTML dashboard (or JSON when started with --json) +# GET /api/fleet strut fleet status --json +# GET /api/stacks strut status-all --json +# GET /api/drift Per-stack drift status, aggregated into a JSON array + +set -euo pipefail + +_DASH_DEFAULT_PORT=8484 +_DASH_DEFAULT_BIND="127.0.0.1" +_DASH_DEFAULT_TTL=30 + +_usage_dashboard() { + echo "" + echo "Usage: strut dashboard [options]" + echo "" + echo "Starts a read-only HTTP server showing fleet + stack health." + echo "" + echo "Options:" + echo " --port HTTP listen port (default: $_DASH_DEFAULT_PORT)" + echo " --bind Bind address (default: $_DASH_DEFAULT_BIND — localhost only)" + echo " --json JSON-only mode: GET / returns JSON instead of HTML" + echo " --help Show this help" + echo "" + echo "Endpoints:" + echo " GET / HTML dashboard (or JSON in --json mode)" + echo " GET /api/fleet strut fleet status --json" + echo " GET /api/stacks strut status-all --json" + echo " GET /api/drift Per-stack drift status, aggregated" + echo "" + echo "Examples:" + echo " strut dashboard --port 8484" + echo " strut dashboard --port 8484 --json" + echo "" +} + +# cmd_dashboard [--port ] [--bind ] [--json] [--help] +cmd_dashboard() { + local port="$_DASH_DEFAULT_PORT" + local bind="$_DASH_DEFAULT_BIND" + local json_only=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --port) port="$2"; shift 2 ;; + --port=*) port="${1#*=}"; shift ;; + --bind) bind="$2"; shift 2 ;; + --bind=*) bind="${1#*=}"; shift ;; + --json) json_only=true; shift ;; + --help|-h) _usage_dashboard; return 0 ;; + *) _usage_dashboard; fail "Unknown dashboard option: $1"; return 1 ;; + esac + done + + command -v socat >/dev/null 2>&1 || fail "dashboard requires 'socat' (apt install socat / brew install socat)" + + local cache_dir + cache_dir=$(mktemp -d "${TMPDIR:-/tmp}/strut-dashboard.XXXXXX") + if declare -F strut_register_cleanup >/dev/null; then + strut_register_cleanup "rm -rf '$cache_dir'" + fi + + # Exported for the handler subprocess spawned per-connection by socat's + # `fork` — each connection gets a fresh shell, so state (cache dir, target + # binary/project) has to travel via the environment, not shell variables. + export _DASH_CACHE_DIR="$cache_dir" + export _DASH_CACHE_TTL="$_DASH_DEFAULT_TTL" + export _DASH_JSON_ONLY="$json_only" + export _DASH_STRUT_HOME="$STRUT_HOME" + export _DASH_STRUT_BIN="$STRUT_HOME/strut" + export _DASH_PROJECT_ROOT="$CLI_ROOT" + export STRUT_PROJECT="$CLI_ROOT" + + log "Dashboard on http://$bind:$port (Ctrl+C to stop)" + echo "" + + local handler_script + handler_script=$(_dashboard_handler_script) + + socat "TCP-LISTEN:$port,bind=$bind,fork,reuseaddr" "SYSTEM:$handler_script" +} + +# ── Per-connection HTTP handler (spawned by socat) ───────────────────────────── + +_dashboard_handler_script() { + cat << 'HANDLER' +read -r method path _version +while IFS= read -r line; do + line="${line%$'\r'}" + [ -z "$line" ] && break +done + +source "$_DASH_STRUT_HOME/lib/utils.sh" 2>/dev/null || true +source "$_DASH_STRUT_HOME/lib/cmd_dashboard.sh" 2>/dev/null || true + +case "$path" in + /api/fleet) + _dashboard_respond "200 OK" "application/json" "$(_dashboard_cache_fetch fleet _dashboard_fleet_json)" + ;; + /api/stacks) + _dashboard_respond "200 OK" "application/json" "$(_dashboard_cache_fetch stacks _dashboard_stacks_json)" + ;; + /api/drift) + _dashboard_respond "200 OK" "application/json" "$(_dashboard_cache_fetch drift _dashboard_drift_json)" + ;; + /) + fleet_body=$(_dashboard_cache_fetch fleet _dashboard_fleet_json) + stacks_body=$(_dashboard_cache_fetch stacks _dashboard_stacks_json) + if [ "${_DASH_JSON_ONLY:-false}" = "true" ]; then + _dashboard_respond "200 OK" "application/json" "{\"fleet\":$fleet_body,\"stacks\":$stacks_body}" + else + _dashboard_respond "200 OK" "text/html; charset=utf-8" "$(_dashboard_render_html "$fleet_body" "$stacks_body")" + fi + ;; + *) + _dashboard_respond "404 Not Found" "text/plain" "not found" + ;; +esac +HANDLER +} + +# _dashboard_respond +_dashboard_respond() { + local status="$1" ctype="$2" body="$3" + local len + len=$(printf '%s' "$body" | wc -c | tr -d ' ') + printf 'HTTP/1.1 %s\r\nContent-Type: %s\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' "$status" "$ctype" "$len" "$body" +} + +# ── Cached data producers ────────────────────────────────────────────────────── +# socat's `fork` spawns one handler process per connection, so an in-memory +# cache wouldn't survive between requests — results are cached to files +# keyed by name, keyed on mtime age against _DASH_CACHE_TTL. + +# _dashboard_cache_fetch [args...] +_dashboard_cache_fetch() { + local key="$1"; shift + local ttl="${_DASH_CACHE_TTL:-$_DASH_DEFAULT_TTL}" + local dir="${_DASH_CACHE_DIR:-}" + + if [ -z "$dir" ]; then + "$@" + return + fi + + local file="$dir/$key.json" + if [ -f "$file" ]; then + local mtime now age + mtime=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null || echo 0) + now=$(date +%s) + age=$((now - mtime)) + if [ "$age" -lt "$ttl" ]; then + cat "$file" + return 0 + fi + fi + + local out + out=$("$@" 2>/dev/null) || out="" + [ -n "$out" ] || out='{"error":"command failed"}' + printf '%s' "$out" > "$file.tmp" && mv "$file.tmp" "$file" + printf '%s' "$out" +} + +_dashboard_fleet_json() { + "$_DASH_STRUT_BIN" fleet status --json +} + +_dashboard_stacks_json() { + "$_DASH_STRUT_BIN" status-all --json +} + +# _dashboard_drift_json — no fleet-wide drift aggregator exists elsewhere, so +# this iterates every stack directory and shells out to `strut drift +# report --json` (using whatever env that invocation defaults to), wrapping +# the per-stack reports into a single JSON array. +_dashboard_drift_json() { + local root="${_DASH_PROJECT_ROOT:-$CLI_ROOT}" + local first=1 d name entry + + printf '[' + for d in "$root"/stacks/*/; do + [ -d "$d" ] || continue + name=$(basename "$d") + [ "$name" = "shared" ] && continue + + entry=$("$_DASH_STRUT_BIN" "$name" drift report --json 2>/dev/null) || entry="" + [ -n "$entry" ] || entry="{\"stack\":\"$name\",\"status\":\"error\"}" + + if [ "$first" -eq 1 ]; then first=0; else printf ','; fi + printf '%s' "$entry" + done + printf ']' +} + +# ── HTML rendering ────────────────────────────────────────────────────────────── + +# _dashboard_html_escape +_dashboard_html_escape() { + local s="$1" + s="${s//&/\&}" + s="${s///\>}" + s="${s//\"/\"}" + printf '%s' "$s" +} + +# _dashboard_render_html +# +# Pure function — degrades to a raw
 dump per section when jq is
+# unavailable or the JSON blob is invalid/empty.
+_dashboard_render_html() {
+  local fleet_json="$1"
+  local stacks_json="$2"
+  local now_str
+  printf -v now_str '%(%Y-%m-%dT%H:%M:%SZ)T' -1
+
+  echo ""
+  echo ""
+  echo ""
+  echo "strut fleet dashboard"
+  echo ""
+  echo "

strut fleet dashboard

" + echo "

Last refresh: $now_str

" + + if command -v jq >/dev/null 2>&1 && printf '%s' "$fleet_json" | jq -e . >/dev/null 2>&1; then + echo "" + printf '%s' "$fleet_json" | jq -r '.hosts[]? | [.host, (.status//"-"), (.branch//"-"), ((.behind//"-")|tostring), ((.dirty//"-")|tostring)] | @tsv' | + while IFS=$'\t' read -r host hstatus branch behind dirty; do + local glyph="ok" symbol="OK" + if [ "$hstatus" != "ok" ]; then + glyph="err"; symbol="ERR" + elif [ "$behind" != "0" ] || [ "$dirty" != "0" ]; then + glyph="warn"; symbol="WARN" + fi + printf '\n' \ + "$(_dashboard_html_escape "$host")" "$glyph" "$symbol" \ + "$(_dashboard_html_escape "$branch")" "$(_dashboard_html_escape "$behind")" "$(_dashboard_html_escape "$dirty")" + done + echo "
HostStatusBranchBehindDirty
%s%s%s%s%s
" + else + echo "

Hosts

" + echo "
$(_dashboard_html_escape "$fleet_json")
" + fi + + echo "

Stacks

" + if command -v jq >/dev/null 2>&1 && printf '%s' "$stacks_json" | jq -e . >/dev/null 2>&1; then + echo "" + printf '%s' "$stacks_json" | jq -r '.stacks[]? | [.name, (.health//"-"), (.last_deploy//"-"), (.backup_age//"-")] | @tsv' | + while IFS=$'\t' read -r name health last_deploy backup_age; do + local glyph="" + case "$health" in + healthy) glyph="ok" ;; + degraded) glyph="warn" ;; + down) glyph="err" ;; + esac + printf '\n' \ + "$(_dashboard_html_escape "$name")" "$glyph" "$(_dashboard_html_escape "$health")" \ + "$(_dashboard_html_escape "$last_deploy")" "$(_dashboard_html_escape "$backup_age")" + done + echo "
StackHealthLast DeployBackup Age
%s%s%s%s
" + else + echo "
$(_dashboard_html_escape "$stacks_json")
" + fi + + echo "" +} diff --git a/strut b/strut index 8c39586..ce05751 100755 --- a/strut +++ b/strut @@ -313,6 +313,7 @@ usage() { echo " list List stacks" echo " list plugins [--json] List discovered project plugins" echo " status-all [--env ] [--json] Dashboard across all stacks" + echo " dashboard [--port ] [--bind ] [--json] Read-only HTTP fleet status page" echo " sync [|--all] [--env ] [--dry-run] Bring host checkout in sync with origin" echo " fleet status|history [--json] [--limit N] Git sync state or deploy history across all hosts" echo " webhook poll|serve|install Push-to-deploy automation" @@ -569,6 +570,12 @@ case "${1:-}" in cmd_status_all "$@" exit $? ;; + dashboard) + shift + source "$LIB/cmd_dashboard.sh" + cmd_dashboard "$@" + exit $? + ;; sync) shift cmd_sync "$@" diff --git a/tests/test_cmd_dashboard.bats b/tests/test_cmd_dashboard.bats new file mode 100644 index 0000000..723db4c --- /dev/null +++ b/tests/test_cmd_dashboard.bats @@ -0,0 +1,206 @@ +#!/usr/bin/env bats +# ================================================== +# tests/test_cmd_dashboard.bats — Tests for strut dashboard +# ================================================== + +setup() { + source "$(dirname "$BATS_TEST_FILENAME")/test_helper/common.bash" + load_utils + source "$CLI_ROOT/lib/output.sh" + + export REAL_CLI_ROOT="$CLI_ROOT" + export CLI_ROOT="$TEST_TMP" + export STRUT_HOME="$REAL_CLI_ROOT" + mkdir -p "$CLI_ROOT/stacks" + + source "$REAL_CLI_ROOT/lib/cmd_dashboard.sh" +} + +teardown() { + common_teardown +} + +# ── Arg parsing / socat guard ──────────────────────────────────────────────── + +_stub_socat_recorder() { + # shellcheck disable=SC2317 + socat() { printf 'socat_args:%s\n' "$*" | head -c 400; return 0; } + export -f socat +} + +@test "cmd_dashboard: default port and bind" { + _stub_socat_recorder + run cmd_dashboard + [ "$status" -eq 0 ] + [[ "$output" == *"TCP-LISTEN:8484,bind=127.0.0.1,fork,reuseaddr"* ]] +} + +@test "cmd_dashboard: --port and --bind override defaults" { + _stub_socat_recorder + run cmd_dashboard --port 9999 --bind 0.0.0.0 + [ "$status" -eq 0 ] + [[ "$output" == *"TCP-LISTEN:9999,bind=0.0.0.0,fork,reuseaddr"* ]] +} + +@test "cmd_dashboard: --json exports JSON-only mode for the handler subprocess" { + # shellcheck disable=SC2317 + socat() { printf 'json_only=%s' "$_DASH_JSON_ONLY"; return 0; } + export -f socat + run cmd_dashboard --port 9999 --json + [ "$status" -eq 0 ] + [[ "$output" == *"json_only=true"* ]] +} + +@test "cmd_dashboard: unknown flag fails" { + run cmd_dashboard --bogus + [ "$status" -ne 0 ] + [[ "$output" == *"Unknown dashboard option"* ]] +} + +@test "cmd_dashboard: --help prints usage without failing" { + run cmd_dashboard --help + [ "$status" -eq 0 ] + [[ "$output" == *"Usage: strut dashboard"* ]] +} + +@test "cmd_dashboard: missing socat fails with install hint" { + PATH="" run cmd_dashboard --port 9999 + [ "$status" -ne 0 ] + [[ "$output" == *"socat"* ]] +} + +# ── _dashboard_cache_fetch ─────────────────────────────────────────────────── + +@test "_dashboard_cache_fetch: runs the command and caches the result" { + export _DASH_CACHE_DIR="$BATS_TEST_TMPDIR" + export _DASH_CACHE_TTL=30 + _dash_counter() { echo '{"n":1}'; } + export -f _dash_counter + + run _dashboard_cache_fetch mykey _dash_counter + [ "$output" = '{"n":1}' ] + [ -f "$BATS_TEST_TMPDIR/mykey.json" ] +} + +@test "_dashboard_cache_fetch: serves cached value within TTL without re-running" { + export _DASH_CACHE_DIR="$BATS_TEST_TMPDIR" + export _DASH_CACHE_TTL=300 + echo '{"cached":true}' > "$BATS_TEST_TMPDIR/cachedkey.json" + _dash_should_not_run() { echo '{"cached":false}'; } + export -f _dash_should_not_run + + run _dashboard_cache_fetch cachedkey _dash_should_not_run + [ "$output" = '{"cached":true}' ] +} + +@test "_dashboard_cache_fetch: falls back to error JSON on command failure" { + export _DASH_CACHE_DIR="$BATS_TEST_TMPDIR" + export _DASH_CACHE_TTL=30 + _dash_failing() { return 1; } + export -f _dash_failing + + run _dashboard_cache_fetch failkey _dash_failing + [[ "$output" == *'"error"'* ]] +} + +# ── _dashboard_respond ─────────────────────────────────────────────────────── + +@test "_dashboard_respond: emits status line, content-type, and accurate length" { + run _dashboard_respond "200 OK" "application/json" '{"a":1}' + [[ "$output" == *"HTTP/1.1 200 OK"* ]] + [[ "$output" == *"Content-Type: application/json"* ]] + [[ "$output" == *"Content-Length: 7"* ]] + [[ "$output" == *'{"a":1}'* ]] +} + +# ── _dashboard_drift_json ──────────────────────────────────────────────────── + +@test "_dashboard_drift_json: aggregates per-stack drift reports into a JSON array" { + mkdir -p "$CLI_ROOT/stacks/api" "$CLI_ROOT/stacks/worker" + _dash_stub_strut() { + case "$1" in + api) echo '{"stack":"api","status":"no_drift"}' ;; + worker) echo '{"stack":"worker","status":"drift_detected"}' ;; + esac + } + export -f _dash_stub_strut + export _DASH_STRUT_BIN=_dash_stub_strut + export _DASH_PROJECT_ROOT="$CLI_ROOT" + + run _dashboard_drift_json + [ "$status" -eq 0 ] + [[ "$output" == \[*\] ]] + [[ "$output" == *'"stack":"api","status":"no_drift"'* ]] + [[ "$output" == *'"stack":"worker","status":"drift_detected"'* ]] +} + +@test "_dashboard_drift_json: skips the 'shared' directory" { + mkdir -p "$CLI_ROOT/stacks/api" "$CLI_ROOT/stacks/shared" + _dash_stub_strut() { echo "{\"stack\":\"$1\"}"; } + export -f _dash_stub_strut + export _DASH_STRUT_BIN=_dash_stub_strut + export _DASH_PROJECT_ROOT="$CLI_ROOT" + + run _dashboard_drift_json + [[ "$output" != *'"stack":"shared"'* ]] + [[ "$output" == *'"stack":"api"'* ]] +} + +@test "_dashboard_drift_json: a failing stack yields an error entry, loop continues" { + mkdir -p "$CLI_ROOT/stacks/api" "$CLI_ROOT/stacks/broken" + _dash_stub_strut() { + case "$1" in + broken) return 1 ;; + *) echo "{\"stack\":\"$1\",\"status\":\"no_drift\"}" ;; + esac + } + export -f _dash_stub_strut + export _DASH_STRUT_BIN=_dash_stub_strut + export _DASH_PROJECT_ROOT="$CLI_ROOT" + + run _dashboard_drift_json + [[ "$output" == *'"stack":"broken","status":"error"'* ]] + [[ "$output" == *'"stack":"api","status":"no_drift"'* ]] +} + +@test "_dashboard_drift_json: no stacks yields an empty array" { + export _DASH_STRUT_BIN=true + export _DASH_PROJECT_ROOT="$CLI_ROOT" + + run _dashboard_drift_json + [ "$output" = "[]" ] +} + +# ── _dashboard_render_html ─────────────────────────────────────────────────── + +_fleet_fixture='{"hosts":[{"host":"compass","status":"ok","branch":"main","behind":"0","ahead":"0","dirty":0,"head_sha":"abc1234"},{"host":"harbor","status":"ok","branch":"main","behind":"3","ahead":"0","dirty":1,"head_sha":"def5678"}],"branch":"main"}' +_stacks_fixture='{"timestamp":"2026-07-12T00:00:00Z","stacks":[{"name":"my-app","health":"healthy","last_deploy":"2h ago","backup_age":"4h ago"}],"summary":{"total":1,"healthy":1,"degraded":0,"down":0,"unknown":0}}' + +@test "_dashboard_render_html: includes a 30s meta-refresh tag" { + run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture" + [[ "$output" == *''* ]] +} + +@test "_dashboard_render_html: renders host rows from fleet JSON" { + run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture" + [[ "$output" == *"compass"* ]] + [[ "$output" == *"harbor"* ]] +} + +@test "_dashboard_render_html: renders stack rows from stacks JSON" { + run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture" + [[ "$output" == *"my-app"* ]] + [[ "$output" == *"healthy"* ]] +} + +@test "_dashboard_render_html: degrades to a
 dump when jq is unavailable" {
+  PATH="" run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture"
+  [[ "$output" == *"
"* ]]
+}
+
+@test "_dashboard_render_html: escapes HTML-significant characters" {
+  local malicious='{"hosts":[{"host":"","status":"ok"}],"branch":"main"}'
+  run _dashboard_render_html "$malicious" "$_stacks_fixture"
+  [[ "$output" != *""* ]]
+  [[ "$output" == *"<script>"* ]]
+}

From fff814af6bc0b6012b5b4715be3b9361be932b87 Mon Sep 17 00:00:00 2001
From: "gfargo-horizon-agent[bot]"
 <294710345+gfargo-horizon-agent[bot]@users.noreply.github.com>
Date: Sun, 12 Jul 2026 14:51:09 +0000
Subject: [PATCH 2/2] fix(dashboard): clean up cache dir on signal, run handler
 under bash

Addresses PR review feedback on #410:
- Register INT/TERM traps that remove the cache dir before exiting,
  since socat blocks in the foreground and the EXIT-only cleanup
  chain never fires on Ctrl+C.
- Run the per-connection handler via socat's EXEC (fork+exec) instead
  of SYSTEM (fork+`/bin/sh -c`), writing it to an executable file with
  a bash shebang so it isn't at the mercy of the host's /bin/sh (dash
  on Debian/Ubuntu silently breaks the handler's source/pipefail use).
- Show actual cache freshness ("12s ago") in the HTML "Last refresh"
  line instead of the page-render timestamp, and document why the
  host table uses a Branch column rather than the mock's Last Deploy
  (fleet status has no per-host deploy timestamp).
---
 lib/cmd_dashboard.sh          | 66 ++++++++++++++++++++++++++++------
 tests/test_cmd_dashboard.bats | 67 +++++++++++++++++++++++++++++++++++
 2 files changed, 123 insertions(+), 10 deletions(-)

diff --git a/lib/cmd_dashboard.sh b/lib/cmd_dashboard.sh
index 851b3a4..f7f2359 100644
--- a/lib/cmd_dashboard.sh
+++ b/lib/cmd_dashboard.sh
@@ -71,6 +71,13 @@ cmd_dashboard() {
   if declare -F strut_register_cleanup >/dev/null; then
     strut_register_cleanup "rm -rf '$cache_dir'"
   fi
+  # socat blocks in the foreground until Ctrl+C; the entrypoint's EXIT trap
+  # (STRUT_CLEANUPS chain, registered above) does not fire on SIGINT/SIGTERM
+  # by itself, so this needs its own handlers — same pattern as cmd_group.sh.
+  # shellcheck disable=SC2064 # cache_dir is fixed at registration time, not signal time
+  trap "rm -rf '$cache_dir'; exit 130" INT
+  # shellcheck disable=SC2064
+  trap "rm -rf '$cache_dir'; exit 143" TERM
 
   # Exported for the handler subprocess spawned per-connection by socat's
   # `fork` — each connection gets a fresh shell, so state (cache dir, target
@@ -86,10 +93,19 @@ cmd_dashboard() {
   log "Dashboard on http://$bind:$port (Ctrl+C to stop)"
   echo ""
 
-  local handler_script
-  handler_script=$(_dashboard_handler_script)
-
-  socat "TCP-LISTEN:$port,bind=$bind,fork,reuseaddr" "SYSTEM:$handler_script"
+  # Written to a file and run via EXEC (fork+exec), not SYSTEM (fork+`/bin/sh
+  # -c`): SYSTEM's shell is whatever /bin/sh is on the host (dash on
+  # Debian/Ubuntu), which chokes on this script's `source` and `pipefail`
+  # usage. EXEC execs the file directly, so its own `#!/usr/bin/env bash`
+  # shebang decides the interpreter regardless of the system's /bin/sh.
+  local handler_file="$cache_dir/handler.sh"
+  {
+    echo '#!/usr/bin/env bash'
+    _dashboard_handler_script
+  } > "$handler_file"
+  chmod +x "$handler_file"
+
+  socat "TCP-LISTEN:$port,bind=$bind,fork,reuseaddr" "EXEC:$handler_file"
 }
 
 # ── Per-connection HTTP handler (spawned by socat) ─────────────────────────────
@@ -121,7 +137,7 @@ case "$path" in
     if [ "${_DASH_JSON_ONLY:-false}" = "true" ]; then
       _dashboard_respond "200 OK" "application/json" "{\"fleet\":$fleet_body,\"stacks\":$stacks_body}"
     else
-      _dashboard_respond "200 OK" "text/html; charset=utf-8" "$(_dashboard_render_html "$fleet_body" "$stacks_body")"
+      _dashboard_respond "200 OK" "text/html; charset=utf-8" "$(_dashboard_render_html "$fleet_body" "$stacks_body" "$(_dashboard_cache_age fleet)")"
     fi
     ;;
   *)
@@ -144,6 +160,11 @@ _dashboard_respond() {
 # cache wouldn't survive between requests — results are cached to files
 # keyed by name, keyed on mtime age against _DASH_CACHE_TTL.
 
+# _dashboard_file_mtime  — last-modified time as a unix timestamp, or 0
+_dashboard_file_mtime() {
+  stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0
+}
+
 # _dashboard_cache_fetch   [args...]
 _dashboard_cache_fetch() {
   local key="$1"; shift
@@ -158,7 +179,7 @@ _dashboard_cache_fetch() {
   local file="$dir/$key.json"
   if [ -f "$file" ]; then
     local mtime now age
-    mtime=$(stat -c %Y "$file" 2>/dev/null || stat -f %m "$file" 2>/dev/null || echo 0)
+    mtime=$(_dashboard_file_mtime "$file")
     now=$(date +%s)
     age=$((now - mtime))
     if [ "$age" -lt "$ttl" ]; then
@@ -174,6 +195,22 @@ _dashboard_cache_fetch() {
   printf '%s' "$out"
 }
 
+# _dashboard_cache_age  — seconds since .json was last (re)written,
+# for display only; empty if the cache dir/file isn't available.
+_dashboard_cache_age() {
+  local key="$1"
+  local dir="${_DASH_CACHE_DIR:-}"
+  [ -n "$dir" ] || return 0
+
+  local file="$dir/$key.json"
+  [ -f "$file" ] || return 0
+
+  local mtime now
+  mtime=$(_dashboard_file_mtime "$file")
+  now=$(date +%s)
+  printf '%s' "$((now - mtime))"
+}
+
 _dashboard_fleet_json() {
   "$_DASH_STRUT_BIN" fleet status --json
 }
@@ -217,15 +254,20 @@ _dashboard_html_escape() {
   printf '%s' "$s"
 }
 
-# _dashboard_render_html  
+# _dashboard_render_html   [cache-age-seconds]
 #
 # Pure function — degrades to a raw 
 dump per section when jq is
 # unavailable or the JSON blob is invalid/empty.
 _dashboard_render_html() {
   local fleet_json="$1"
   local stacks_json="$2"
-  local now_str
-  printf -v now_str '%(%Y-%m-%dT%H:%M:%SZ)T' -1
+  local age="${3:-}"
+  local refresh_label
+  if [[ "$age" =~ ^[0-9]+$ ]]; then
+    refresh_label="${age}s ago"
+  else
+    printf -v refresh_label '%(%Y-%m-%dT%H:%M:%SZ)T' -1
+  fi
 
   echo ""
   echo ""
@@ -239,8 +281,12 @@ _dashboard_render_html() {
   echo ".ok{color:#4caf50}.warn{color:#e0a030}.err{color:#e05050}"
   echo ""
   echo "

strut fleet dashboard

" - echo "

Last refresh: $now_str

" + echo "

Last refresh: $refresh_label

" + # The issue mock's host table has a LAST DEPLOY column, but `fleet status + # --json` (lib/cmd_fleet.sh) has no per-host deploy timestamp to show there + # — only branch/behind/ahead/dirty/head_sha. Branch is shown instead; + # last-deploy is surfaced per-stack in the table below, from status-all. if command -v jq >/dev/null 2>&1 && printf '%s' "$fleet_json" | jq -e . >/dev/null 2>&1; then echo "" printf '%s' "$fleet_json" | jq -r '.hosts[]? | [.host, (.status//"-"), (.branch//"-"), ((.behind//"-")|tostring), ((.dirty//"-")|tostring)] | @tsv' | diff --git a/tests/test_cmd_dashboard.bats b/tests/test_cmd_dashboard.bats index 723db4c..59e33e1 100644 --- a/tests/test_cmd_dashboard.bats +++ b/tests/test_cmd_dashboard.bats @@ -69,6 +69,46 @@ _stub_socat_recorder() { [[ "$output" == *"socat"* ]] } +@test "cmd_dashboard: uses EXEC (not SYSTEM) so the handler always runs under bash" { + _stub_socat_recorder + run cmd_dashboard --port 9999 + [ "$status" -eq 0 ] + [[ "$output" == *"EXEC:"* ]] + [[ "$output" != *"SYSTEM:"* ]] +} + +@test "cmd_dashboard: writes an executable handler file with a bash shebang" { + local captured_path="" + # shellcheck disable=SC2317 + socat() { + for arg in "$@"; do + [[ "$arg" == EXEC:* ]] && captured_path="${arg#EXEC:}" + done + printf '%s' "$captured_path" > "$BATS_TEST_TMPDIR/handler_path" + return 0 + } + export -f socat + run cmd_dashboard --port 9999 + [ "$status" -eq 0 ] + local handler_path + handler_path=$(cat "$BATS_TEST_TMPDIR/handler_path") + [ -x "$handler_path" ] + [ "$(head -n1 "$handler_path")" = "#!/usr/bin/env bash" ] +} + +@test "cmd_dashboard: registers INT and TERM traps that remove the cache dir" { + _stub_socat_recorder + # `run` captures output via a subshell, which would swallow the trap along + # with it — call directly (output redirected) so the trap lands in this shell. + cmd_dashboard --port 9999 >/dev/null + local int_trap term_trap + int_trap=$(trap -p INT) + term_trap=$(trap -p TERM) + trap - INT TERM + [[ "$int_trap" == *"rm -rf"*"strut-dashboard."* ]] + [[ "$term_trap" == *"rm -rf"*"strut-dashboard."* ]] +} + # ── _dashboard_cache_fetch ─────────────────────────────────────────────────── @test "_dashboard_cache_fetch: runs the command and caches the result" { @@ -176,6 +216,22 @@ _stub_socat_recorder() { _fleet_fixture='{"hosts":[{"host":"compass","status":"ok","branch":"main","behind":"0","ahead":"0","dirty":0,"head_sha":"abc1234"},{"host":"harbor","status":"ok","branch":"main","behind":"3","ahead":"0","dirty":1,"head_sha":"def5678"}],"branch":"main"}' _stacks_fixture='{"timestamp":"2026-07-12T00:00:00Z","stacks":[{"name":"my-app","health":"healthy","last_deploy":"2h ago","backup_age":"4h ago"}],"summary":{"total":1,"healthy":1,"degraded":0,"down":0,"unknown":0}}' +@test "_dashboard_cache_age: reports seconds since the cache file was written" { + export _DASH_CACHE_DIR="$BATS_TEST_TMPDIR" + echo '{}' > "$BATS_TEST_TMPDIR/agekey.json" + touch -d "@$(($(date +%s) - 15))" "$BATS_TEST_TMPDIR/agekey.json" + + run _dashboard_cache_age agekey + [[ "$output" =~ ^[0-9]+$ ]] + [ "$output" -ge 14 ] +} + +@test "_dashboard_cache_age: empty when the cache file doesn't exist" { + export _DASH_CACHE_DIR="$BATS_TEST_TMPDIR" + run _dashboard_cache_age missingkey + [ -z "$output" ] +} + @test "_dashboard_render_html: includes a 30s meta-refresh tag" { run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture" [[ "$output" == *''* ]] @@ -198,6 +254,17 @@ _stacks_fixture='{"timestamp":"2026-07-12T00:00:00Z","stacks":[{"name":"my-app", [[ "$output" == *"
"* ]]
 }
 
+@test "_dashboard_render_html: shows relative cache freshness when an age is given" {
+  run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture" 12
+  [[ "$output" == *"Last refresh: 12s ago"* ]]
+}
+
+@test "_dashboard_render_html: falls back to an absolute timestamp without an age" {
+  run _dashboard_render_html "$_fleet_fixture" "$_stacks_fixture"
+  [[ "$output" == *"Last refresh: "*"Z"* ]]
+  [[ "$output" != *"Last refresh: "*"s ago"* ]]
+}
+
 @test "_dashboard_render_html: escapes HTML-significant characters" {
   local malicious='{"hosts":[{"host":"","status":"ok"}],"branch":"main"}'
   run _dashboard_render_html "$malicious" "$_stacks_fixture"
HostStatusBranchBehindDirty