feat(health): operator-only cards and URLs follow the Global-Admin view; stacked URL rows

The server already strips SignalR Quota, Poison Queues and every endpoint URL
for non-Global-Admins, but the page mirrored the Global-Admin VIEW only for the
build blocks, so demo mode still showed the full operator report. The new
lib/healthCheckView.ts applies the same rule presentation-side (cards + URL
detail rows), and both card render paths share one HealthDetails component
that puts URL values on their own line under the label instead of wrapping
them right-aligned next to it.

The healthy agent-binaries message no longer advertises the legacy blob; that
account stays in the Global-Admin-only details.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 03:57:10 +02:00
parent d59286b8b3
commit a635ac3bc6
5 changed files with 195 additions and 46 deletions
@@ -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);
@@ -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)
{
@@ -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<string, unknown> | undefined }) {
if (!details || Object.keys(details).length === 0) return null;
return (
<div className="mt-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Details</h4>
<dl className="space-y-1">
{Object.entries(details).map(([key, value]) => {
if (isUrlDetail(value)) {
return (
<div key={key} className="text-xs">
<dt className="font-medium text-gray-600 dark:text-gray-400">{key}</dt>
<dd className="text-gray-900 dark:text-gray-100 font-mono break-all">{value}</dd>
</div>
);
}
const isResourceId = key === 'Resource' && typeof value === 'string' && value.startsWith('/subscriptions/');
return (
<div key={key} className="flex justify-between text-xs items-center gap-3">
<dt className="font-medium text-gray-600 dark:text-gray-400 shrink-0">{key}</dt>
<dd className="text-gray-900 dark:text-gray-100 font-mono text-right break-all">
{isResourceId ? (
<span
title={String(value)}
className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400 cursor-help"
>
Set
</span>
) : formatDetailValue(value)}
</dd>
</div>
);
})}
</dl>
</div>
);
}
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() {
<div>
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">Backend Services</h2>
<div className="grid gap-5 sm:grid-cols-2">
{healthResult.checks.map((check, index) => {
{visibleHealthChecks(healthResult.checks, operatorView).map((check, index) => {
const colors = getStatusColor(check.status);
return (
<div key={index} className={`bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 ${colors.accent}`}>
@@ -433,31 +483,7 @@ export default function HealthCheckPage() {
{check.message}
</p>
{check.details && Object.keys(check.details).length > 0 && (
<div className="mt-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Details</h4>
<dl className="space-y-1">
{Object.entries(check.details).map(([key, value]) => {
const isResourceId = key === 'Resource' && typeof value === 'string' && value.startsWith('/subscriptions/');
return (
<div key={key} className="flex justify-between text-xs items-center gap-3">
<dt className="font-medium text-gray-600 dark:text-gray-400 shrink-0">{key}</dt>
<dd className="text-gray-900 dark:text-gray-100 font-mono text-right break-all">
{isResourceId ? (
<span
title={String(value)}
className="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-400 cursor-help"
>
Set
</span>
) : Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : String(value)}
</dd>
</div>
);
})}
</dl>
</div>
)}
<HealthDetails details={check.details} />
</div>
</div>
);
@@ -509,21 +535,8 @@ export default function HealthCheckPage() {
{display.message}
</p>
{display.details && Object.keys(display.details).length > 0 && (
<div className="mt-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-3 border border-gray-200 dark:border-gray-600">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Details</h4>
<dl className="space-y-1">
{Object.entries(display.details).map(([key, value]) => (
<div key={key} className="flex justify-between text-xs items-center gap-3">
<dt className="font-medium text-gray-600 dark:text-gray-400 shrink-0">{key}</dt>
<dd className="text-gray-900 dark:text-gray-100 font-mono text-right break-all">
{Array.isArray(value) ? value.join(', ') : typeof value === 'object' ? JSON.stringify(value) : String(value)}
</dd>
</div>
))}
</dl>
</div>
)}
<HealthDetails details={visibleHealthDetails(display.details, operatorView)} />
</div>
</div>
);
@@ -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();
});
});
@@ -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<string> = 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<string, unknown> | undefined,
operatorView: boolean,
): Record<string, unknown> | undefined {
if (!details || operatorView) return details;
const kept = Object.entries(details).filter(([, v]) => !isUrlDetail(v));
return kept.length > 0 ? Object.fromEntries(kept) : undefined;
}