update-fleet-stats.sh
1 #!/usr/bin/env bash 2 # update-fleet-stats.sh — Update fleet dashboard from beads-hub data 3 # Idempotent, safe to run every 5 minutes via cron. 4 # Requires: bd CLI at ~/.local/bin/bd, python3, git 5 set -euo pipefail 6 7 BD="${HOME}/.local/bin/bd" 8 BEADS_HUB="${HOME}/.openclaw/workspaces/beads-hub" 9 DOCS_DIR="$(cd "$(dirname "$0")/.." && pwd)" 10 FLEET_HTML="${DOCS_DIR}/fleet/index.html" 11 12 if [ ! -f "$BD" ]; then echo "ERROR: bd CLI not found at $BD" >&2; exit 1; fi 13 if [ ! -f "$FLEET_HTML" ]; then echo "ERROR: fleet/index.html not found" >&2; exit 1; fi 14 15 # Pull latest data 16 cd "$BEADS_HUB" && git pull -q 2>/dev/null || true 17 cd "$DOCS_DIR" && git pull -q 2>/dev/null || true 18 19 # Generate updated HTML via Python (reads bd JSON, patches index.html) 20 python3 << 'PYEOF' 21 import json, subprocess, sys, re 22 from datetime import datetime, timezone, timedelta 23 24 BD = sys.path # unused, just use the env 25 bd = "${HOME}/.local/bin/bd".replace("${HOME}", __import__("os").environ["HOME"]) 26 beads_hub = "${HOME}/.openclaw/workspaces/beads-hub".replace("${HOME}", __import__("os").environ["HOME"]) 27 fleet_html = "${DOCS_DIR}/fleet/index.html".replace("${DOCS_DIR}", __import__("os").environ.get("DOCS_DIR", "")) 28 29 import os 30 bd = os.path.expanduser("~/.local/bin/bd") 31 beads_hub = os.path.expanduser("~/.openclaw/workspaces/beads-hub") 32 docs_dir = os.path.dirname(os.path.dirname(os.path.abspath("${0}"))) 33 # Re-derive docs_dir properly 34 script_dir = os.path.dirname(os.path.abspath(__file__)) if '__file__' in dir() else None 35 # Just use env 36 fleet_html = os.environ.get("FLEET_HTML", "") 37 if not fleet_html: 38 # Fallback 39 fleet_html = os.path.expanduser("~/.openclaw/workspaces/docs/fleet/index.html") 40 41 def bd_list(status=None): 42 cmd = [bd, "list", "--json"] 43 if status: 44 cmd += ["--status", status] 45 r = subprocess.run(cmd, capture_output=True, text=True, cwd=beads_hub) 46 if r.returncode != 0: 47 print(f"bd error: {r.stderr}", file=sys.stderr) 48 return [] 49 return json.loads(r.stdout) 50 51 now = datetime.now(timezone.utc) 52 cutoff = now - timedelta(hours=24) 53 54 all_beads = bd_list() 55 closed_beads = bd_list("closed") 56 open_beads = [b for b in all_beads if b["status"] == "open"] 57 in_progress = [b for b in all_beads if b["status"] == "in_progress"] 58 59 # Recent closed (last 24h) 60 recent_closed = [b for b in closed_beads if b.get("closed_at") and b["closed_at"] > cutoff.isoformat()] 61 recent_closed.sort(key=lambda b: b.get("closed_at", ""), reverse=True) 62 63 # All-time counts 64 total_closed = len(closed_beads) 65 total_open = len(open_beads) 66 total_in_progress = len(in_progress) 67 total_all = total_closed + total_open + total_in_progress 68 69 # Stats mapping (aviation metaphors) 70 distance_nm = total_closed # each closed bead = 1 nautical mile 71 sorties = total_all # total beads = total sorties 72 fleet_ready = f"{min(5, 5)}/6" # could be dynamic later 73 74 # Agent mapping for flight log 75 def guess_agent(title): 76 t = title.lower() 77 if "research:" in t: return "ROMANOV" 78 if "container" in t or "ci/cd" in t or "deploy" in t or "fix" in t or "github" in t or "pipeline" in t: return "PLTOPS" 79 if "code" in t or "script" in t or "build" in t or "dao" in t or "smart contract" in t: return "CODEMONKEY" 80 if "url" in t or "brew" in t or "summar" in t: return "BREW" 81 if "linkedin" in t: return "LINKEDIN BRIEF" 82 return "TOWER" 83 84 # Build flight log HTML (last 10 recent closed) 85 log_entries = [] 86 for b in recent_closed[:10]: 87 closed_at = b.get("closed_at", "") 88 try: 89 t = datetime.fromisoformat(closed_at.replace("Z", "+00:00")) 90 time_str = t.strftime("%H:%MZ") 91 except: 92 time_str = "??:??Z" 93 agent = guess_agent(b["title"]) 94 bead_id = b["id"] 95 # Truncate title 96 title = b["title"][:60] 97 log_entries.append( 98 f' <div class="log-entry">\n' 99 f' <span class="log-time">{time_str}</span>\n' 100 f' <span class="log-callsign">{agent}</span>\n' 101 f' <span class="log-mission">{title} — {bead_id}</span>\n' 102 f' <span class="log-status"><span class="complete">✓ RTB</span></span>\n' 103 f' </div>' 104 ) 105 106 # If no recent closed, also show in_progress as ACTIVE 107 for b in in_progress[:max(0, 10 - len(log_entries))]: 108 agent = guess_agent(b["title"]) 109 title = b["title"][:60] 110 updated = b.get("updated_at", "") 111 try: 112 t = datetime.fromisoformat(updated.replace("Z", "+00:00")) 113 time_str = t.strftime("%H:%MZ") 114 except: 115 time_str = "??:??Z" 116 log_entries.append( 117 f' <div class="log-entry">\n' 118 f' <span class="log-time">{time_str}</span>\n' 119 f' <span class="log-callsign">{agent}</span>\n' 120 f' <span class="log-mission">{title} — {b["id"]}</span>\n' 121 f' <span class="log-status"><span class="active">● ACTIVE</span></span>\n' 122 f' </div>' 123 ) 124 125 flight_log_html = "\n".join(log_entries) 126 127 # Read HTML 128 with open(fleet_html, "r") as f: 129 html = f.read() 130 131 # Update timestamp in status line 132 now_str = now.strftime("%d %b %Y").upper() 133 html = re.sub( 134 r'ALL SYSTEMS NOMINAL — LAST 24H OPS SUMMARY — [^<]+', 135 f'ALL SYSTEMS NOMINAL — LAST 24H OPS SUMMARY — {now_str}', 136 html 137 ) 138 139 # Update footer timestamp 140 now_full = now.strftime("%d %b %Y %H:%MZ").upper() 141 html = re.sub( 142 r'UPDATED: [^·]+·', 143 f'UPDATED: {now_full} · ', 144 html 145 ) 146 147 # Update stats bar values (order: distance, fuel, passengers, flight hrs, sorties, fleet ready) 148 stat_values = [ 149 str(distance_nm), # Distance Flown (nm) 150 f"{total_all * 35:.1f}K", # Fuel Burned (rough token estimate) 151 str(total_open + total_in_progress), # Passengers (active beads) 152 f"{total_all * 0.6:.1f}", # Flight Hours 153 str(sorties), # Sorties Flown 154 fleet_ready, # Fleet Ready 155 ] 156 157 # Replace stat values by finding stat-value divs followed by stat-unit/stat-label 158 # We'll do targeted replacements based on stat-label text 159 label_value_map = { 160 "Distance Flown": str(distance_nm), 161 "Fuel Burned": f"{total_all * 35:.0f}K", 162 "Passengers": str(total_open + total_in_progress), 163 "Flight Hours": f"{total_all * 0.6:.1f}", 164 "Sorties Flown": str(sorties), 165 "Fleet Ready": fleet_ready, 166 } 167 168 for label, value in label_value_map.items(): 169 # Match the stat-item block containing this label and update its value 170 pattern = re.compile( 171 r'(<div class="stat-value">)[^<]*(</div>\s*<div class="stat-unit">[^<]*</div>\s*<div class="stat-label">' + re.escape(label) + r'</div>)', 172 re.DOTALL 173 ) 174 html = pattern.sub(rf'\g<1>{value}\2', html) 175 176 # Replace flight log section 177 flight_log_pattern = re.compile( 178 r'(<div class="flight-log">\n)(.*?)(</div>\s*\n\s*<div style="text-align: center)', 179 re.DOTALL 180 ) 181 html = flight_log_pattern.sub( 182 rf'\g<1>{flight_log_html}\n\3', 183 html 184 ) 185 186 with open(fleet_html, "w") as f: 187 f.write(html) 188 189 print(f"Updated fleet dashboard: {total_closed} closed, {total_open} open, {total_in_progress} in_progress, {len(recent_closed)} recent closed (24h)") 190 PYEOF 191 192 # Commit and push if changed 193 cd "$DOCS_DIR" 194 if git diff --quiet fleet/index.html 2>/dev/null; then 195 echo "No changes to fleet dashboard." 196 else 197 git add fleet/index.html 198 git commit -m "fleet: auto-update dashboard stats $(date -u '+%Y-%m-%dT%H:%MZ')" 199 git push 200 echo "Fleet dashboard updated and pushed." 201 fi