diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs index c25da4ba..22b47c69 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HealthCheckServiceAgentBinariesTests.cs @@ -31,7 +31,10 @@ public class HealthCheckServiceAgentBinariesTests Assert.Equal("Agent Binaries", check.Name); Assert.Equal("healthy", check.Status); - Assert.Contains("download alias", check.Message); + // The healthy message names neither host: the legacy keepalive account is not + // advertised to tenant admins (it only appears in the Global-Admin-only Details). + Assert.DoesNotContain("legacy", check.Message, System.StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("alias", check.Message, System.StringComparison.OrdinalIgnoreCase); Assert.Null(check.Details); // Both hosts must actually have been probed (ZIP + PS1 each). Assert.Equal(2, handler.Requests.FindAll(u => u.Contains(AliasHost)).Count); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs index afa7de0d..180ca3ce 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/HealthCheckService.cs @@ -210,7 +210,9 @@ public class HealthCheckService if (issues.Count == 0) { check.Status = "healthy"; - check.Message = $"Agent package and bootstrap script available via download alias + legacy blob ({sw.ElapsedMilliseconds}ms)"; + // Neutral wording for everyone: the legacy keepalive account is operator detail + // (Details, Global Admins only) and is not advertised to tenant admins. + check.Message = $"Agent package and bootstrap script available ({sw.ElapsedMilliseconds}ms)"; } else if (aliasZipOk && aliasPs1Ok) { diff --git a/src/Web/autopilot-monitor-web/app/health-check/page.tsx b/src/Web/autopilot-monitor-web/app/health-check/page.tsx index e382b8e6..7f072dc7 100644 --- a/src/Web/autopilot-monitor-web/app/health-check/page.tsx +++ b/src/Web/autopilot-monitor-web/app/health-check/page.tsx @@ -9,13 +9,63 @@ import { useState, useEffect, useCallback } from 'react'; import * as signalR from '@microsoft/signalr'; import { api } from '@/lib/api'; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { isUrlDetail, visibleHealthChecks, visibleHealthDetails } from '@/lib/healthCheckView'; import type { DetailedHealthCheckResponse, HealthCheck, McpHealthCheckResponse } from '@/utils/wire-types.generated'; +function formatDetailValue(value: unknown): string { + return Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : String(value); +} + +/** + * Key/value rows of a check card. URL values get their own line under the label (left-aligned, + * breaking anywhere) instead of fighting the label for width; short values stay right-aligned + * on the label's line. Azure resource IDs collapse to a "Set" pill with the path in the tooltip. + */ +function HealthDetails({ details }: { details: Record | undefined }) { + if (!details || Object.keys(details).length === 0) return null; + return ( +
+

Details

+
+ {Object.entries(details).map(([key, value]) => { + if (isUrlDetail(value)) { + return ( +
+
{key}
+
{value}
+
+ ); + } + const isResourceId = key === 'Resource' && typeof value === 'string' && value.startsWith('/subscriptions/'); + return ( +
+
{key}
+
+ {isResourceId ? ( + + Set + + ) : formatDetailValue(value)} +
+
+ ); + })} +
+
+ ); +} + export default function HealthCheckPage() { const { user, getAccessToken } = useAuth(); - // Build stamps (version + commit hash, linking into the private repo) are operator-only detail. - // They follow the Global-Admin VIEW, so the page can be shown live without leaking internals. - const showBuildDetails = useGlobalAdminUi(); + // Operator-only detail — build stamps (version + commit hash, linking into the private repo), + // the SignalR-quota and poison-queue cards, and the endpoint URLs in the check details — follows + // the Global-Admin VIEW, so the page can be shown live (demo mode) without leaking internals. + // The server applies the same rule by real identity; this only mirrors it for presentation. + const operatorView = useGlobalAdminUi(); + const showBuildDetails = operatorView; const { addNotification } = useNotifications(); const { connectionState, isConnected, joinedGroups, joinGroup } = useSignalR(); const { tenantId } = useTenant(); @@ -397,7 +447,7 @@ export default function HealthCheckPage() {

Backend Services

- {healthResult.checks.map((check, index) => { + {visibleHealthChecks(healthResult.checks, operatorView).map((check, index) => { const colors = getStatusColor(check.status); return (
@@ -433,31 +483,7 @@ export default function HealthCheckPage() { {check.message}

- {check.details && Object.keys(check.details).length > 0 && ( -
-

Details

-
- {Object.entries(check.details).map(([key, value]) => { - const isResourceId = key === 'Resource' && typeof value === 'string' && value.startsWith('/subscriptions/'); - return ( -
-
{key}
-
- {isResourceId ? ( - - Set - - ) : Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : String(value)} -
-
- ); - })} -
-
- )} +
); @@ -509,21 +535,8 @@ export default function HealthCheckPage() { {display.message}

- {display.details && Object.keys(display.details).length > 0 && ( -
-

Details

-
- {Object.entries(display.details).map(([key, value]) => ( -
-
{key}
-
- {Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : String(value)} -
-
- ))} -
-
- )} + +
); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts new file mode 100644 index 00000000..39633817 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/healthCheckView.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import type { HealthCheck } from "@/utils/wire-types.generated"; +import { isUrlDetail, visibleHealthChecks, visibleHealthDetails } from "../healthCheckView"; + +const checks: HealthCheck[] = [ + { + name: "Storage Backend", + description: "Data storage connectivity", + status: "healthy", + message: "Storage reachable (0ms)", + details: { "Table endpoint": "https://example.table.core.windows.net" }, + }, + { + name: "Agent Binaries", + description: "Agent download package availability", + status: "healthy", + message: "Agent package and bootstrap script available (48ms)", + details: { + "Agent ZIP": "https://download.example.com/agent/Agent.zip", + "Bootstrap script": "https://download.example.com/agent/Install.ps1", + "Legacy blob (keepalive)": "https://legacy.blob.core.windows.net/agent", + }, + }, + { + name: "SignalR Quota", + description: "Connection + daily message usage vs. plan limits", + status: "healthy", + message: "Within plan limits", + details: { "Connections (max/1h)": "6/1000 (0%)", Resource: "/subscriptions/x" }, + }, + { + name: "Poison Queues", + description: "Async-worker dead-letter backlog", + status: "healthy", + message: "All poison queues empty", + details: { "foo-poison": "0 messages" }, + }, + { + name: "MCP Server", + description: "AI query interface availability", + status: "healthy", + message: "MCP server reachable (6ms)", + details: { "Server URL": "https://mcp.example.com", Version: "1.6.410" }, + }, +]; + +describe("isUrlDetail", () => { + it("recognises http(s) strings only", () => { + expect(isUrlDetail("https://a.b")).toBe(true); + expect(isUrlDetail("HTTP://a.b")).toBe(true); + expect(isUrlDetail("1.6.410")).toBe(false); + expect(isUrlDetail("/subscriptions/x")).toBe(false); + expect(isUrlDetail(42)).toBe(false); + expect(isUrlDetail(undefined)).toBe(false); + }); +}); + +describe("visibleHealthChecks", () => { + it("returns the same reference in the operator view — nothing is hidden", () => { + expect(visibleHealthChecks(checks, true)).toBe(checks); + }); + + it("drops SignalR Quota and Poison Queues outside the operator view, like the server does", () => { + const names = visibleHealthChecks(checks, false).map((c) => c.name); + expect(names).toEqual(["Storage Backend", "Agent Binaries", "MCP Server"]); + }); + + it("strips endpoint URLs from the remaining cards but keeps non-URL rows", () => { + const visible = visibleHealthChecks(checks, false); + expect(visible.find((c) => c.name === "Storage Backend")?.details).toBeUndefined(); + expect(visible.find((c) => c.name === "Agent Binaries")?.details).toBeUndefined(); + expect(visible.find((c) => c.name === "MCP Server")?.details).toEqual({ Version: "1.6.410" }); + }); + + it("does not mutate the input", () => { + const before = JSON.stringify(checks); + visibleHealthChecks(checks, false); + expect(JSON.stringify(checks)).toBe(before); + }); +}); + +describe("visibleHealthDetails", () => { + it("passes undefined through and leaves the operator view untouched", () => { + expect(visibleHealthDetails(undefined, false)).toBeUndefined(); + const d = { "Server URL": "https://mcp.example.com" }; + expect(visibleHealthDetails(d, true)).toBe(d); + }); + + it("returns undefined rather than an empty object when every row was a URL", () => { + expect(visibleHealthDetails({ "Server URL": "https://mcp.example.com" }, false)).toBeUndefined(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/healthCheckView.ts b/src/Web/autopilot-monitor-web/lib/healthCheckView.ts new file mode 100644 index 00000000..1e899513 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/healthCheckView.ts @@ -0,0 +1,39 @@ +import type { HealthCheck } from "@/utils/wire-types.generated"; + +/** + * Presentation-side mirror of the server's operator-only rule for the detailed health report + * (HealthCheckFunction.GetDetailedHealthCheck): the server strips these for non-Global-Admins, + * and this makes the page match that shape when the Global-Admin VIEW is off (demo mode), so a + * live demo looks exactly like a tenant admin's page. Pure logic, no DOM — see lib/demoMode.ts. + * + * NOT a security boundary: the real gate is the server, which never sends this data to non-GAs. + */ + +/** Checks the server only returns to Global Admins: internal infrastructure topology. */ +export const OPERATOR_ONLY_CHECKS: ReadonlySet = new Set(["SignalR Quota", "Poison Queues"]); + +/** True for detail values that are URLs — the server includes these only for Global Admins. */ +export function isUrlDetail(value: unknown): value is string { + return typeof value === "string" && /^https?:\/\//i.test(value); +} + +/** + * Drops the operator-only cards and the endpoint-URL detail rows unless the operator view is on. + * Returns the input untouched (same reference) when nothing needs hiding. + */ +export function visibleHealthChecks(checks: readonly HealthCheck[], operatorView: boolean): HealthCheck[] { + if (operatorView) return checks as HealthCheck[]; + return checks + .filter((c) => !OPERATOR_ONLY_CHECKS.has(c.name)) + .map((c) => ({ ...c, details: visibleHealthDetails(c.details, false) })); +} + +/** Removes URL-valued rows unless the operator view is on; undefined when nothing is left. */ +export function visibleHealthDetails( + details: Record | undefined, + operatorView: boolean, +): Record | undefined { + if (!details || operatorView) return details; + const kept = Object.entries(details).filter(([, v]) => !isUrlDetail(v)); + return kept.length > 0 ? Object.fromEntries(kept) : undefined; +}