79 lines
4.8 KiB
Bash
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
|