From bf5ce06cd358aa84b9eb9702f35933f4308e47e1 Mon Sep 17 00:00:00 2001
From: carsonlayden
Date: Thu, 11 Jun 2026 00:53:08 -0700
Subject: [PATCH] Add live fleet monitoring + Azure Monitor (App Insights)
instrumentation to the admin app
---
.github/workflows/AdminWebpage-Deploy-WF.yml | 19 +-
Iac/admin-webapp/main.tf | 23 +
Iac/admin-webapp/outputs.tf | 11 +
admin-webapp/.env.example | 5 +
admin-webapp/package-lock.json | 653 ++++++------------
admin-webapp/package.json | 1 +
admin-webapp/src/api/admin.js | 32 +
admin-webapp/src/api/admin.test.js | 43 ++
admin-webapp/src/main.jsx | 4 +
admin-webapp/src/pages/BotsPage.jsx | 134 +++-
admin-webapp/src/telemetry/appInsights.js | 32 +
.../src/telemetry/appInsights.test.js | 27 +
12 files changed, 528 insertions(+), 456 deletions(-)
create mode 100644 admin-webapp/src/telemetry/appInsights.js
create mode 100644 admin-webapp/src/telemetry/appInsights.test.js
diff --git a/.github/workflows/AdminWebpage-Deploy-WF.yml b/.github/workflows/AdminWebpage-Deploy-WF.yml
index 1e59f66..aa82265 100644
--- a/.github/workflows/AdminWebpage-Deploy-WF.yml
+++ b/.github/workflows/AdminWebpage-Deploy-WF.yml
@@ -50,7 +50,23 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- # ── 2. Build the SPA with upstream URLs baked in ──────────────────────
+ # ── 2. Fetch the App Insights connection string (Azure Monitor) ───────
+ # The resource is provisioned by the IaC workflow (iac.yml). This read is
+ # tolerant: if it doesn't exist yet, telemetry is simply disabled in the
+ # build and the next deploy picks it up. The connection string is a client
+ # ingestion key, not a secret — masked here as good practice.
+ - name: Get App Insights connection string
+ id: appinsights
+ run: |
+ CS=$(az resource show \
+ --resource-group "$RESOURCE_GROUP" \
+ --name appi-deliverybot-admin \
+ --resource-type microsoft.insights/components \
+ --query properties.ConnectionString -o tsv 2>/dev/null || true)
+ if [ -n "$CS" ]; then echo "::add-mask::$CS"; fi
+ echo "connection_string=$CS" >> "$GITHUB_OUTPUT"
+
+ # ── 3. Build the SPA with upstream URLs baked in ──────────────────────
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -75,6 +91,7 @@ jobs:
VITE_ENTRA_CLIENT_ID: ${{ env.ENTRA_CLIENT_ID }}
VITE_ENTRA_TENANT_ID: ${{ env.ENTRA_TENANT_ID }}
VITE_ENTRA_ADMIN_GROUP_ID: ${{ env.ENTRA_ADMIN_GROUP_ID }}
+ VITE_APPINSIGHTS_CONNECTION_STRING: ${{ steps.appinsights.outputs.connection_string }}
run: npm run build
# ── 3. Deploy the build to the App Service ─────────────────────────────
diff --git a/Iac/admin-webapp/main.tf b/Iac/admin-webapp/main.tf
index a10bad4..9eb13ed 100644
--- a/Iac/admin-webapp/main.tf
+++ b/Iac/admin-webapp/main.tf
@@ -14,3 +14,26 @@ module "admin_webapp" {
simulator_api_url = var.simulator_api_url
tags = var.tags
}
+
+# ── Observability (final feature: Azure Monitor) ────────────────────────────
+# Dedicated Log Analytics workspace + Application Insights for the admin app.
+# The App Insights connection string is a client ingestion key (not a secret),
+# baked into the SPA at build time — so no data-plane role assignment is needed
+# and this stays fully self-service (no portal/RBAC work).
+resource "azurerm_log_analytics_workspace" "admin" {
+ name = "law-deliverybot-admin"
+ resource_group_name = var.resource_group_name
+ location = var.location
+ sku = "PerGB2018"
+ retention_in_days = 30
+ tags = var.tags
+}
+
+resource "azurerm_application_insights" "admin" {
+ name = "appi-deliverybot-admin"
+ resource_group_name = var.resource_group_name
+ location = var.location
+ workspace_id = azurerm_log_analytics_workspace.admin.id
+ application_type = "web"
+ tags = var.tags
+}
diff --git a/Iac/admin-webapp/outputs.tf b/Iac/admin-webapp/outputs.tf
index a51d087..dc23dce 100644
--- a/Iac/admin-webapp/outputs.tf
+++ b/Iac/admin-webapp/outputs.tf
@@ -12,3 +12,14 @@ output "app_url" {
description = "HTTPS URL of the Admin Web App."
value = module.admin_webapp.app_url
}
+
+output "admin_app_insights_name" {
+ description = "Name of the admin app's Application Insights resource."
+ value = azurerm_application_insights.admin.name
+}
+
+output "admin_app_insights_connection_string" {
+ description = "App Insights connection string (client ingestion key) for the admin SPA."
+ value = azurerm_application_insights.admin.connection_string
+ sensitive = true
+}
diff --git a/admin-webapp/.env.example b/admin-webapp/.env.example
index b15d4ec..3cbffb2 100644
--- a/admin-webapp/.env.example
+++ b/admin-webapp/.env.example
@@ -21,3 +21,8 @@ VITE_ENTRA_CLIENT_ID=b5a029c3-d046-4005-9497-23ba18df70b2
VITE_ENTRA_TENANT_ID=37321907-14a5-4390-987d-ec0c66c655cd
# Object ID of the DeliveryBot-Admin security group (gates who can sign in).
VITE_ENTRA_ADMIN_GROUP_ID=14fcd995-e89f-4020-b5ff-4a9b48a5824e
+
+# Azure Monitor / Application Insights (final feature). Client ingestion key
+# (not a secret). When unset, telemetry is disabled. In CI the deploy workflow
+# fetches this from the IaC-provisioned `appi-deliverybot-admin` resource.
+VITE_APPINSIGHTS_CONNECTION_STRING=
diff --git a/admin-webapp/package-lock.json b/admin-webapp/package-lock.json
index 20d64ce..d5f7929 100644
--- a/admin-webapp/package-lock.json
+++ b/admin-webapp/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@azure/msal-browser": "^5.11.0",
"@azure/msal-react": "^5.4.2",
+ "@microsoft/applicationinsights-web": "^3.4.1",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
@@ -461,9 +462,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -471,448 +472,6 @@
"tslib": "^2.4.0"
}
},
- "node_modules/@esbuild/aix-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
- "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
- "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
- "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/android-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
- "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
- "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/darwin-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
- "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
- "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/freebsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
- "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
- "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
- "cpu": [
- "arm"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
- "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
- "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-loong64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
- "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
- "cpu": [
- "loong64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-mips64el": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
- "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
- "cpu": [
- "mips64el"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-ppc64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
- "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
- "cpu": [
- "ppc64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-riscv64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
- "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
- "cpu": [
- "riscv64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-s390x": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
- "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
- "cpu": [
- "s390x"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/linux-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
- "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/netbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
- "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
- "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
- "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
- "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
- "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
- "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
- "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
- "cpu": [
- "ia32"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@esbuild/win32-x64": {
- "version": "0.28.0",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
- "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
- "cpu": [
- "x64"
- ],
- "dev": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1157,6 +716,138 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@microsoft/applicationinsights-analytics-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-analytics-js/-/applicationinsights-analytics-js-3.4.1.tgz",
+ "integrity": "sha512-zdxZzu50/gsE2JWrzeviHloFZu9r5/x2+OLD0TIYHhvrod321AKkStmKlDoep1JAsSephjxBfwTjciKiFXXyGA==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-core-js": "3.4.1",
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-cfgsync-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-cfgsync-js/-/applicationinsights-cfgsync-js-3.4.1.tgz",
+ "integrity": "sha512-ifNgSIisKM/rZoLdpzS6sIQqBBRNXXAhiazZizwoP2MLdK93Z8JcPNi6eXkKPhW0Fi3SuoUivCaM7GgAMgVEFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-core-js": "3.4.1",
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-async": ">= 0.5.5 < 2.x",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-channel-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-3.4.1.tgz",
+ "integrity": "sha512-QS1k6iwVwR1MznGAB1H0F9raqpevbFNbadLS5O1419pz9OEWBfF9wRQLnENCyo8QS9Q0IdiqnGAON/D8IywpWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-core-js": "3.4.1",
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-async": ">= 0.5.5 < 2.x",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-core-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-3.4.1.tgz",
+ "integrity": "sha512-eXIHZ1+nvBiJgVpufBiTP801Vtr5FEwjWZioUsb44NC/z/UcsZh2MDJ1mBpjaDO73LVYUw/ZZmDCCo6Pg/61kA==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-async": ">= 0.5.5 < 2.x",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-dependencies-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-dependencies-js/-/applicationinsights-dependencies-js-3.4.1.tgz",
+ "integrity": "sha512-cnjVVTxSeavmwCoOwXi/ZQuCcjo7SnYYRWyS+GsMCrTRTItVHZlVj6NHgmFEQZWNybrM+U1RgwZE2wXn1/Liyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-core-js": "3.4.1",
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-async": ">= 0.5.5 < 2.x",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-properties-js": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-properties-js/-/applicationinsights-properties-js-3.4.1.tgz",
+ "integrity": "sha512-s2cUuknjazaoCbh9i6ljymeZqeQqpyAE8v2ZUxCkAwRuxbonAvZWQtEr4QQmEHWJIdbWgn0Ge+OOlMtMkh+Ixg==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-core-js": "3.4.1",
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-shims": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-3.0.1.tgz",
+ "integrity": "sha512-DKwboF47H1nb33rSUfjqI6ryX29v+2QWcTrRvcQDA32AZr5Ilkr7whOOSsD1aBzwqX0RJEIP1Z81jfE3NBm/Lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nevware21/ts-utils": ">= 0.9.4 < 2.x"
+ }
+ },
+ "node_modules/@microsoft/applicationinsights-web": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-web/-/applicationinsights-web-3.4.1.tgz",
+ "integrity": "sha512-gdYLIYkP11D+V71nNCupYsmWE8LAL9EpIR2Q7+B3n6dpck7tgaYMXFN3S5ZrOh3yxLAwt7GVXZoDcN2mGkagxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@microsoft/applicationinsights-analytics-js": "3.4.1",
+ "@microsoft/applicationinsights-cfgsync-js": "3.4.1",
+ "@microsoft/applicationinsights-channel-js": "3.4.1",
+ "@microsoft/applicationinsights-core-js": "3.4.1",
+ "@microsoft/applicationinsights-dependencies-js": "3.4.1",
+ "@microsoft/applicationinsights-properties-js": "3.4.1",
+ "@microsoft/applicationinsights-shims": "3.0.1",
+ "@microsoft/dynamicproto-js": "^2.0.3",
+ "@nevware21/ts-async": ">= 0.5.5 < 2.x",
+ "@nevware21/ts-utils": ">= 0.12.6 < 2.x"
+ },
+ "peerDependencies": {
+ "tslib": ">= 1.0.0"
+ }
+ },
+ "node_modules/@microsoft/dynamicproto-js": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-2.0.5.tgz",
+ "integrity": "sha512-V+Zr7PDKIEaItVwF/OyWQlKeugNRYg7KJJ+RhEIL2FMW6NlG8FN2l4XA9Z42hNtsjwJFlcUiF38pmM/AaXsF7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@nevware21/ts-utils": ">= 0.14.0 < 2.x"
+ }
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -1176,6 +867,41 @@
"@emnapi/runtime": "^1.7.1"
}
},
+ "node_modules/@nevware21/ts-async": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/@nevware21/ts-async/-/ts-async-0.6.1.tgz",
+ "integrity": "sha512-W2kFiT5oPuxTrB3NrxUId/U+1AuAhIaiDQkLC4HcxkjNc+85GfELYdPQXnsDWDG8yji24F5qk6QpBDxZX3/0+g==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nevware21"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://www.buymeacoffee.com/nevware21"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@nevware21/ts-utils": ">= 0.15.0 < 2.x"
+ }
+ },
+ "node_modules/@nevware21/ts-utils": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@nevware21/ts-utils/-/ts-utils-0.15.0.tgz",
+ "integrity": "sha512-+bUMKIiKAgoW5uNEb5xxzBzdwdLS9SKRcOy8SxLE+KqSlIdUYV5O9nxJVq1RUYcO2DtL5DlrK1GbgcVEHv6GVA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nevware21"
+ },
+ {
+ "type": "other",
+ "url": "https://buymeacoffee.com/nevware21"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/@oxc-project/types": {
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
@@ -1409,6 +1135,40 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
@@ -4259,9 +4019,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
"license": "0BSD",
- "optional": true
+ "peer": true
},
"node_modules/type-check": {
"version": "0.4.0",
diff --git a/admin-webapp/package.json b/admin-webapp/package.json
index d07084b..90d8457 100644
--- a/admin-webapp/package.json
+++ b/admin-webapp/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@azure/msal-browser": "^5.11.0",
"@azure/msal-react": "^5.4.2",
+ "@microsoft/applicationinsights-web": "^3.4.1",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
diff --git a/admin-webapp/src/api/admin.js b/admin-webapp/src/api/admin.js
index 0f44589..57d983d 100644
--- a/admin-webapp/src/api/admin.js
+++ b/admin-webapp/src/api/admin.js
@@ -20,6 +20,7 @@ import {
createSimulatorBot,
updateSimulatorBot,
deleteSimulatorBot,
+ listSimulatorBots,
simulatorConfig,
} from './simulator.js'
@@ -27,6 +28,37 @@ export async function listBots() {
return botnetList()
}
+// Live fleet view: the BotNet registry enriched with the simulator's live
+// runtime telemetry (power level, status, location), matched by botId. Both
+// calls run concurrently and the simulator side is best-effort — if it's
+// unreachable, bots come back with `telemetry: null` and the registry view
+// still renders.
+export async function listBotsWithTelemetry() {
+ const [botnetResult, simResult] = await Promise.all([
+ botnetList(),
+ listSimulatorBots(),
+ ])
+
+ const simBots = simResult?.ok && Array.isArray(simResult.data) ? simResult.data : []
+ const byBotId = new Map(simBots.map((b) => [b.botId, b]))
+
+ const data = (Array.isArray(botnetResult.data) ? botnetResult.data : []).map((bot) => {
+ const t = byBotId.get(toBotId(bot.name))
+ return {
+ ...bot,
+ telemetry: t
+ ? { powerLevel: t.powerLevel, status: t.status, location: t.currentLocation }
+ : null,
+ }
+ })
+
+ return {
+ data,
+ source: botnetResult.source,
+ simulatorReachable: Boolean(simResult?.ok),
+ }
+}
+
export async function registerBot({ name, batteryLevel = 100, isOnline = true }) {
const botnetResult = await botnetCreate({ name, batteryLevel, isOnline })
diff --git a/admin-webapp/src/api/admin.test.js b/admin-webapp/src/api/admin.test.js
index 23a5fcd..b6b2b88 100644
--- a/admin-webapp/src/api/admin.test.js
+++ b/admin-webapp/src/api/admin.test.js
@@ -14,6 +14,7 @@ vi.mock('./simulator.js', () => ({
createSimulatorBot: vi.fn(),
updateSimulatorBot: vi.fn(),
deleteSimulatorBot: vi.fn(),
+ listSimulatorBots: vi.fn(),
simulatorConfig: { baseUrl: 'http://sim', configured: true },
}))
@@ -21,6 +22,48 @@ const bots = await import('./bots.js')
const sim = await import('./simulator.js')
const admin = await import('./admin.js')
+describe('listBotsWithTelemetry (live monitoring)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('merges simulator telemetry into BotNet bots by botId', async () => {
+ bots.listBots.mockResolvedValue({
+ source: 'api',
+ data: [
+ { id: 1, name: 'bot-001', batteryLevel: 80 },
+ { id: 2, name: 'bot-002', batteryLevel: 50 },
+ ],
+ })
+ sim.listSimulatorBots.mockResolvedValue({
+ ok: true,
+ data: [{ botId: 'bot-001', powerLevel: 79.4, status: 1, currentLocation: { latitude: 47.6, longitude: -117.4 } }],
+ })
+
+ const result = await admin.listBotsWithTelemetry()
+
+ expect(result.source).toBe('api')
+ expect(result.simulatorReachable).toBe(true)
+ expect(result.data[0].telemetry).toEqual({
+ powerLevel: 79.4,
+ status: 1,
+ location: { latitude: 47.6, longitude: -117.4 },
+ })
+ // bot-002 has no simulator match → telemetry is null.
+ expect(result.data[1].telemetry).toBeNull()
+ })
+
+ it('returns bots without telemetry when the simulator is unreachable', async () => {
+ bots.listBots.mockResolvedValue({ source: 'api', data: [{ id: 1, name: 'bot-001' }] })
+ sim.listSimulatorBots.mockResolvedValue({ ok: false, skipped: true })
+
+ const result = await admin.listBotsWithTelemetry()
+
+ expect(result.simulatorReachable).toBe(false)
+ expect(result.data[0].telemetry).toBeNull()
+ })
+})
+
describe('registerBot (issue #49)', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/admin-webapp/src/main.jsx b/admin-webapp/src/main.jsx
index 8b68125..95963a6 100644
--- a/admin-webapp/src/main.jsx
+++ b/admin-webapp/src/main.jsx
@@ -6,6 +6,10 @@ import './index.css'
import App from './App.jsx'
import { authEnabled } from './auth/authConfig.js'
import { msalInstance } from './auth/msalInstance.js'
+import { initTelemetry } from './telemetry/appInsights.js'
+
+// Azure Monitor: start client telemetry (no-op when not configured).
+initTelemetry()
const root = createRoot(document.getElementById('root'))
diff --git a/admin-webapp/src/pages/BotsPage.jsx b/admin-webapp/src/pages/BotsPage.jsx
index f4fe85d..5f76db5 100644
--- a/admin-webapp/src/pages/BotsPage.jsx
+++ b/admin-webapp/src/pages/BotsPage.jsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import {
- listBots,
+ listBotsWithTelemetry,
registerBot,
modifyBot,
removeBot,
@@ -9,6 +9,7 @@ import {
simulatorConfig,
} from '../api/admin.js'
import { apiConfig as botnetConfig } from '../api/bots.js'
+import { trackEvent } from '../telemetry/appInsights.js'
import BotDialog from '../components/BotDialog.jsx'
import ConfirmDialog from '../components/ConfirmDialog.jsx'
@@ -30,16 +31,23 @@ export default function BotsPage() {
const [loading, setLoading] = useState(true)
const [source, setSource] = useState('mock')
const [busyId, setBusyId] = useState(null)
+ const [autoRefresh, setAutoRefresh] = useState(true)
+ const [lastUpdated, setLastUpdated] = useState(null)
+ const [simReachable, setSimReachable] = useState(false)
const [dialog, setDialog] = useState({ open: false, mode: 'create', bot: null })
const [deleteTarget, setDeleteTarget] = useState(null)
const [banner, setBanner] = useState(null)
- const refresh = useCallback(async () => {
- setLoading(true)
- const { data, source } = await listBots()
+ // Pull the BotNet registry + live simulator telemetry. `quiet` skips the
+ // loading flag so the auto-refresh poll doesn't flicker the table.
+ const refresh = useCallback(async (quiet = false) => {
+ if (!quiet) setLoading(true)
+ const { data, source, simulatorReachable } = await listBotsWithTelemetry()
setBots(Array.isArray(data) ? data : [])
setSource(source)
+ setSimReachable(simulatorReachable)
+ setLastUpdated(new Date())
setLoading(false)
}, [])
@@ -47,10 +55,23 @@ export default function BotsPage() {
refresh()
}, [refresh])
+ // Live monitoring: poll every 5s while auto-refresh is on, but pause during
+ // edits / deletes / in-flight actions so we don't disrupt the operator.
+ useEffect(() => {
+ if (!autoRefresh) return undefined
+ const id = setInterval(() => {
+ if (!dialog.open && !deleteTarget && busyId === null) {
+ refresh(true)
+ }
+ }, 5000)
+ return () => clearInterval(id)
+ }, [autoRefresh, refresh, dialog.open, deleteTarget, busyId])
+
// #51 Quick-action: recharge (double-writes battery=100 to BotNet + simulator)
async function onRecharge(bot) {
setBusyId(bot.id)
const result = await rechargeBot(bot.id, bot.name)
+ trackEvent('BotRecharged', { botId: bot.id, botName: bot.name })
const sim = result?.simulator
if (!result?.botnet?.error && sim && !sim.ok && !sim.skipped) {
setBanner({
@@ -67,7 +88,9 @@ export default function BotsPage() {
// #51 Quick-action: toggle servicing status (BotNet-only — see admin.js)
async function onToggleServicing(bot) {
setBusyId(bot.id)
- await setServicingStatus(bot.id, !bot.isServicingCustomer)
+ const nextActive = !bot.isServicingCustomer
+ await setServicingStatus(bot.id, nextActive)
+ trackEvent('BotServicingToggled', { botId: bot.id, active: nextActive })
await refresh()
setBusyId(null)
}
@@ -76,6 +99,7 @@ export default function BotsPage() {
async function handleCreate(values) {
const result = await registerBot(values)
if (!result?.botnet?.error) {
+ trackEvent('BotCreated', { botName: values.name })
const sim = result?.simulator
if (sim && !sim.ok && !sim.skipped) {
setBanner({
@@ -95,6 +119,7 @@ export default function BotsPage() {
const id = dialog.bot.id
const result = await modifyBot(id, values.name, values)
if (!result?.botnet?.error) {
+ trackEvent('BotUpdated', { botId: id, botName: values.name })
const sim = result?.simulator
if (sim && !sim.ok && !sim.skipped) {
setBanner({
@@ -114,6 +139,7 @@ export default function BotsPage() {
if (!deleteTarget) return { botnet: { error: 'no target' } }
const result = await removeBot(deleteTarget.id, deleteTarget.name)
if (!result?.botnet?.error) {
+ trackEvent('BotDeleted', { botId: deleteTarget.id, botName: deleteTarget.name })
const sim = result?.simulator
if (sim && !sim.ok && !sim.skipped) {
setBanner({
@@ -138,6 +164,12 @@ export default function BotsPage() {
+ setAutoRefresh((v) => !v)}
+ />
-
@@ -174,6 +206,7 @@ export default function BotsPage() {
Battery |
Status |
Servicing Customer |
+ Live Telemetry |
Last Updated |
Actions |
@@ -181,12 +214,12 @@ export default function BotsPage() {
{loading && bots.length === 0 && (
- | Loading bots… |
+ Loading bots… |
)}
{!loading && bots.length === 0 && (
- |
+ |
No bots registered. Click + New Bot to add one.
|
@@ -210,6 +243,9 @@ export default function BotsPage() {
offLabel="Idle"
/>
+
+
+ |
{formatTime(bot.lastUpdated)} |
@@ -333,6 +369,63 @@ function StatusPill({ ok, okLabel, offLabel }) {
)
}
+// Live monitoring indicator + pause/resume control.
+function LiveControl({ autoRefresh, lastUpdated, simReachable, onToggle }) {
+ const timeStr = lastUpdated
+ ? lastUpdated.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+ : '—'
+ const dotColor = !autoRefresh ? 'var(--text-dim)' : simReachable ? 'var(--ok)' : 'var(--warn)'
+ const label = !autoRefresh ? 'Paused' : simReachable ? 'Live' : 'Live (no telemetry)'
+ const glow = dotColor === 'var(--ok)' ? 'rgba(34,197,94,0.25)' : 'rgba(245,158,11,0.25)'
+ return (
+
+
+
+ {label}
+ · {timeStr}
+
+
+ {autoRefresh ? 'Pause' : 'Resume'}
+
+
+ )
+}
+
+// Simulator BotStatus enum (Core/Bots): 0 = Available, 1 = OnDelivery.
+function simStatusLabel(status) {
+ if (status === 0) return 'Available'
+ if (status === 1) return 'On delivery'
+ return status == null ? '' : String(status)
+}
+
+// Live runtime telemetry from the simulator for one bot.
+function Telemetry({ telemetry, simReachable }) {
+ if (!telemetry) {
+ return (
+
+ ● {simReachable ? 'no signal' : 'offline'}
+
+ )
+ }
+ const power =
+ typeof telemetry.powerLevel === 'number' ? `${telemetry.powerLevel.toFixed(1)}%` : '—'
+ return (
+
+
+ {power}
+
+ {simStatusLabel(telemetry.status)}
+
+
+ )
+}
+
const styles = {
section: { padding: '0 2rem 2rem' },
header: {
@@ -438,6 +531,31 @@ const styles = {
borderRadius: '999px',
fontSize: '0.8rem',
},
+ liveControl: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.3rem 0.6rem',
+ border: '1px solid var(--border)',
+ borderRadius: '999px',
+ background: 'var(--bg-elev)',
+ },
+ liveDot: {
+ width: '0.6rem',
+ height: '0.6rem',
+ borderRadius: '50%',
+ display: 'inline-block',
+ flexShrink: 0,
+ },
+ liveText: { fontSize: '0.82rem', fontWeight: 500 },
+ liveBtn: {
+ background: 'transparent',
+ color: 'var(--text-dim)',
+ border: '1px solid var(--border)',
+ padding: '0.2rem 0.55rem',
+ borderRadius: '6px',
+ fontSize: '0.78rem',
+ },
footer: {
marginTop: '1rem',
fontSize: '0.8rem',
diff --git a/admin-webapp/src/telemetry/appInsights.js b/admin-webapp/src/telemetry/appInsights.js
new file mode 100644
index 0000000..04db8dc
--- /dev/null
+++ b/admin-webapp/src/telemetry/appInsights.js
@@ -0,0 +1,32 @@
+// Azure Monitor / Application Insights client telemetry (final feature).
+//
+// Gated behind VITE_APPINSIGHTS_CONNECTION_STRING (a client ingestion key, not
+// a secret — baked in at build time like the API URLs). When it's unset,
+// telemetry is disabled and every call here is a safe no-op, so local dev and
+// the unconfigured build keep working. The SDK is dynamically imported so it
+// never loads (or enters the test graph) unless telemetry is actually on.
+
+const connectionString = import.meta.env.VITE_APPINSIGHTS_CONNECTION_STRING ?? ''
+
+export const telemetryEnabled = Boolean(connectionString)
+
+let appInsights = null
+
+export async function initTelemetry() {
+ if (!telemetryEnabled || appInsights) return
+ const { ApplicationInsights } = await import('@microsoft/applicationinsights-web')
+ appInsights = new ApplicationInsights({
+ config: {
+ connectionString,
+ enableAutoRouteTracking: true, // SPA tab/page views
+ },
+ })
+ appInsights.loadAppInsights()
+ appInsights.trackPageView()
+}
+
+// Record a named admin action (e.g. BotRecharged). No-op until telemetry inits.
+export function trackEvent(name, properties = {}) {
+ if (!appInsights) return
+ appInsights.trackEvent({ name }, properties)
+}
diff --git a/admin-webapp/src/telemetry/appInsights.test.js b/admin-webapp/src/telemetry/appInsights.test.js
new file mode 100644
index 0000000..eba7b1d
--- /dev/null
+++ b/admin-webapp/src/telemetry/appInsights.test.js
@@ -0,0 +1,27 @@
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { trackEvent } from './appInsights.js'
+
+describe('appInsights telemetry (final: Azure Monitor)', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ vi.resetModules()
+ })
+
+ it('is disabled when no connection string is configured', async () => {
+ vi.resetModules()
+ vi.stubEnv('VITE_APPINSIGHTS_CONNECTION_STRING', '')
+ const mod = await import('./appInsights.js?nocache=' + Date.now())
+ expect(mod.telemetryEnabled).toBe(false)
+ })
+
+ it('enables telemetry when a connection string is set', async () => {
+ vi.resetModules()
+ vi.stubEnv('VITE_APPINSIGHTS_CONNECTION_STRING', 'InstrumentationKey=test;IngestionEndpoint=https://x/')
+ const mod = await import('./appInsights.js?nocache=' + Date.now())
+ expect(mod.telemetryEnabled).toBe(true)
+ })
+
+ it('trackEvent is a safe no-op before telemetry initializes', () => {
+ expect(() => trackEvent('TestEvent', { a: 1 })).not.toThrow()
+ })
+})
|