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} + + +
+ ) +} + +// 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() + }) +})