Files
claudetools/.claude/skills/unifi-wifi/scripts/live-stats.sh
Howard Enos c99615df7e sync: auto-sync from HOWARD-HOME at 2026-06-15 20:40:48
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-15 20:40:48
2026-06-15 20:40:59 -07:00

79 lines
4.8 KiB
Bash

#!/usr/bin/env bash
# live-stats.sh — Plane-2 live RF/airtime from the UOS Network API (classic session API).
# Gives CURRENT per-AP per-radio cu_total / cu_self / num_sta / retry% and device-level
# satisfaction, plus the worst-clients view (signal / retry% / satisfaction_reason) — for
# before/after validation of changes.
# NOTE: satisfaction is populated at the DEVICE level (per-radio is -1 on this controller),
# and tx_retries is a cumulative counter so we report radio_table_stats.tx_retries_pct (a rate).
# TODO: AP-to-AP RF-neighbor table (for confident radio DISABLES) — not a single API field;
# build it from the `rogue` collection by matching our own APs' vap_table BSSIDs. Not done yet.
#
# AUTH (provision once): the classic API needs a controller admin session. Create a dedicated
# READ-ONLY admin in the UniFi UI (OS Settings -> Admins -> add a Viewer), then vault it:
# bash .claude/skills/vault/scripts/vault-helper.sh new infrastructure/uos-server-network-api \
# --kind generic --name "UOS Network API (read-only admin)" --tag unifi \
# --set username=<viewer> --set password=<pw>
# (A UniFi Network Integration API key also works for /proxy/network/integration/v1, but the rich
# radio_table_stats + RF-neighbor data live in the classic /proxy/network/api path used here.)
#
# Usage: bash .claude/skills/unifi-wifi/scripts/live-stats.sh <site-name|short> [--clients]
set -euo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
VAULT="$REPO/.claude/scripts/vault.sh"
HOST="${UOS_HOST:-172.16.3.29}"; PORT="${UOS_HTTPS_PORT:-11443}"
SITEARG="${1:?usage: live-stats.sh <site-name|short> [--clients]}"; WANT_CLIENTS="${2:-}"
# Prefer the read-only admin; fall back to the read-write one (a single admin covers both).
U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api credentials.username 2>/dev/null || true)"
P="$(bash "$VAULT" get-field infrastructure/uos-server-network-api credentials.password 2>/dev/null || true)"
if [ -z "$U" ] || [ -z "$P" ]; then
U="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.username 2>/dev/null || true)"
P="$(bash "$VAULT" get-field infrastructure/uos-server-network-api-rw credentials.password 2>/dev/null || true)"
fi
if [ -z "$U" ] || [ -z "$P" ]; then
echo "[BLOCKED] No controller credential vaulted yet. Provision a read-only admin and vault it:"
sed -n '8,14p' "$0"
exit 2
fi
CJ="$(mktemp)"; trap 'rm -f "$CJ"' EXIT
base="https://$HOST:$PORT"
# UniFi OS login -> session cookie
code=$(curl -sk -c "$CJ" -o /dev/null -w '%{http_code}' -X POST "$base/api/auth/login" \
-H 'Content-Type: application/json' --data-binary "$(python -c 'import json,sys;print(json.dumps({"username":sys.argv[1],"password":sys.argv[2]}))' "$U" "$P")")
[ "$code" = "200" ] || { echo "[ERROR] login HTTP $code"; exit 1; }
# resolve site short name (classic API keys on the short name, not the _id or display name)
SHORT="$(curl -sk -b "$CJ" "$base/proxy/network/api/self/sites" | python -c "
import sys,json
d=json.load(sys.stdin).get('data',[]); q='''$SITEARG'''.lower()
for s in d:
if s.get('_id')=='''$SITEARG''' or s.get('name')=='''$SITEARG''' or q in (s.get('desc','').lower()): print(s.get('name')); break
" 2>/dev/null)"
[ -n "$SHORT" ] || SHORT="$SITEARG"
[ -n "$SHORT" ] || { echo "[ERROR] could not resolve site '$SITEARG'"; exit 1; }
echo "[INFO] site short=$SHORT"
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/device" | python -c "
import sys,json
aps=[d for d in json.load(sys.stdin).get('data',[]) if d.get('type')=='uap']
print('# APs reporting:',len(aps))
for d in sorted(aps,key=lambda a:str(a.get('name'))):
# device-level satisfaction is the populated one (per-radio satisfaction is -1 on this controller)
print('AP',d.get('name'),'clients=',d.get('num_sta'),'satisfaction=',d.get('satisfaction'))
for r in d.get('radio_table_stats',[]):
print(' ',r.get('radio'),'ch',r.get('channel'),'cu_total',r.get('cu_total'),'cu_self_rx',r.get('cu_self_rx'),'cu_self_tx',r.get('cu_self_tx'),'num_sta',r.get('num_sta'),'retry%',r.get('tx_retries_pct'))
" 2>&1
if [ "$WANT_CLIENTS" = "--clients" ]; then
echo "=== worst wireless clients by satisfaction (signal / retry% / why) ==="
curl -sk -b "$CJ" "$base/proxy/network/api/s/$SHORT/stat/sta" | python -c "
import sys,json
cs=[c for c in json.load(sys.stdin).get('data',[]) if not c.get('is_wired')]
cs.sort(key=lambda c:(c.get('satisfaction') if isinstance(c.get('satisfaction'),(int,float)) else 999))
print('# wireless clients:',len(cs),' (worst 40 by satisfaction)')
for c in cs[:40]:
print(' ',(c.get('hostname') or c.get('mac')),'sat',c.get('satisfaction'),'signal',c.get('signal'),'noise',c.get('noise'),'retry%',c.get('wifi_tx_retries_percentage'),'band',c.get('radio'),'ch',c.get('channel'),'why',c.get('satisfaction_reason'))
" 2>&1
fi