2b80a6defa
New ResidualBench tool (eventlog, flatten, audit, audit-contention) and docker/measure-ui-responsiveness.sh. All four residuals pass; results in docs/plans/2026-11-10-wp4-residual-scope-results.md. Findings filed as #122, #123 and #124 (fixed in the following commit).
187 lines
8.5 KiB
Bash
Executable File
187 lines
8.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# measure-ui-responsiveness.sh — Phase-8 WP-4 [xc-8] "UI workflows remain responsive" (ScadaBridge#49).
|
|
#
|
|
# Times Central UI page loads on the docker rig, first idle and then under load, with a real
|
|
# Chromium (host python Playwright). A "load" is a full navigation measured until the page is
|
|
# usable: the Blazor circuit is up and no loading indicator (spinner / "Loading…") remains.
|
|
#
|
|
# Load phase, all concurrent with the measuring browser:
|
|
# * OPERATORS extra browser sessions (default 6), each looping the same page list;
|
|
# * an inbound-API client calling SimpleAlarmStatus on MesAlarmDemo01 at API_RPS (default 20)
|
|
# — central method script + Route.To() to site-a + audit rows for every call.
|
|
#
|
|
# Prereqs: rig up; docker/seed-mes-alarm-demo.sh applied (SimpleAlarmStatus + MesAlarmDemo01);
|
|
# python3 playwright with chromium; the CLI built. A temporary API key `zztest-ui-load-<ts>`
|
|
# is created for the run and deleted afterwards.
|
|
#
|
|
# Usage: bash docker/measure-ui-responsiveness.sh [--operators N] [--api-rps N] [--repeats N]
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/.."
|
|
URL="${SCADABRIDGE_URL:-http://localhost:9000}"
|
|
OPERATORS=6; API_RPS=20; REPEATS=5
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--operators) OPERATORS="$2"; shift 2 ;;
|
|
--api-rps) API_RPS="$2"; shift 2 ;;
|
|
--repeats) REPEATS="$2"; shift 2 ;;
|
|
*) echo "unknown option $1" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
CLI=(dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI --no-build -- --url "$URL"
|
|
--username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}" --format json)
|
|
python3 -c 'import playwright.sync_api' 2>/dev/null || { echo "python3 playwright missing (pip install playwright && playwright install chromium)"; exit 1; }
|
|
|
|
KEY_NAME="zztest-ui-load-$(date +%s)"
|
|
# `api-key create` prints text, not JSON: "API key created. KeyId: <id>", then the token alone on a line
|
|
# (the "save this token" banner goes to stderr).
|
|
KEY_OUT=$("${CLI[@]}" security api-key create --name "$KEY_NAME" --methods SimpleAlarmStatus 2>/dev/null)
|
|
KEY_ID=$(echo "$KEY_OUT" | sed -n 's/.*KeyId: *\([A-Za-z0-9]*\).*/\1/p' | head -1)
|
|
TOKEN=$(echo "$KEY_OUT" | awk 'NF==1 && length($1) > 20 {print $1; exit}')
|
|
[ -n "$TOKEN" ] && [ -n "$KEY_ID" ] || { echo "could not parse api-key create output; delete key '$KEY_NAME' by hand" >&2; exit 1; }
|
|
cleanup() { "${CLI[@]}" security api-key delete --key-id "$KEY_ID" >/dev/null 2>&1 || echo "WARN: delete API key $KEY_NAME ($KEY_ID) by hand"; }
|
|
trap cleanup EXIT
|
|
echo "# temporary API key $KEY_NAME ($KEY_ID)"
|
|
|
|
# multiprocessing "spawn" re-imports the main module from a file, so the helper cannot run from stdin.
|
|
HELPER=$(mktemp "${TMPDIR:-/tmp}/sb-ui-responsiveness.XXXXXX.py")
|
|
trap 'rm -f "$HELPER"; cleanup' EXIT
|
|
cat >"$HELPER" <<'PY'
|
|
import json, multiprocessing as mp, os, statistics, threading, time, urllib.request
|
|
|
|
URL = os.environ["URL"]; TOKEN = os.environ["TOKEN"]
|
|
OPERATORS = int(os.environ["OPERATORS"]); API_RPS = int(os.environ["API_RPS"]); REPEATS = int(os.environ["REPEATS"])
|
|
|
|
PAGES = [
|
|
("Dashboard", "/"),
|
|
("Health", "/monitoring/health"),
|
|
("Topology", "/deployment/topology"),
|
|
("Instances", "/deployment/instances"),
|
|
("Deployments", "/deployment/deployments"),
|
|
("Templates", "/design/templates"),
|
|
("Audit Log", "/audit/log"),
|
|
("Config audit", "/audit/configuration"),
|
|
("Site Calls", "/site-calls/report"),
|
|
("Notifications", "/notifications/report"),
|
|
("Alarms", "/monitoring/alarms"),
|
|
("Event Logs", "/monitoring/event-logs"),
|
|
("Sites", "/admin/sites"),
|
|
("Flow Debugger", "/deployment/flow-debugger"),
|
|
]
|
|
|
|
READY_JS = """() => {
|
|
if (window.Blazor === undefined) return false;
|
|
const busy = document.querySelector('.spinner-border, .spinner-grow, .placeholder-glow, [aria-busy="true"]');
|
|
if (busy && busy.offsetParent !== null) return false;
|
|
const main = document.querySelector('main') || document.body;
|
|
return !/Loading\\u2026|Loading\\.\\.\\./.test(main.innerText);
|
|
}"""
|
|
|
|
def load(page, path):
|
|
"""Navigate and return ms until the page is usable: the Blazor circuit's websocket has
|
|
received a frame, and the page then shows no loading indicator for 200 ms straight
|
|
(an interactive re-render can briefly bring a spinner back after the prerendered HTML)."""
|
|
t = time.perf_counter()
|
|
with page.expect_websocket(lambda ws: "_blazor" in ws.url, timeout=60000) as ws_info:
|
|
page.goto(URL + path, wait_until="domcontentloaded", timeout=60000)
|
|
ws_info.value.wait_for_event("framereceived", timeout=60000)
|
|
deadline = t + 60
|
|
ready_since = None
|
|
while time.perf_counter() < deadline:
|
|
now = time.perf_counter()
|
|
if page.evaluate(READY_JS):
|
|
ready_since = ready_since or now
|
|
if now - ready_since >= 0.2:
|
|
return (ready_since - t) * 1000
|
|
else:
|
|
ready_since = None
|
|
time.sleep(0.03)
|
|
raise TimeoutError(f"{path} not ready within 60 s")
|
|
|
|
def measure(label, repeats):
|
|
from playwright.sync_api import sync_playwright
|
|
res = {}
|
|
with sync_playwright() as p:
|
|
b = p.chromium.launch()
|
|
pg = b.new_page()
|
|
load(pg, "/") # warm the browser + the circuit path once
|
|
for name, path in PAGES:
|
|
res[name] = [load(pg, path) for _ in range(repeats)]
|
|
b.close()
|
|
return res
|
|
|
|
def operator(stop, idx, counter):
|
|
from playwright.sync_api import sync_playwright
|
|
with sync_playwright() as p:
|
|
b = p.chromium.launch()
|
|
pg = b.new_page()
|
|
i = idx
|
|
while not stop.is_set():
|
|
name, path = PAGES[i % len(PAGES)]
|
|
try:
|
|
load(pg, path)
|
|
with counter.get_lock(): counter.value += 1
|
|
except Exception:
|
|
pass
|
|
i += 1
|
|
b.close()
|
|
|
|
def api_client(stop, stats):
|
|
body = json.dumps({"MachineCode": "MesAlarmDemo01"}).encode()
|
|
interval = 1.0 / API_RPS
|
|
def one():
|
|
req = urllib.request.Request(URL + "/api/SimpleAlarmStatus", data=body, method="POST",
|
|
headers={"Content-Type": "application/json", "X-API-Key": TOKEN})
|
|
t = time.perf_counter()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=60) as r:
|
|
r.read(); ok = r.status == 200
|
|
except Exception:
|
|
ok = False
|
|
stats.append(((time.perf_counter() - t) * 1000, ok))
|
|
nxt = time.perf_counter()
|
|
while not stop.is_set():
|
|
threading.Thread(target=one, daemon=True).start()
|
|
nxt += interval
|
|
time.sleep(max(0, nxt - time.perf_counter()))
|
|
|
|
def pct(xs, p):
|
|
xs = sorted(xs); return xs[min(len(xs) - 1, max(0, int(round(p / 100 * len(xs) + 0.5)) - 1))]
|
|
|
|
def report(idle, loaded):
|
|
print("| page | idle P50 ms | idle max ms | load P50 ms | load max ms |")
|
|
print("|---|---|---|---|---|")
|
|
for name, _ in PAGES:
|
|
a, b = idle[name], loaded[name]
|
|
print(f"| {name} | {statistics.median(a):.0f} | {max(a):.0f} | {statistics.median(b):.0f} | {max(b):.0f} |")
|
|
allA = [x for v in idle.values() for x in v]; allB = [x for v in loaded.values() for x in v]
|
|
print(f"| **all pages** | P50 {statistics.median(allA):.0f}, P95 {pct(allA, 95):.0f} | max {max(allA):.0f} | "
|
|
f"P50 {statistics.median(allB):.0f}, P95 {pct(allB, 95):.0f} | max {max(allB):.0f} |")
|
|
|
|
if __name__ == "__main__":
|
|
mp.set_start_method("spawn")
|
|
print(f"## idle ({REPEATS} loads per page)")
|
|
idle = measure("idle", REPEATS)
|
|
|
|
stop = mp.Event(); counter = mp.Value("i", 0)
|
|
ops = [mp.Process(target=operator, args=(stop, i * 3, counter)) for i in range(OPERATORS)]
|
|
for o in ops: o.start()
|
|
api_stats = []; tstop = threading.Event()
|
|
api = threading.Thread(target=api_client, args=(tstop, api_stats), daemon=True); api.start()
|
|
time.sleep(15) # let the background load reach steady state
|
|
t0 = time.perf_counter(); c0 = counter.value; n0 = len(api_stats)
|
|
loaded = measure("load", REPEATS)
|
|
secs = time.perf_counter() - t0
|
|
tstop.set(); stop.set()
|
|
for o in ops: o.join(timeout=60)
|
|
window = api_stats[n0:]
|
|
lat = [l for l, ok in window]; oks = sum(1 for _, ok in window if ok)
|
|
print(f"\n## under load: {OPERATORS} operator sessions ({(counter.value - c0) / secs:.1f} page loads/s), "
|
|
f"inbound API {len(window) / secs:.1f} calls/s offered, {oks}/{len(window)} HTTP 200, "
|
|
f"API latency P50 {statistics.median(lat):.0f} ms / P95 {pct(lat, 95):.0f} ms / max {max(lat):.0f} ms")
|
|
print()
|
|
report(idle, loaded)
|
|
PY
|
|
URL="$URL" TOKEN="$TOKEN" OPERATORS="$OPERATORS" API_RPS="$API_RPS" REPEATS="$REPEATS" python3 "$HELPER"
|