mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+4
-1
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user