Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ strut <stack> <command> [--env <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 |
Expand All @@ -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
```

---
Expand Down
2 changes: 1 addition & 1 deletion completions/bash.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
2 changes: 1 addition & 1 deletion completions/fish.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion completions/zsh.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
285 changes: 285 additions & 0 deletions lib/cmd_dashboard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
#!/usr/bin/env bash
# ==================================================
# lib/cmd_dashboard.sh — Lightweight fleet status dashboard
# ==================================================
# Provides:
# cmd_dashboard [--port <N>] [--bind <addr>] [--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 <N> HTTP listen port (default: $_DASH_DEFAULT_PORT)"
echo " --bind <addr> 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 <N>] [--bind <addr>] [--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
Comment thread
gfargo-horizon-agent[bot] marked this conversation as resolved.
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
Comment thread
gfargo-horizon-agent[bot] marked this conversation as resolved.
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 <status-line> <content-type> <body>
_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 <key> <cmd> [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 <stack> 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 <text>
_dashboard_html_escape() {
local s="$1"
s="${s//&/\&amp;}"
s="${s//</\&lt;}"
s="${s//>/\&gt;}"
s="${s//\"/\&quot;}"
printf '%s' "$s"
}

# _dashboard_render_html <fleet-json> <stacks-json>
#
# Pure function — degrades to a raw <pre> 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 "<!DOCTYPE html>"
echo "<html><head><meta charset=\"utf-8\">"
echo "<meta http-equiv=\"refresh\" content=\"30\">"
echo "<title>strut fleet dashboard</title>"
echo "<style>"
echo "body{font-family:monospace;background:#111;color:#eee;padding:1.5rem}"
echo "table{border-collapse:collapse;margin-bottom:1.5rem}"
echo "th,td{padding:.3rem .8rem;text-align:left;border-bottom:1px solid #333}"
echo "th{color:#888;text-transform:uppercase;font-size:.8em}"
echo ".ok{color:#4caf50}.warn{color:#e0a030}.err{color:#e05050}"
echo "</style></head><body>"
echo "<h2>strut fleet dashboard</h2>"
echo "<p>Last refresh: $now_str</p>"

if command -v jq >/dev/null 2>&1 && printf '%s' "$fleet_json" | jq -e . >/dev/null 2>&1; then
echo "<table><tr><th>Host</th><th>Status</th><th>Branch</th><th>Behind</th><th>Dirty</th></tr>"
Comment thread
gfargo-horizon-agent[bot] marked this conversation as resolved.
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 '<tr><td>%s</td><td class="%s">%s</td><td>%s</td><td>%s</td><td>%s</td></tr>\n' \
"$(_dashboard_html_escape "$host")" "$glyph" "$symbol" \
"$(_dashboard_html_escape "$branch")" "$(_dashboard_html_escape "$behind")" "$(_dashboard_html_escape "$dirty")"
done
echo "</table>"
else
echo "<h3>Hosts</h3>"
echo "<pre>$(_dashboard_html_escape "$fleet_json")</pre>"
fi

echo "<h3>Stacks</h3>"
if command -v jq >/dev/null 2>&1 && printf '%s' "$stacks_json" | jq -e . >/dev/null 2>&1; then
echo "<table><tr><th>Stack</th><th>Health</th><th>Last Deploy</th><th>Backup Age</th></tr>"
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 '<tr><td>%s</td><td class="%s">%s</td><td>%s</td><td>%s</td></tr>\n' \
"$(_dashboard_html_escape "$name")" "$glyph" "$(_dashboard_html_escape "$health")" \
"$(_dashboard_html_escape "$last_deploy")" "$(_dashboard_html_escape "$backup_age")"
done
echo "</table>"
else
echo "<pre>$(_dashboard_html_escape "$stacks_json")</pre>"
fi

echo "</body></html>"
}
7 changes: 7 additions & 0 deletions strut
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ usage() {
echo " list List stacks"
echo " list plugins [--json] List discovered project plugins"
echo " status-all [--env <name>] [--json] Dashboard across all stacks"
echo " dashboard [--port <N>] [--bind <addr>] [--json] Read-only HTTP fleet status page"
echo " sync [<host>|--all] [--env <name>] [--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"
Expand Down Expand Up @@ -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 "$@"
Expand Down
Loading
Loading