diff --git a/samples/eventhubs/python/README.md b/samples/eventhubs/python/README.md index dcc39c5..6d67beb 100644 --- a/samples/eventhubs/python/README.md +++ b/samples/eventhubs/python/README.md @@ -71,7 +71,8 @@ schema registry. `LOCALSTACK_AUTH_TOKEN` - [Docker](https://docs.docker.com/get-docker/) - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) and - [azlocal](https://pypi.org/project/azlocal/) (`pip install azlocal`) + [lstk](https://github.com/localstack/lstk) (`brew install localstack/tap/lstk` or + `npm install -g @localstack/lstk`) - Python 3.12+ with `src/producers/requirements.txt` installed - `jq` and `zip` - [Terraform](https://developer.hashicorp.com/terraform/downloads) or the Bicep CLI, for @@ -86,7 +87,7 @@ IMAGE_NAME=localstack/localstack-azure localstack start -d localstack wait -t 120 # Route the Azure CLI to the emulator -azlocal start-interception +lstk az start-interception cd samples/eventhubs/python pip install -r src/producers/requirements.txt diff --git a/samples/eventhubs/python/bicep/README.md b/samples/eventhubs/python/bicep/README.md index 278b6d2..a0fb0c6 100644 --- a/samples/eventhubs/python/bicep/README.md +++ b/samples/eventhubs/python/bicep/README.md @@ -5,7 +5,7 @@ application code with the Azure CLI. ## Prerequisites -- The LocalStack Azure emulator is running and `azlocal start-interception` has been run. +- The LocalStack Azure emulator is running and `lstk az start-interception` has been run. - The Bicep CLI (`az bicep install`) and `jq` are available. ## Usage diff --git a/samples/eventhubs/python/scripts/README.md b/samples/eventhubs/python/scripts/README.md index 0197288..93a27c0 100644 --- a/samples/eventhubs/python/scripts/README.md +++ b/samples/eventhubs/python/scripts/README.md @@ -1,7 +1,7 @@ # Azure CLI deployment Deploys the payment fraud detection pipeline with the Azure CLI, then verifies and -exercises it. Run `azlocal start-interception` first so every `az` call is routed to the +exercises it. Run `lstk az start-interception` first so every `az` call is routed to the LocalStack Azure emulator. ## Scripts diff --git a/samples/eventhubs/python/scripts/deploy.sh b/samples/eventhubs/python/scripts/deploy.sh index 34ca796..3c87a9a 100644 --- a/samples/eventhubs/python/scripts/deploy.sh +++ b/samples/eventhubs/python/scripts/deploy.sh @@ -17,7 +17,7 @@ # Web App (Python) operations dashboard # # Everything runs against the LocalStack Azure emulator; no real cloud resources -# are created. Run 'azlocal start-interception' first. +# are created. Run 'lstk az start-interception' first. # ============================================================================= # Variables diff --git a/samples/eventhubs/python/src/dashboard/app.py b/samples/eventhubs/python/src/dashboard/app.py index ce54344..df05d79 100644 --- a/samples/eventhubs/python/src/dashboard/app.py +++ b/samples/eventhubs/python/src/dashboard/app.py @@ -40,6 +40,9 @@ API_VERSION = "2023-07-01" # Upper bound on a single alerts read, so a page refresh can never hang the worker thread. ALERT_READ_TIMEOUT_SECONDS = 15 +# How many alerts the table below the headline shows. The page labels itself with this, so the +# label cannot drift from the list it describes - and the headline total is deliberately separate. +ALERT_PAGE_SIZE = 25 def eventhub_connection() -> str: @@ -59,12 +62,31 @@ def _namespace_host(conn_str: str) -> str: return "" -def partition_snapshot() -> list[dict]: - """Live per-partition head positions for the payments hub.""" +def hub_total(hub_name: str) -> int: + """Events ever enqueued in a hub, summed from per-partition runtime metadata. + + Counting by reading a hub does not scale and, worse, is bounded by whatever page size the + reader uses - which would silently turn a headline count into a page size. The runtime + metadata gives the real total without consuming anything. + + High-water mark, not live occupancy: `last_enqueued_sequence_number` is the last event + *enqueued*, so this errs high once retention expires events - it never falls. For events still + retained the figure would be `last_enqueued_sequence_number - beginning_sequence_number + 1`. + + A read failure is deliberately not caught. Returning 0 would be indistinguishable from an empty + hub, and since 0 is a real value the page's fallback would not fire: the headline would read 0 + above a list of alerts, then flash a delta the size of the whole hub on the next good refresh. + https://learn.microsoft.com/en-us/dotnet/api/azure.messaging.eventhubs.partitionproperties + """ + return sum(row["events"] for row in partition_snapshot(hub_name)) + + +def partition_snapshot(hub_name: str = EVENT_HUB_NAME) -> list[dict]: + """Live per-partition head positions for a hub.""" conn = eventhub_connection() if not conn: return [] - client = EventHubProducerClient.from_connection_string(conn, eventhub_name=EVENT_HUB_NAME) + client = EventHubProducerClient.from_connection_string(conn, eventhub_name=hub_name) rows = [] with client: for partition_id in client.get_partition_ids(): @@ -123,7 +145,7 @@ def checkpoint_snapshot(partitions: list[dict]) -> list[dict]: return sorted(rows, key=lambda row: row["partition"]) -def recent_alerts(limit: int = 25) -> list[dict]: +def recent_alerts(limit: int = ALERT_PAGE_SIZE) -> list[dict]: """Read the fraud-alerts hub from the beginning (the sample keeps volumes small).""" conn = eventhub_connection() if not conn: @@ -266,7 +288,11 @@ def schema_snapshot() -> list[dict]: @app.route("/") def index(): - return render_template("index.html", refreshed=datetime.now(UTC).strftime("%H:%M:%S")) + return render_template( + "index.html", + refreshed=datetime.now(UTC).strftime("%H:%M:%S"), + alert_page_size=ALERT_PAGE_SIZE, + ) @app.route("/api/overview") @@ -280,6 +306,8 @@ def overview(): "alert_hub": ALERT_HUB_NAME, "consumer_group": FRAUD_CONSUMER_GROUP, "total_events": sum(row["events"] for row in partitions), + # The real number of alerts, not the length of the page below it. + "total_alerts": hub_total(ALERT_HUB_NAME), "partitions": partitions, "checkpoints": checkpoint_snapshot(partitions), "alerts": recent_alerts(), diff --git a/samples/eventhubs/python/src/dashboard/static/style.css b/samples/eventhubs/python/src/dashboard/static/style.css index 79d29dc..26d8ae0 100644 --- a/samples/eventhubs/python/src/dashboard/static/style.css +++ b/samples/eventhubs/python/src/dashboard/static/style.css @@ -29,3 +29,31 @@ table thead th { color: #6c757d; font-weight: 600; } + +/* A refresh that changes nothing should look different from one that does: headline numbers + flash and show how much they moved, so cause and effect are visible while demonstrating. */ +.stat-changed { + animation: stat-flash 1.2s ease-out; +} + +@keyframes stat-flash { + 0% { background: #fff3cd; } + 100% { background: transparent; } +} + +.stat-delta { + display: inline-block; + margin-left: .35rem; + padding: .1rem .4rem; + border-radius: .35rem; + font-size: .75rem; + font-weight: 700; + color: #0f5132; + background: #d1e7dd; + opacity: 0; + transition: opacity .3s ease-in; +} + +.stat-delta.show { + opacity: 1; +} diff --git a/samples/eventhubs/python/src/dashboard/templates/index.html b/samples/eventhubs/python/src/dashboard/templates/index.html index a1bcd79..65ffd5c 100644 --- a/samples/eventhubs/python/src/dashboard/templates/index.html +++ b/samples/eventhubs/python/src/dashboard/templates/index.html @@ -20,21 +20,21 @@
| Account | Amount | Merchant | Reasons |
|---|