From bc4c266470120ebcb04b101ecbd56ef5e7185764 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 19:28:36 +0000 Subject: [PATCH] Add Tenant Config Report page for Galactic Admins New read-only report page that shows the full tenant configuration and computed runtime parameters (AgentConfigResponse) side by side. Helps Galactic Admins quickly analyze what a tenant has configured. - Tenant selector to pick any tenant cross-tenant - All TenantConfiguration fields grouped by section - Computed runtime parameters with defaults applied - Non-default values highlighted in purple for quick scanning - SAS URLs are masked for security - Added nav item to Galactic Admin section https://claude.ai/code/session_0136YgG8JDuqAhHvMbALyPoZ --- .../app/tenant-config-report/page.tsx | 665 ++++++++++++++++++ .../lib/globalNavConfig.tsx | 9 + tasks/todo.md | 16 + 3 files changed, 690 insertions(+) create mode 100644 src/Web/autopilot-monitor-web/app/tenant-config-report/page.tsx diff --git a/src/Web/autopilot-monitor-web/app/tenant-config-report/page.tsx b/src/Web/autopilot-monitor-web/app/tenant-config-report/page.tsx new file mode 100644 index 00000000..204c6b69 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/tenant-config-report/page.tsx @@ -0,0 +1,665 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { ProtectedRoute } from '../../components/ProtectedRoute'; +import { useAuth } from '../../contexts/AuthContext'; +import { API_BASE_URL } from '@/lib/config'; +import { authenticatedFetch, TokenExpiredError } from '@/lib/authenticatedFetch'; +import { trackEvent } from '@/lib/appInsights'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface TenantInfo { + tenantId: string; + domainName: string; +} + +/** Full tenant configuration as returned by GET /api/config/{tenantId}. */ +interface TenantConfig { + tenantId: string; + domainName?: string; + lastUpdated?: string; + updatedBy?: string; + onboardedAt?: string; + + // Tenant status + disabled?: boolean; + disabledReason?: string; + disabledUntil?: string; + + // Security + rateLimitRequestsPerMinute?: number; + customRateLimitRequestsPerMinute?: number | null; + manufacturerWhitelist?: string; + modelWhitelist?: string; + validateAutopilotDevice?: boolean; + validateCorporateIdentifier?: boolean; + allowInsecureAgentRequests?: boolean; + + // Data management + dataRetentionDays?: number; + sessionTimeoutHours?: number; + maxNdjsonPayloadSizeMB?: number; + + // Collectors + enablePerformanceCollector?: boolean; + performanceCollectorIntervalSeconds?: number; + helloWaitTimeoutSeconds?: number; + + // Auth circuit breaker + maxAuthFailures?: number | null; + authFailureTimeoutMinutes?: number | null; + agentMaxLifetimeMinutes?: number | null; + + // Agent behavior + selfDestructOnComplete?: boolean | null; + keepLogFile?: boolean | null; + rebootOnComplete?: boolean | null; + rebootDelaySeconds?: number | null; + enableGeoLocation?: boolean | null; + enableImeMatchLog?: boolean | null; + logLevel?: string | null; + maxBatchSize?: number | null; + showEnrollmentSummary?: boolean | null; + enrollmentSummaryTimeoutSeconds?: number | null; + enrollmentSummaryBrandingImageUrl?: string | null; + enrollmentSummaryLaunchRetrySeconds?: number | null; + showScriptOutput?: boolean | null; + sendTraceEvents?: boolean; + + // Analyzers + enableLocalAdminAnalyzer?: boolean | null; + enableSoftwareInventoryAnalyzer?: boolean | null; + localAdminAllowedAccountsJson?: string | null; + + // Bootstrap & unrestricted + bootstrapTokenEnabled?: boolean; + unrestrictedModeEnabled?: boolean; + unrestrictedMode?: boolean; + + // Diagnostics + diagnosticsBlobSasUrl?: string | null; + diagnosticsUploadMode?: string; + diagnosticsLogPathsJson?: string | null; + + // Webhook notifications (new) + webhookProviderType?: number; + webhookUrl?: string | null; + webhookNotifyOnSuccess?: boolean; + webhookNotifyOnFailure?: boolean; + + // Teams notifications (legacy) + teamsWebhookUrl?: string | null; + teamsNotifyOnSuccess?: boolean; + teamsNotifyOnFailure?: boolean; +} + +// ── Defaults (mirrors C# TenantConfiguration defaults) ────────────────────── + +const DEFAULTS: Record = { + disabled: false, + rateLimitRequestsPerMinute: 100, + customRateLimitRequestsPerMinute: null, + manufacturerWhitelist: 'Dell*,HP*,Lenovo*,Microsoft Corporation', + modelWhitelist: '*', + validateAutopilotDevice: false, + validateCorporateIdentifier: false, + allowInsecureAgentRequests: false, + dataRetentionDays: 90, + sessionTimeoutHours: 5, + maxNdjsonPayloadSizeMB: 5, + enablePerformanceCollector: true, + performanceCollectorIntervalSeconds: 30, + helloWaitTimeoutSeconds: 30, + maxAuthFailures: null, + authFailureTimeoutMinutes: null, + agentMaxLifetimeMinutes: null, + selfDestructOnComplete: true, + keepLogFile: false, + rebootOnComplete: null, + rebootDelaySeconds: null, + enableGeoLocation: null, + enableImeMatchLog: null, + logLevel: null, + maxBatchSize: null, + showEnrollmentSummary: null, + enrollmentSummaryTimeoutSeconds: null, + enrollmentSummaryBrandingImageUrl: null, + enrollmentSummaryLaunchRetrySeconds: null, + showScriptOutput: true, + sendTraceEvents: true, + enableLocalAdminAnalyzer: null, + enableSoftwareInventoryAnalyzer: null, + localAdminAllowedAccountsJson: null, + bootstrapTokenEnabled: false, + unrestrictedModeEnabled: false, + unrestrictedMode: false, + diagnosticsBlobSasUrl: null, + diagnosticsUploadMode: 'Off', + diagnosticsLogPathsJson: null, + webhookProviderType: 0, + webhookUrl: null, + webhookNotifyOnSuccess: true, + webhookNotifyOnFailure: true, + teamsWebhookUrl: null, + teamsNotifyOnSuccess: true, + teamsNotifyOnFailure: true, +}; + +// Runtime defaults (what the agent actually receives if tenant didn't set a value) +const RUNTIME_DEFAULTS: Record = { + uploadIntervalSeconds: 30, + selfDestructOnComplete: true, + keepLogFile: false, + enableGeoLocation: true, + enableImeMatchLog: false, + maxAuthFailures: 5, + authFailureTimeoutMinutes: 0, + logLevel: 'Info', + rebootOnComplete: false, + rebootDelaySeconds: 10, + showEnrollmentSummary: false, + enrollmentSummaryTimeoutSeconds: 60, + enrollmentSummaryLaunchRetrySeconds: 120, + maxBatchSize: 100, + diagnosticsUploadMode: 'Off', + sendTraceEvents: true, + unrestrictedMode: false, + // Collectors + enablePerformanceCollector: true, + performanceIntervalSeconds: 30, + collectorIdleTimeoutMinutes: 15, + enableAgentSelfMetrics: true, + agentSelfMetricsIntervalSeconds: 60, + helloWaitTimeoutSeconds: 30, + agentMaxLifetimeMinutes: 360, + // Analyzers + enableLocalAdminAnalyzer: true, + enableSoftwareInventoryAnalyzer: false, +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const WEBHOOK_PROVIDERS: Record = { + 0: 'None', + 1: 'Teams (Legacy Connector)', + 2: 'Teams (Workflow Webhook)', + 10: 'Slack', +}; + +function formatValue(val: unknown): string { + if (val === null || val === undefined) return '—'; + if (typeof val === 'boolean') return val ? 'Yes' : 'No'; + if (typeof val === 'string') return val || '—'; + return String(val); +} + +function isNonDefault(key: string, val: unknown, defaults: Record): boolean { + if (!(key in defaults)) return false; + const def = defaults[key]; + // Both null/undefined → same + if ((val === null || val === undefined) && (def === null || def === undefined)) return false; + return val !== def; +} + +function maskSasUrl(url: string | null | undefined): string { + if (!url) return '—'; + try { + const u = new URL(url); + return `${u.origin}${u.pathname}?***`; + } catch { + return url.length > 40 ? url.substring(0, 40) + '...' : url; + } +} + +function formatDate(val: string | null | undefined): string { + if (!val) return '—'; + try { + return new Date(val).toLocaleString(); + } catch { + return val; + } +} + +// ── Section component ──────────────────────────────────────────────────────── + +interface ConfigRowProps { + label: string; + value: unknown; + configKey?: string; + defaults?: Record; + masked?: boolean; + isDate?: boolean; +} + +function ConfigRow({ label, value, configKey, defaults, masked, isDate }: ConfigRowProps) { + const highlight = configKey && defaults ? isNonDefault(configKey, value, defaults) : false; + const displayVal = masked + ? maskSasUrl(value as string) + : isDate + ? formatDate(value as string) + : formatValue(value); + + return ( + + {label} + + {displayVal} + {highlight && (custom)} + + + ); +} + +interface SectionProps { + title: string; + children: React.ReactNode; +} + +function Section({ title, children }: SectionProps) { + return ( +
+

{title}

+
+ + {children} +
+
+
+ ); +} + +// ── Compute runtime parameters ─────────────────────────────────────────────── + +function computeRuntime(c: TenantConfig): Record { + return { + uploadIntervalSeconds: 30, + selfDestructOnComplete: c.selfDestructOnComplete ?? true, + keepLogFile: c.keepLogFile ?? false, + enableGeoLocation: c.enableGeoLocation ?? true, + enableImeMatchLog: c.enableImeMatchLog ?? false, + maxAuthFailures: c.maxAuthFailures ?? 5, + authFailureTimeoutMinutes: c.authFailureTimeoutMinutes ?? 0, + logLevel: c.logLevel ?? 'Info', + rebootOnComplete: c.rebootOnComplete ?? false, + rebootDelaySeconds: c.rebootDelaySeconds ?? 10, + showEnrollmentSummary: c.showEnrollmentSummary ?? false, + enrollmentSummaryTimeoutSeconds: c.enrollmentSummaryTimeoutSeconds ?? 60, + enrollmentSummaryBrandingImageUrl: c.enrollmentSummaryBrandingImageUrl ?? null, + enrollmentSummaryLaunchRetrySeconds: c.enrollmentSummaryLaunchRetrySeconds ?? 120, + maxBatchSize: c.maxBatchSize ?? 100, + diagnosticsUploadEnabled: !!(c.diagnosticsBlobSasUrl && c.diagnosticsUploadMode && c.diagnosticsUploadMode !== 'Off'), + diagnosticsUploadMode: c.diagnosticsUploadMode ?? 'Off', + sendTraceEvents: c.sendTraceEvents ?? true, + unrestrictedMode: c.unrestrictedMode ?? false, + // Collectors + enablePerformanceCollector: c.enablePerformanceCollector ?? true, + performanceIntervalSeconds: c.performanceCollectorIntervalSeconds ?? 30, + collectorIdleTimeoutMinutes: 15, + enableAgentSelfMetrics: true, + agentSelfMetricsIntervalSeconds: 60, + helloWaitTimeoutSeconds: c.helloWaitTimeoutSeconds ?? 30, + agentMaxLifetimeMinutes: c.agentMaxLifetimeMinutes ?? 360, + // Analyzers + enableLocalAdminAnalyzer: c.enableLocalAdminAnalyzer ?? true, + enableSoftwareInventoryAnalyzer: c.enableSoftwareInventoryAnalyzer ?? false, + }; +} + +// ── Main page ──────────────────────────────────────────────────────────────── + +function TenantConfigReportContent() { + const { user, getAccessToken } = useAuth(); + + useEffect(() => { trackEvent('tenant_config_report_viewed'); }, []); + + const [tenants, setTenants] = useState([]); + const [selectedTenantId, setSelectedTenantId] = useState(''); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingTenants, setLoadingTenants] = useState(true); + const [error, setError] = useState(null); + + // Fetch tenant list + useEffect(() => { + if (!user?.isGalacticAdmin) return; + const fetchTenants = async () => { + try { + setLoadingTenants(true); + const response = await authenticatedFetch(`${API_BASE_URL}/api/config/all`, getAccessToken); + if (response.ok) { + const data = await response.json(); + const mapped: TenantInfo[] = data.map((t: { tenantId: string; domainName: string }) => ({ + tenantId: t.tenantId, + domainName: t.domainName || '', + })); + mapped.sort((a, b) => (a.domainName || a.tenantId).localeCompare(b.domainName || b.tenantId)); + setTenants(mapped); + if (mapped.length > 0 && !selectedTenantId) { + setSelectedTenantId(mapped[0].tenantId); + } + } + } catch (err) { + if (err instanceof TokenExpiredError) { + setError('Session expired. Please refresh.'); + } else { + console.error('Error fetching tenant list:', err); + } + } finally { + setLoadingTenants(false); + } + }; + fetchTenants(); + }, [user?.isGalacticAdmin, getAccessToken]); + + // Fetch config for selected tenant + const fetchConfig = useCallback(async () => { + if (!selectedTenantId) return; + try { + setLoading(true); + setError(null); + const response = await authenticatedFetch( + `${API_BASE_URL}/api/config/${selectedTenantId}`, + getAccessToken, + ); + if (!response.ok) { + throw new Error(`Failed to load config: ${response.status} ${response.statusText}`); + } + const data: TenantConfig = await response.json(); + setConfig(data); + } catch (err) { + if (err instanceof TokenExpiredError) { + setError('Session expired. Please refresh.'); + } else { + setError((err as Error).message); + } + setConfig(null); + } finally { + setLoading(false); + } + }, [selectedTenantId, getAccessToken]); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + // Computed runtime + const runtime = config ? computeRuntime(config) : null; + + // Webhook display helper + const webhookDisplay = config + ? (() => { + if (config.webhookUrl && config.webhookProviderType) { + return { url: config.webhookUrl, provider: config.webhookProviderType, onSuccess: config.webhookNotifyOnSuccess, onFailure: config.webhookNotifyOnFailure }; + } + if (config.teamsWebhookUrl) { + return { url: config.teamsWebhookUrl, provider: 1, onSuccess: config.teamsNotifyOnSuccess, onFailure: config.teamsNotifyOnFailure }; + } + return { url: null, provider: 0, onSuccess: true, onFailure: true }; + })() + : null; + + const selectedTenant = tenants.find((t) => t.tenantId === selectedTenantId); + + return ( +
+ {/* Header */} +
+
+

Tenant Config Report

+

+ Read-only overview of all tenant configuration and runtime parameters +

+
+
+ + + +
+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Loading */} + {loading && ( +
+ + + + + Loading configuration... +
+ )} + + {/* Config report */} + {!loading && config && ( + <> + {/* Tenant header card */} +
+
+
+ Domain + {config.domainName || '—'} +
+
+ Tenant ID + {config.tenantId} +
+
+ Last Updated + {formatDate(config.lastUpdated)} +
+
+ Updated By + {config.updatedBy || '—'} +
+
+ {config.disabled && ( +
+ TENANT DISABLED{config.disabledReason ? `: ${config.disabledReason}` : ''} + {config.disabledUntil ? ` (until ${formatDate(config.disabledUntil)})` : ''} +
+ )} +
+ + {/* Legend */} +
+ + Purple rows = non-default value (custom) +
+ +
+ {/* ────── LEFT: Tenant Configuration ────── */} +
+

Tenant Configuration

+ +
+ + + + +
+ +
+ + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + + + + + + + + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + + +
+ +
+ + + +
+ +
+ + + +
+
+ + {/* ────── RIGHT: Runtime Parameters (Agent Config) ────── */} +
+

Runtime Parameters (Agent)

+

+ These are the effective values the agent receives at runtime, with defaults applied for unset fields. +

+ + {runtime && ( + <> +
+ + +
+ +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + + +
+ +
+ + + + + + + +
+ +
+ + +
+ +
+ + +
+ + )} +
+
+ + )} + + {/* Empty state */} + {!loading && !error && !config && ( +
+ Select a tenant to view its configuration report. +
+ )} +
+ ); +} + +export default function TenantConfigReportPage() { + return ( + + + + ); +} diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index d462ffce..3a35ecf7 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -40,6 +40,14 @@ function MonitorIcon({ className = "w-5 h-5" }: { className?: string }) { ); } +function DocumentMagnifyingGlassIcon({ className = "w-5 h-5" }: { className?: string }) { + return ( + + + + ); +} + function ClipboardDocumentIcon({ className = "w-5 h-5" }: { className?: string }) { return ( @@ -113,6 +121,7 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { id: "platform-usage-metrics", label: "Platform Usage", href: "/platform-usage-metrics", icon: }, { id: "platform-metrics", label: "Platform Metrics", href: "/platform-metrics", icon: }, + { id: "tenant-config-report", label: "Tenant Config", href: "/tenant-config-report", icon: }, ], }, ]; diff --git a/tasks/todo.md b/tasks/todo.md index e69de29b..5385930b 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -0,0 +1,16 @@ +# Tenant Config Report (Galactic Admin) + +## Goal +New galactic-admin-only page that shows a read-only report of a tenant's full configuration, +including admin-set parameters and computed runtime (agent) parameters. + +## Plan +- [x] Write plan +- [ ] Create `/app/tenant-config-report/page.tsx` — galactic admin only, read-only report + - Tenant selector (reuse pattern from gather-rules page) + - Fetch config via `GET /api/config/{tenantId}` (existing endpoint, GA cross-tenant) + - Display all TenantConfiguration fields grouped by section + - Show computed runtime parameters (AgentConfigResponse equivalents with defaults) + - Highlight non-default values for quick scanning +- [ ] Add nav item to galactic admin section in `globalNavConfig.tsx` +- [ ] Commit and push to feature branch