From e3991d55bec75449f62dcda1a32d7299fd01ead7 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 6 Sep 2026 21:08:03 +0200 Subject: [PATCH] web(admin): Tenant Config Report renders from an exhaustive catalog over the generated wire types (D-215) The report typed its own TenantConfig interface and drifted for five months: 31 stored fields were missing (payingCustomer, the MSP overrides, three validation gates, the SLA block, four analyzer flags, app-homing and trial bookkeeping) and three runtime values were wrong (upload interval 30 instead of 10, diagnostics-enabled without the Hosted destination, unrestricted mode without the entitlement). tenantConfigReportCatalog.ts is Record and Record: every key is rendered or excluded with a reason, so a new C# property fails tsc once the wire type is regenerated. Runtime rows carry the GetAgentConfigFunction rule and a source tag; entitlement values come from feature-flags, operator knobs from global/config. A vitest pins the same parity against SHARED_MANIFEST.tenantConfiguration.fields and the resolution rules (upload 10, Hosted destination, flags-driven unrestricted mode, admin idle timeout, analyzer defaults). Empty strings count as unset for the custom highlight. Co-Authored-By: Claude Fable 5.1 --- internal | 2 +- .../sections/SectionTenantConfigReport.tsx | 652 ++++++------------ .../tenantConfigReportCatalog.test.ts | 215 ++++++ .../sections/tenantConfigReportCatalog.ts | 420 +++++++++++ 4 files changed, 832 insertions(+), 457 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts create mode 100644 src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts diff --git a/internal b/internal index fea9a5c7..f80fd1b1 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit fea9a5c7e7952b4174a03fbcfbc661e16e9fbdaa +Subproject commit f80fd1b166ade087c2b40174f9e20d29e115b278 diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index e0bf6726..7752cbac 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -4,6 +4,20 @@ import { useEffect, useState, useCallback } from 'react'; import { useAuth } from '../../../../contexts/AuthContext'; import { api } from '@/lib/api'; import { apiErrorText, fetchJson } from "@/lib/apiClient"; +import type { + AdminConfiguration, + TenantConfiguration, + TenantFeatureFlagsResponse, +} from "@/utils/wire-types.generated"; +import { + isNonDefault, + RUNTIME_REPORT_SECTIONS, + TENANT_REPORT_SECTIONS, + UNRESOLVED, + type RowKind, + type RuntimeContext, + type RuntimeSource, +} from './tenantConfigReportCatalog'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -12,201 +26,6 @@ interface TenantInfo { domainName: string; } -/** Full tenant configuration as returned by GET /api/config/{tenantId}. */ -interface TenantConfig { - tenantId: string; - domainName?: string; - lastUpdated?: string; - updatedBy?: string; - onboardedAt?: string; - contactEmail?: string | null; - companyName?: string | null; - - // Tenant status - disabled?: boolean; - disabledReason?: string; - disabledUntil?: string; - mcpDisabled?: boolean; - mcpDisabledReason?: string; - - // Security - customRateLimitRequestsPerMinute?: number | null; - customUserRateLimitRequestsPerMinute?: 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; - enableTimezoneAutoSet?: boolean | null; - enableDoGroupIdAutoSet?: boolean | null; - ntpServer?: string | null; - enableImeMatchLog?: boolean | null; - enableGatherRuleDebugLog?: boolean | null; - enableEspContinueAnywayObservation?: 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; - - // Plan tier - planTier?: string; - trialExpiresUtc?: string | null; - trialConsumed?: boolean; - - // 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; - webhookNotifyOnStart?: boolean; - notificationChannelsJson?: string | null; - - // Teams notifications (legacy) - teamsWebhookUrl?: string | null; - teamsNotifyOnSuccess?: boolean; - teamsNotifyOnFailure?: boolean; - teamsNotifyOnStart?: boolean; -} - -// ── Defaults (mirrors C# TenantConfiguration defaults) ────────────────────── - -const DEFAULTS: Record = { - disabled: false, - mcpDisabled: false, - customRateLimitRequestsPerMinute: null, - customUserRateLimitRequestsPerMinute: 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, - enableTimezoneAutoSet: null, - enableDoGroupIdAutoSet: null, - ntpServer: 'time.windows.com', - enableImeMatchLog: null, - enableGatherRuleDebugLog: null, - enableEspContinueAnywayObservation: null, - logLevel: null, - maxBatchSize: null, - showEnrollmentSummary: null, - enrollmentSummaryTimeoutSeconds: null, - enrollmentSummaryBrandingImageUrl: null, - enrollmentSummaryLaunchRetrySeconds: null, - showScriptOutput: true, - sendTraceEvents: true, - enableLocalAdminAnalyzer: null, - enableSoftwareInventoryAnalyzer: null, - localAdminAllowedAccountsJson: null, - planTier: 'free', - bootstrapTokenEnabled: false, - unrestrictedModeEnabled: false, - unrestrictedMode: false, - diagnosticsBlobSasUrl: null, - diagnosticsUploadMode: 'Off', - diagnosticsLogPathsJson: null, - webhookProviderType: 0, - webhookUrl: null, - webhookNotifyOnSuccess: true, - webhookNotifyOnFailure: true, - webhookNotifyOnStart: false, - teamsWebhookUrl: null, - teamsNotifyOnSuccess: true, - teamsNotifyOnFailure: true, - teamsNotifyOnStart: false, -}; - -// 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, - enableTimezoneAutoSet: false, - enableDoGroupIdAutoSet: false, - ntpServer: 'time.windows.com', - enableImeMatchLog: false, - enableGatherRuleDebugLog: false, - enableEspContinueAnywayObservation: 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 = { @@ -218,21 +37,22 @@ const WEBHOOK_PROVIDERS: Record = { 30: 'Discord', }; +const SOURCE_LABELS: Record = { + tenant: 'tenant', + admin: 'global', + flags: 'entitlement', + constant: 'fixed', +}; + function formatValue(val: unknown): string { if (val === null || val === undefined) return '—'; + if (val === UNRESOLVED) return 'n/a (source not loaded)'; if (typeof val === 'boolean') return val ? 'Yes' : 'No'; if (typeof val === 'string') return val || '—'; + if (Array.isArray(val)) return val.length ? val.join(', ') : '—'; 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 { @@ -252,30 +72,37 @@ function formatDate(val: string | null | undefined): string { } } -// ── Section component ──────────────────────────────────────────────────────── +function formatByKind(kind: RowKind | undefined, value: unknown): string { + switch (kind) { + case 'date': + return formatDate(value as string); + case 'masked': + return maskSasUrl(value as string); + case 'secret': + return value ? 'configured (hidden)' : '—'; + default: + return formatValue(value); + } +} + +// ── Row and section primitives ─────────────────────────────────────────────── interface ConfigRowProps { label: string; - value: unknown; - configKey?: string; - defaults?: Record; - masked?: boolean; - isDate?: boolean; + display: string; + highlight?: boolean; + sourceTag?: string; } -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); - +function ConfigRow({ label, display, highlight, sourceTag }: ConfigRowProps) { return ( - {label} + + {label} + {sourceTag && {sourceTag}} + - {displayVal} + {display} {highlight && (custom)} @@ -300,46 +127,63 @@ function Section({ title, children }: SectionProps) { ); } -// ── Compute runtime parameters ─────────────────────────────────────────────── +// ── Notification channels (the one non-scalar block) ───────────────────────── -function computeRuntime(c: TenantConfig): Record { - return { - uploadIntervalSeconds: 30, - selfDestructOnComplete: c.selfDestructOnComplete ?? true, - keepLogFile: c.keepLogFile ?? false, - enableGeoLocation: c.enableGeoLocation ?? true, - enableTimezoneAutoSet: c.enableTimezoneAutoSet ?? false, - enableDoGroupIdAutoSet: c.enableDoGroupIdAutoSet ?? false, - ntpServer: c.ntpServer ?? 'time.windows.com', - enableImeMatchLog: c.enableImeMatchLog ?? false, - enableGatherRuleDebugLog: c.enableGatherRuleDebugLog ?? false, - enableEspContinueAnywayObservation: c.enableEspContinueAnywayObservation ?? 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, - }; +interface ChannelView { + id: string; + name?: string; + providerType?: number; + url?: string; + enabled?: boolean; + notifyOnStart?: boolean; + notifyOnSuccess?: boolean; + notifyOnFailure?: boolean; + notifyOnSlaEvents?: boolean; +} + +/** Channel list when migrated, else the legacy single-webhook fields — mirrors TenantConfiguration.GetNotificationChannels. */ +function resolveChannels(config: TenantConfiguration): ChannelView[] { + if (config.notificationChannelsJson) { + try { + const parsed = JSON.parse(config.notificationChannelsJson); + if (Array.isArray(parsed) && parsed.length > 0) return parsed as ChannelView[]; + } catch { /* malformed → fall back to legacy display */ } + } + if (config.webhookUrl && config.webhookProviderType) { + return [{ id: 'legacy', name: 'Default (legacy)', providerType: config.webhookProviderType, url: config.webhookUrl, enabled: true, notifyOnStart: config.webhookNotifyOnStart, notifyOnSuccess: config.webhookNotifyOnSuccess, notifyOnFailure: config.webhookNotifyOnFailure, notifyOnSlaEvents: true }]; + } + if (config.teamsWebhookUrl) { + return [{ id: 'legacy', name: 'Default (legacy)', providerType: 1, url: config.teamsWebhookUrl, enabled: true, notifyOnStart: config.teamsNotifyOnStart, notifyOnSuccess: config.teamsNotifyOnSuccess, notifyOnFailure: config.teamsNotifyOnFailure, notifyOnSlaEvents: true }]; + } + return []; +} + +function ChannelRows({ config }: { config: TenantConfiguration }) { + const channels = resolveChannels(config); + if (channels.length === 0) { + return ; + } + return ( + <> + {channels.map((ch, i) => ( + 0 ? 'border-t-2 border-gray-200 dark:border-gray-600' : ''}> + + + + + + + + + + + +
+ + + ))} + + ); } // ── Main component ─────────────────────────────────────────────────────────── @@ -349,13 +193,13 @@ export function SectionTenantConfigReport() { const [tenants, setTenants] = useState([]); const [selectedTenantId, setSelectedTenantId] = useState(''); - // Mount-time clock for trial-expiry checks — render must stay pure - // (react-hooks/purity); day-granularity expiry doesn't need a live clock. - const [nowMs] = useState(() => Date.now()); - const [config, setConfig] = useState(null); + const [config, setConfig] = useState(null); + const [flags, setFlags] = useState(null); + const [adminConfig, setAdminConfig] = useState(null); const [loading, setLoading] = useState(false); const [loadingTenants, setLoadingTenants] = useState(true); const [error, setError] = useState(null); + const [sideError, setSideError] = useState(null); // Fetch tenant list useEffect(() => { @@ -379,20 +223,34 @@ export function SectionTenantConfigReport() { fetchTenants(); }, [user?.isGlobalAdmin, getAccessToken]); - // Fetch config for selected tenant + // Fetch config plus its two runtime sources for the selected tenant. The full config is + // the report; feature-flags (entitlement) and global config (operator knobs) are fail-soft — + // their rows show "n/a" and a notice instead of blocking the whole report. const fetchConfig = useCallback(async () => { if (!selectedTenantId) return; try { setLoading(true); setError(null); - const data = await fetchJson( - api.config.tenant(selectedTenantId), - getAccessToken, - ); - setConfig(data); + setSideError(null); + const [configResult, flagsResult, adminResult] = await Promise.allSettled([ + fetchJson(api.config.tenant(selectedTenantId), getAccessToken), + fetchJson(api.config.featureFlags(selectedTenantId), getAccessToken), + fetchJson(api.globalConfig.get(), getAccessToken), + ]); + if (configResult.status === 'rejected') throw configResult.reason; + setConfig(configResult.value); + setFlags(flagsResult.status === 'fulfilled' ? flagsResult.value : null); + setAdminConfig(adminResult.status === 'fulfilled' ? adminResult.value : null); + const sideFailures = [ + flagsResult.status === 'rejected' ? `feature flags: ${apiErrorText(flagsResult.reason)}` : null, + adminResult.status === 'rejected' ? `global config: ${apiErrorText(adminResult.reason)}` : null, + ].filter((s): s is string => s !== null); + setSideError(sideFailures.length ? `Runtime sources unavailable — ${sideFailures.join('; ')}` : null); } catch (err) { setError(apiErrorText(err)); setConfig(null); + setFlags(null); + setAdminConfig(null); } finally { setLoading(false); } @@ -405,30 +263,7 @@ export function SectionTenantConfigReport() { void run(); }, [fetchConfig]); - // Computed runtime - const runtime = config ? computeRuntime(config) : null; - - // Webhook display helper: channel list when migrated, else legacy single-webhook fields - // (mirrors the backend's GetNotificationChannels fallback order). - const webhookChannels = config - ? (() => { - if (config.notificationChannelsJson) { - try { - const parsed = JSON.parse(config.notificationChannelsJson); - if (Array.isArray(parsed) && parsed.length > 0) { - return parsed as { id: string; name?: string; providerType?: number; url?: string; enabled?: boolean; notifyOnStart?: boolean; notifyOnSuccess?: boolean; notifyOnFailure?: boolean; notifyOnSlaEvents?: boolean }[]; - } - } catch { /* malformed → fall back to legacy display */ } - } - if (config.webhookUrl && config.webhookProviderType) { - return [{ id: 'legacy', name: 'Default (legacy)', providerType: config.webhookProviderType, url: config.webhookUrl, enabled: true, notifyOnStart: config.webhookNotifyOnStart, notifyOnSuccess: config.webhookNotifyOnSuccess, notifyOnFailure: config.webhookNotifyOnFailure, notifyOnSlaEvents: true }]; - } - if (config.teamsWebhookUrl) { - return [{ id: 'legacy', name: 'Default (legacy)', providerType: 1, url: config.teamsWebhookUrl, enabled: true, notifyOnStart: config.teamsNotifyOnStart, notifyOnSuccess: config.teamsNotifyOnSuccess, notifyOnFailure: config.teamsNotifyOnFailure, notifyOnSlaEvents: true }]; - } - return []; - })() - : []; + const runtimeCtx: RuntimeContext | null = config ? { config, adminConfig, flags } : null; return (
@@ -487,7 +322,7 @@ export function SectionTenantConfigReport() { )} {/* Config report */} - {!loading && config && ( + {!loading && config && runtimeCtx && ( <> {/* Tenant header card */}
@@ -509,6 +344,34 @@ export function SectionTenantConfigReport() { {config.updatedBy || '—'}
+ {/* Effective entitlement — backend-resolved (feature-flags), not the stored plan tier */} + {flags && ( +
+
+ Effective Edition + + {flags.edition} + via {flags.editionSource} + {flags.isTrial && flags.trialExpiresUtc ? ` (trial until ${formatDate(flags.trialExpiresUtc)})` : ''} + +
+
+ MCP Usage Plan + {flags.entitlements.mcpUsagePlan || '—'} +
+
+ Delegated Tenant Slots + + {flags.entitlements.maxDelegatedTenants} + {!flags.entitlements.delegatedAdminAllowed && (delegation not included)} + +
+
+ Retention Cap (days) + {flags.entitlements.retentionCapDays} +
+
+ )} {config.disabled && (
TENANT DISABLED{config.disabledReason ? `: ${config.disabledReason}` : ''} @@ -522,6 +385,12 @@ export function SectionTenantConfigReport() { )}
+ {sideError && ( +
+ {sideError} +
+ )} + {/* Legend */}
@@ -533,177 +402,48 @@ export function SectionTenantConfigReport() {

Tenant Configuration

-
- - - - - - - nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`} - configKey="planTier" - defaults={DEFAULTS} - /> -
- -
- - - - - -
- -
- - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - - - - - - - - - - - - - -
- -
- - - - -
- -
- - - -
- -
- {webhookChannels.length === 0 && ( - - )} - {webhookChannels.map((ch, i) => ( -
0 ? 'mt-2 pt-2 border-t border-gray-100 dark:border-gray-700' : ''}> - - - - - - - -
- ))} -
- -
- - - -
- -
- - - -
+ {TENANT_REPORT_SECTIONS.map(({ section, rows }) => ( +
+ {section === 'Webhooks' && } + {rows.map(({ key, row }) => { + const value = config[key as keyof TenantConfiguration]; + return ( + + ); + })} +
+ ))}
{/* ────── RIGHT: Runtime Parameters (Agent Config) ────── */}

Runtime Parameters (Agent)

- These are the effective values the agent receives at runtime, with defaults applied for unset fields. + The effective values the agent receives at runtime, with defaults applied for unset fields. + The tag says where a value is resolved from: tenant config, global config, the tenant's entitlement, or a fixed backend constant.

- {runtime && ( - <> -
- - -
- -
- - - - - - - - - - - - - - -
- -
- - -
- -
- - - - -
- -
- - - - - - - -
- -
- - -
- -
- - -
- - )} + {RUNTIME_REPORT_SECTIONS.map(({ section, rows }) => ( +
+ {rows.map(({ key, row }) => { + const value = row.value(runtimeCtx); + return ( + + ); + })} +
+ ))}
diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts new file mode 100644 index 00000000..e3692524 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/__tests__/tenantConfigReportCatalog.test.ts @@ -0,0 +1,215 @@ +/** + * Parity pins for the Tenant Config Report catalog. + * + * The catalog is compile-time exhaustive over the generated wire types; this file adds the + * runtime half against SHARED_MANIFEST.tenantConfiguration.fields (the C#-reflected field + * list, independent of the TS type) and pins the runtime resolution rules that mirror + * GetAgentConfigFunction.cs with test-local fixtures. + */ +import { describe, expect, it } from "vitest"; +import { SHARED_MANIFEST } from "@/utils/shared-manifests.generated"; +import type { AdminConfiguration, TenantConfiguration, TenantFeatureFlagsResponse } from "@/utils/wire-types.generated"; +import { + ANALYZER_FIELDS, + COLLECTOR_FIELDS, + DEFAULT_UPLOAD_INTERVAL_SECONDS, + isExcluded, + isNonDefault, + RUNTIME_FIELDS, + RUNTIME_REPORT_SECTIONS, + TENANT_FIELDS, + TENANT_REPORT_SECTIONS, + UNRESOLVED, + type RuntimeContext, +} from "../tenantConfigReportCatalog"; + +const manifestFields = [...SHARED_MANIFEST.tenantConfiguration.fields] as string[]; + +describe("TENANT_FIELDS parity with the C# field manifest", () => { + it("catalogues every wire field of TenantConfiguration (rendered or excluded with a reason)", () => { + const missing = manifestFields.filter((f) => !(f in TENANT_FIELDS)); + expect(missing).toEqual([]); + }); + + it("has no key outside the manifest (a removed C# property must leave the catalog)", () => { + const stale = Object.keys(TENANT_FIELDS).filter((k) => !manifestFields.includes(k)); + expect(stale).toEqual([]); + }); + + it("gives every exclusion a reason and every row a non-empty label", () => { + for (const [key, spec] of Object.entries(TENANT_FIELDS)) { + if (isExcluded(spec)) expect(spec.excluded, key).not.toBe(""); + else expect(spec.label, key).not.toBe(""); + } + }); + + it("renders the fields the operator asked for by name", () => { + const rendered = new Set(TENANT_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key))); + for (const key of [ + "payingCustomer", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", "managedByProTenantId", + "trialConsumed", "proDowngradedUtc", "validateDeviceAssociation", "validateCloudPcDevice", + "validateIntuneDeviceBinding", "sessionGraceHours", "absoluteMaxSessionHours", + "diagnosticsUploadDestination", "webhookNotifyOnHardwareRejection", "entraAppRolesEnabled", + "slaTargetSuccessRate", "slaConsecutiveFailureThreshold", "keepAwakeDuringUserEsp", + ]) { + expect(rendered.has(key), key).toBe(true); + } + }); + + it("renders each field in exactly one section", () => { + const seen = new Map(); + for (const { rows } of TENANT_REPORT_SECTIONS) { + for (const { key } of rows) seen.set(key, (seen.get(key) ?? 0) + 1); + } + expect([...seen.entries()].filter(([, n]) => n !== 1)).toEqual([]); + }); +}); + +// ── Runtime rules ──────────────────────────────────────────────────────────── + +/** Minimal stored config: everything nullable unset, non-nullables at their C# initializers. */ +function storedConfig(overrides: Partial = {}): TenantConfiguration { + return { + tenantId: "00000000-0000-0000-0000-000000000001", + domainName: "contoso.example", + lastUpdated: "2026-09-06T00:00:00Z", + updatedBy: "ga@operator.example", + disabled: false, + mcpDisabled: false, + planTier: "free", + trialConsumed: false, + payingCustomer: false, + manufacturerWhitelist: "Dell*,HP*,Lenovo*,Microsoft Corporation", + modelWhitelist: "*", + validateAutopilotDevice: false, + validateCorporateIdentifier: false, + validateDeviceAssociation: false, + validateCloudPcDevice: false, + validateIntuneDeviceBinding: false, + allowInsecureAgentRequests: false, + dataRetentionDays: 90, + sessionTimeoutHours: 5, + sessionGraceHours: 0, + maxNdjsonPayloadSizeMB: 5, + enablePerformanceCollector: true, + performanceCollectorIntervalSeconds: 30, + helloWaitTimeoutSeconds: 30, + ntpServer: "time.windows.com", + logLevel: "", + enrollmentSummaryBrandingImageUrl: "", + localAdminAllowedAccountsJson: "", + bootstrapTokenEnabled: false, + unrestrictedModeEnabled: false, + unrestrictedMode: false, + entraAppRolesEnabled: false, + diagnosticsLogPathsJson: "", + diagnosticsBlobSasUrl: "", + diagnosticsUploadMode: "Off", + diagnosticsUploadDestination: "CustomerSas", + sendTraceEvents: true, + teamsWebhookUrl: "", + teamsNotifyOnSuccess: true, + teamsNotifyOnFailure: true, + teamsNotifyOnStart: false, + webhookProviderType: 0, + webhookUrl: "", + webhookNotifyOnSuccess: true, + webhookNotifyOnFailure: true, + webhookNotifyOnHardwareRejection: false, + webhookNotifyOnStart: false, + webhookCustomHeadersJson: "", + notificationChannelsJson: "", + slaNotifyOnSuccessRateBreach: false, + slaNotifyOnDurationBreach: false, + slaNotifyOnAppInstallBreach: false, + slaNotifyOnConsecutiveFailures: false, + slaConsecutiveFailureThreshold: 5, + ...overrides, + }; +} + +const adminConfig = { collectorIdleTimeoutMinutes: 25, desktopDetectorNoCandidateTimeoutMinutes: 7, allowAgentDowngrade: true, modernDeploymentHarmlessEventIdsJson: "[100, 42]" } as AdminConfiguration; +const flags = (unrestrictedMode: boolean) => ({ unrestrictedMode }) as TenantFeatureFlagsResponse; + +const ctx = (overrides: Partial = {}, extra: Partial = {}): RuntimeContext => ({ + config: storedConfig(overrides), + adminConfig: null, + flags: null, + ...extra, +}); + +const runtimeValue = (record: typeof RUNTIME_FIELDS | typeof COLLECTOR_FIELDS | typeof ANALYZER_FIELDS, key: string, c: RuntimeContext) => { + const spec = (record as Record)[key]; + if (!spec || isExcluded(spec)) throw new Error(`${key} is not a rendered runtime row`); + return spec.value(c); +}; + +describe("runtime rows mirror GetAgentConfigFunction", () => { + it("upload interval is the backend constant (10), not the old 30", () => { + expect(DEFAULT_UPLOAD_INTERVAL_SECONDS).toBe(10); + expect(runtimeValue(RUNTIME_FIELDS, "uploadIntervalSeconds", ctx())).toBe(10); + }); + + it("diagnostics upload is enabled by a SAS URL or the hosted destination, regardless of mode", () => { + expect(runtimeValue(RUNTIME_FIELDS, "diagnosticsUploadEnabled", ctx())).toBe(false); + expect(runtimeValue(RUNTIME_FIELDS, "diagnosticsUploadEnabled", ctx({ diagnosticsUploadDestination: "Hosted" }))).toBe(true); + expect(runtimeValue(RUNTIME_FIELDS, "diagnosticsUploadEnabled", ctx({ diagnosticsBlobSasUrl: "https://x.blob.core.windows.net/c?sig=1", diagnosticsUploadMode: "Off" }))).toBe(true); + }); + + it("unrestricted mode comes from the entitlement flags, never from the stored toggle alone", () => { + expect(runtimeValue(RUNTIME_FIELDS, "unrestrictedMode", ctx({ unrestrictedModeEnabled: true, unrestrictedMode: true }))).toBe(UNRESOLVED); + expect(runtimeValue(RUNTIME_FIELDS, "unrestrictedMode", ctx({ unrestrictedModeEnabled: true, unrestrictedMode: true }, { flags: flags(false) }))).toBe(false); + expect(runtimeValue(RUNTIME_FIELDS, "unrestrictedMode", ctx({}, { flags: flags(true) }))).toBe(true); + }); + + it("operator knobs resolve from the global config and are unresolved without it", () => { + expect(runtimeValue(COLLECTOR_FIELDS, "collectorIdleTimeoutMinutes", ctx())).toBe(UNRESOLVED); + const c = ctx({}, { adminConfig }); + expect(runtimeValue(COLLECTOR_FIELDS, "collectorIdleTimeoutMinutes", c)).toBe(25); + expect(runtimeValue(COLLECTOR_FIELDS, "desktopDetectorNoCandidateTimeoutMinutes", c)).toBe(7); + expect(runtimeValue(COLLECTOR_FIELDS, "modernDeploymentHarmlessEventIds", c)).toEqual([100, 42]); + expect(runtimeValue(RUNTIME_FIELDS, "allowAgentDowngrade", c)).toBe(true); + }); + + it("applies the analyzer defaults of the agent config builder", () => { + const c = ctx(); + expect(runtimeValue(ANALYZER_FIELDS, "enableLocalAdminAnalyzer", c)).toBe(true); + expect(runtimeValue(ANALYZER_FIELDS, "enableIntegrityBypassAnalyzer", c)).toBe(true); + expect(runtimeValue(ANALYZER_FIELDS, "enableConsoleBypassDetection", c)).toBe(true); + expect(runtimeValue(ANALYZER_FIELDS, "enableSoftwareInventoryAnalyzer", c)).toBe(false); + expect(runtimeValue(ANALYZER_FIELDS, "enableRealmJoinWatcher", c)).toBe(false); + expect(runtimeValue(ANALYZER_FIELDS, "keepAwakeDuringUserEsp", c)).toBe(false); + expect(runtimeValue(ANALYZER_FIELDS, "localAdminAllowedAccounts", ctx({ localAdminAllowedAccountsJson: '["Admin","Helpdesk"]' }))).toEqual(["Admin", "Helpdesk"]); + expect(runtimeValue(ANALYZER_FIELDS, "localAdminAllowedAccounts", ctx({ localAdminAllowedAccountsJson: "not json" }))).toEqual([]); + }); + + it("nullable agent knobs fall back to the agent defaults", () => { + const c = ctx(); + expect(runtimeValue(RUNTIME_FIELDS, "maxAuthFailures", c)).toBe(5); + expect(runtimeValue(RUNTIME_FIELDS, "authFailureTimeoutMinutes", c)).toBe(0); + expect(runtimeValue(RUNTIME_FIELDS, "logLevel", ctx({ logLevel: null as unknown as string }))).toBe("Info"); + expect(runtimeValue(RUNTIME_FIELDS, "ntpServer", ctx({ ntpServer: "" }))).toBe("time.windows.com"); + expect(runtimeValue(COLLECTOR_FIELDS, "agentMaxLifetimeMinutes", c)).toBe(360); + expect(runtimeValue(RUNTIME_FIELDS, "rebootDelaySeconds", ctx({ rebootDelaySeconds: 30 }))).toBe(30); + }); + + it("renders every runtime row in exactly one section", () => { + const keys = RUNTIME_REPORT_SECTIONS.flatMap((s) => s.rows.map((r) => r.key)); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toContain("uploadIntervalSeconds"); + expect(keys).toContain("collectorIdleTimeoutMinutes"); + expect(keys).toContain("enableConsoleBypassDetection"); + }); +}); + +describe("isNonDefault", () => { + it("treats null and undefined as one value and compares arrays by content", () => { + expect(isNonDefault(undefined, null)).toBe(false); + expect(isNonDefault("", null)).toBe(false); + expect(isNonDefault("", "Off")).toBe(true); + expect(isNonDefault(null, false)).toBe(true); + expect(isNonDefault(false, false)).toBe(false); + expect(isNonDefault([100, 1005, 1010], [100, 1005, 1010])).toBe(false); + expect(isNonDefault([100, 42], [100, 1005, 1010])).toBe(true); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts new file mode 100644 index 00000000..337c0bea --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/tenantConfigReportCatalog.ts @@ -0,0 +1,420 @@ +/** + * Row catalog for the Tenant Config Report (admin/tenants/config-report). + * + * Both halves of the report are exhaustive over the generated wire types: `TENANT_FIELDS` + * over every key of `TenantConfiguration` (GET config/{tenantId}) and `RUNTIME_FIELDS` / + * `COLLECTOR_FIELDS` / `ANALYZER_FIELDS` over every key of `AgentConfigResponse` and its two + * sub-objects (what GET agent/config serves). A key is either rendered (a row with section + * and label) or excluded with a reason — nothing can be silently forgotten: a new C# property + * forces the wire-type regeneration (SharedManifestParityTests) and `tsc` then fails here + * until the key is catalogued. The vitest next to this module pins the same thing at runtime + * against `SHARED_MANIFEST.tenantConfiguration.fields`. + * + * Runtime rows mirror the resolution rules of GetAgentConfigFunction.cs (backend); every + * rule carries the source it reads from so the operator sees which knob to turn. + */ +import type { + AdminConfiguration, + AgentConfigResponse, + AnalyzerConfiguration, + CollectorConfiguration, + TenantConfiguration, + TenantFeatureFlagsResponse, +} from "@/utils/wire-types.generated"; + +// ── Row model ──────────────────────────────────────────────────────────────── + +/** How a value is rendered. `secret` never shows the value, only whether one is set. */ +export type RowKind = "value" | "date" | "masked" | "secret"; + +export interface TenantRow { + section: TenantSection; + label: string; + kind?: RowKind; + /** + * The C# initializer of TenantConfiguration.cs. A row WITH this key (even `null`) is + * compared against it and highlighted as "custom" when the stored value differs; a row + * without it is informational and never highlighted. + */ + default?: unknown; +} + +export interface Excluded { + excluded: string; +} + +export const isExcluded = (spec: T | Excluded): spec is Excluded => + "excluded" in spec; + +export const TENANT_SECTIONS = [ + "Tenant Status", + "Plan & Billing", + "Identity & App Homing", + "Security & Validation", + "Hardware Whitelist", + "Data Management", + "Agent Collectors", + "Auth Circuit Breaker", + "Agent Behavior", + "Enrollment Summary", + "Analyzers", + "Webhooks", + "SLA Targets", + "Diagnostics", + "Feature Flags (Global)", +] as const; +export type TenantSection = (typeof TENANT_SECTIONS)[number]; + +const NTP_DEFAULT = "time.windows.com"; + +// ── Tenant configuration (left column) ─────────────────────────────────────── + +const HEADER = { excluded: "shown in the tenant header card" } as const; +const CHANNEL_BLOCK = { excluded: "rendered by the notification channel block (legacy fallback order)" } as const; + +export const TENANT_FIELDS: Record = { + tenantId: HEADER, + domainName: HEADER, + lastUpdated: HEADER, + updatedBy: HEADER, + + // Tenant Status + disabled: { section: "Tenant Status", label: "Disabled", default: false }, + disabledReason: { section: "Tenant Status", label: "Disabled Reason" }, + disabledUntil: { section: "Tenant Status", label: "Disabled Until", kind: "date" }, + mcpDisabled: { section: "Tenant Status", label: "MCP Disabled", default: false }, + mcpDisabledReason: { section: "Tenant Status", label: "MCP Disabled Reason" }, + onboardedAt: { section: "Tenant Status", label: "Onboarded At", kind: "date" }, + onboardedBy: { section: "Tenant Status", label: "Onboarded By" }, + contactEmail: { section: "Tenant Status", label: "Contact Email" }, + companyName: { section: "Tenant Status", label: "Company" }, + + // Plan & Billing (stored values; the effective edition comes from feature-flags, header card) + planTier: { section: "Plan & Billing", label: "Plan Tier (stored)", default: "free" }, + trialExpiresUtc: { section: "Plan & Billing", label: "Trial Expires", kind: "date" }, + trialStartedUtc: { section: "Plan & Billing", label: "Trial Started", kind: "date" }, + trialGrantedBy: { section: "Plan & Billing", label: "Trial Granted By" }, + trialConsumed: { section: "Plan & Billing", label: "Trial Consumed", default: false }, + proDowngradedUtc: { section: "Plan & Billing", label: "Pro Downgraded At", kind: "date" }, + payingCustomer: { section: "Plan & Billing", label: "Paying Customer", default: false }, + maxDelegatedTenantsOverride: { section: "Plan & Billing", label: "Delegated Tenant Slots (override)", default: null }, + mcpUsagePlanOverride: { section: "Plan & Billing", label: "MCP Usage Plan (override)", default: null }, + managedByProTenantId: { section: "Plan & Billing", label: "Managed By Pro Tenant", default: null }, + + // Identity & App Homing + homedAppClientId: { section: "Identity & App Homing", label: "Homed App Client ID" }, + lastAuthClientId: { section: "Identity & App Homing", label: "Last Auth Client ID" }, + lastAuthClientIdSince: { section: "Identity & App Homing", label: "Last Auth Client ID Since", kind: "date" }, + entraAppRolesEnabled: { section: "Identity & App Homing", label: "Entra App Roles Enabled", default: false }, + + // Security & Validation + validateAutopilotDevice: { section: "Security & Validation", label: "Validate Autopilot Device", default: false }, + validateCorporateIdentifier: { section: "Security & Validation", label: "Validate Corporate Identifier", default: false }, + validateDeviceAssociation: { section: "Security & Validation", label: "Validate Device Association", default: false }, + validateCloudPcDevice: { section: "Security & Validation", label: "Validate Cloud PC Device", default: false }, + validateIntuneDeviceBinding: { section: "Security & Validation", label: "Validate Intune Device Binding", default: false }, + allowInsecureAgentRequests: { section: "Security & Validation", label: "Allow Insecure Agent Requests", default: false }, + customRateLimitRequestsPerMinute: { section: "Security & Validation", label: "Device API Rate Limit Override", default: null }, + customUserRateLimitRequestsPerMinute: { section: "Security & Validation", label: "MCP & Integrations API Rate Limit Override", default: null }, + + // Hardware Whitelist + manufacturerWhitelist: { section: "Hardware Whitelist", label: "Manufacturers", default: "Dell*,HP*,Lenovo*,Microsoft Corporation" }, + modelWhitelist: { section: "Hardware Whitelist", label: "Models", default: "*" }, + webhookNotifyOnHardwareRejection: { section: "Hardware Whitelist", label: "Notify On Hardware Rejection", default: false }, + + // Data Management + dataRetentionDays: { section: "Data Management", label: "Data Retention (days)", default: 90 }, + sessionTimeoutHours: { section: "Data Management", label: "Session Timeout (hours)", default: 5 }, + sessionGraceHours: { section: "Data Management", label: "Session Grace (hours)", default: 0 }, + absoluteMaxSessionHours: { section: "Data Management", label: "Absolute Max Session (hours)", default: null }, + maxNdjsonPayloadSizeMB: { section: "Data Management", label: "Max NDJSON Payload (MB)", default: 5 }, + + // Agent Collectors + enablePerformanceCollector: { section: "Agent Collectors", label: "Performance Collector", default: true }, + performanceCollectorIntervalSeconds: { section: "Agent Collectors", label: "Perf. Interval (sec)", default: 30 }, + helloWaitTimeoutSeconds: { section: "Agent Collectors", label: "Hello Wait Timeout (sec)", default: 30 }, + + // Auth Circuit Breaker + maxAuthFailures: { section: "Auth Circuit Breaker", label: "Max Auth Failures", default: null }, + authFailureTimeoutMinutes: { section: "Auth Circuit Breaker", label: "Auth Failure Timeout (min)", default: null }, + agentMaxLifetimeMinutes: { section: "Auth Circuit Breaker", label: "Agent Max Lifetime (min)", default: null }, + + // Agent Behavior + selfDestructOnComplete: { section: "Agent Behavior", label: "Self-Destruct On Complete", default: true }, + keepLogFile: { section: "Agent Behavior", label: "Keep Log File", default: false }, + rebootOnComplete: { section: "Agent Behavior", label: "Reboot On Complete", default: null }, + rebootDelaySeconds: { section: "Agent Behavior", label: "Reboot Delay (sec)", default: null }, + enableGeoLocation: { section: "Agent Behavior", label: "Geo-Location", default: null }, + enableTimezoneAutoSet: { section: "Agent Behavior", label: "Timezone Auto-Set", default: null }, + enableDoGroupIdAutoSet: { section: "Agent Behavior", label: "DO GroupId Auto-Set", default: null }, + // TableConfigRepository maps an empty stored value to the default, so the wire never carries null here. + ntpServer: { section: "Agent Behavior", label: "NTP Server", default: NTP_DEFAULT }, + keepAwakeDuringUserEsp: { section: "Agent Behavior", label: "Keep Awake During User ESP", default: null }, + enableImeMatchLog: { section: "Agent Behavior", label: "IME Match Log", default: null }, + enableGatherRuleDebugLog: { section: "Agent Behavior", label: "Gather Rule Debug Log", default: null }, + enableEspContinueAnywayObservation: { section: "Agent Behavior", label: "Continue-Anyway Observation", default: null }, + logLevel: { section: "Agent Behavior", label: "Log Level", default: null }, + maxBatchSize: { section: "Agent Behavior", label: "Max Batch Size", default: null }, + showScriptOutput: { section: "Agent Behavior", label: "Show Script Output", default: true }, + sendTraceEvents: { section: "Agent Behavior", label: "Send Trace Events", default: true }, + + // Enrollment Summary + showEnrollmentSummary: { section: "Enrollment Summary", label: "Show Summary", default: null }, + enrollmentSummaryTimeoutSeconds: { section: "Enrollment Summary", label: "Timeout (sec)", default: null }, + enrollmentSummaryBrandingImageUrl: { section: "Enrollment Summary", label: "Branding Image URL", default: null }, + enrollmentSummaryLaunchRetrySeconds: { section: "Enrollment Summary", label: "Launch Retry (sec)", default: null }, + + // Analyzers + enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", default: null }, + localAdminAllowedAccountsJson: { section: "Analyzers", label: "Allowed Local Admin Accounts", default: null }, + enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", default: null }, + enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", default: null }, + enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", default: null }, + enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", default: null }, + + // Webhooks — channel list plus the legacy single-webhook fields it falls back to + notificationChannelsJson: CHANNEL_BLOCK, + webhookProviderType: CHANNEL_BLOCK, + webhookUrl: CHANNEL_BLOCK, + webhookNotifyOnSuccess: CHANNEL_BLOCK, + webhookNotifyOnFailure: CHANNEL_BLOCK, + webhookNotifyOnStart: CHANNEL_BLOCK, + teamsWebhookUrl: CHANNEL_BLOCK, + teamsNotifyOnSuccess: CHANNEL_BLOCK, + teamsNotifyOnFailure: CHANNEL_BLOCK, + teamsNotifyOnStart: CHANNEL_BLOCK, + webhookCustomHeadersJson: { section: "Webhooks", label: "Custom Headers", kind: "secret" }, + + // SLA Targets + slaTargetSuccessRate: { section: "SLA Targets", label: "Target Success Rate (%)", default: null }, + slaTargetMaxDurationMinutes: { section: "SLA Targets", label: "Target Max Duration (min)", default: null }, + slaTargetAppInstallSuccessRate: { section: "SLA Targets", label: "Target App Install Success Rate (%)", default: null }, + slaNotifyOnSuccessRateBreach: { section: "SLA Targets", label: "Notify On Success Rate Breach", default: false }, + slaSuccessRateNotifyThreshold: { section: "SLA Targets", label: "Success Rate Notify Threshold (%)", default: null }, + slaNotifyOnDurationBreach: { section: "SLA Targets", label: "Notify On Duration Breach", default: false }, + slaNotifyOnAppInstallBreach: { section: "SLA Targets", label: "Notify On App Install Breach", default: false }, + slaNotifyOnConsecutiveFailures: { section: "SLA Targets", label: "Notify On Consecutive Failures", default: false }, + slaConsecutiveFailureThreshold: { section: "SLA Targets", label: "Consecutive Failure Threshold", default: 5 }, + + // Diagnostics + diagnosticsUploadMode: { section: "Diagnostics", label: "Upload Mode", default: "Off" }, + diagnosticsUploadDestination: { section: "Diagnostics", label: "Upload Destination", default: "CustomerSas" }, + diagnosticsBlobSasUrl: { section: "Diagnostics", label: "Blob SAS URL", kind: "masked" }, + diagnosticsLogPathsJson: { section: "Diagnostics", label: "Custom Log Paths", default: null }, + + // Feature Flags (Global) + bootstrapTokenEnabled: { section: "Feature Flags (Global)", label: "Bootstrap Token Enabled", default: false }, + unrestrictedModeEnabled: { section: "Feature Flags (Global)", label: "Unrestricted Mode Enabled (GA gate)", default: false }, + unrestrictedMode: { section: "Feature Flags (Global)", label: "Unrestricted Mode (tenant toggle)", default: false }, +}; + +// ── Runtime parameters (right column) ──────────────────────────────────────── + +export interface RuntimeContext { + config: TenantConfiguration; + /** GET global/config — null while loading or when the call failed. */ + adminConfig: AdminConfiguration | null; + /** GET config/{tenantId}/feature-flags — backend-resolved entitlement surface. */ + flags: TenantFeatureFlagsResponse | null; +} + +/** Which knob a runtime value is resolved from. */ +export type RuntimeSource = "tenant" | "admin" | "flags" | "constant"; + +export const RUNTIME_SECTIONS = [ + "Upload & Batching", + "Agent Behavior", + "Auth Circuit Breaker", + "Enrollment Summary", + "Collectors", + "Diagnostics", + "Analyzers", +] as const; +export type RuntimeSection = (typeof RUNTIME_SECTIONS)[number]; + +/** Sentinel for a value that cannot be resolved because its source did not load. */ +export const UNRESOLVED = Symbol("unresolved"); + +export interface RuntimeRow { + section: RuntimeSection; + label: string; + source: RuntimeSource; + /** Backend resolution rule (GetAgentConfigFunction.cs). */ + value: (ctx: RuntimeContext) => unknown; + /** The agent-side default (AgentConfigResponse.cs / AdminConfiguration.cs initializer) for the "custom" highlight. */ + default?: unknown; +} + +/** Shared.Constants.DefaultUploadIntervalSeconds — the one runtime constant not on any config row. */ +export const DEFAULT_UPLOAD_INTERVAL_SECONDS = 10; + +/** GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled: a SAS URL or the hosted destination, regardless of mode. */ +export const resolveDiagnosticsUploadEnabled = (c: TenantConfiguration): boolean => + !!c.diagnosticsBlobSasUrl || (c.diagnosticsUploadDestination ?? "").toLowerCase() === "hosted"; + +/** TenantConfiguration.GetLocalAdminAllowedAccounts: JSON string array, anything else → empty. */ +export const parseLocalAdminAllowedAccounts = (json: string | null | undefined): string[] => { + if (!json) return []; + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +}; + +/** AdminConfiguration.GetModernDeploymentHarmlessEventIds: JSON int array, blank/invalid → defaults. */ +export const parseModernDeploymentHarmlessEventIds = (json: string | null | undefined): number[] => { + const defaults = [100, 1005, 1010]; + if (!json || !json.trim()) return defaults; + try { + const parsed: unknown = JSON.parse(json); + return Array.isArray(parsed) && parsed.every((x) => typeof x === "number") ? parsed : defaults; + } catch { + return defaults; + } +}; + +const admin = (pick: (a: AdminConfiguration) => T) => + (ctx: RuntimeContext): T | typeof UNRESOLVED => (ctx.adminConfig ? pick(ctx.adminConfig) : UNRESOLVED); + +const NOT_CONFIG = { excluded: "per-request payload, not a configuration value" } as const; +const SUB_OBJECT = { excluded: "sub-object, catalogued separately" } as const; +const CLASS_DEFAULT = { excluded: "agent class default, neither tenant- nor operator-tunable" } as const; + +export const RUNTIME_FIELDS: Record = { + configVersion: { excluded: "compiled into the backend" }, + uploadIntervalSeconds: { section: "Upload & Batching", label: "Upload Interval (sec)", source: "constant", value: () => DEFAULT_UPLOAD_INTERVAL_SECONDS, default: DEFAULT_UPLOAD_INTERVAL_SECONDS }, + maxBatchSize: { section: "Upload & Batching", label: "Max Batch Size", source: "tenant", value: ({ config }) => config.maxBatchSize ?? 100, default: 100 }, + + selfDestructOnComplete: { section: "Agent Behavior", label: "Self-Destruct", source: "tenant", value: ({ config }) => config.selfDestructOnComplete ?? true, default: true }, + keepLogFile: { section: "Agent Behavior", label: "Keep Log File", source: "tenant", value: ({ config }) => config.keepLogFile ?? false, default: false }, + rebootOnComplete: { section: "Agent Behavior", label: "Reboot On Complete", source: "tenant", value: ({ config }) => config.rebootOnComplete ?? false, default: false }, + rebootDelaySeconds: { section: "Agent Behavior", label: "Reboot Delay (sec)", source: "tenant", value: ({ config }) => config.rebootDelaySeconds ?? 10, default: 10 }, + enableGeoLocation: { section: "Agent Behavior", label: "Geo-Location", source: "tenant", value: ({ config }) => config.enableGeoLocation ?? true, default: true }, + enableTimezoneAutoSet: { section: "Agent Behavior", label: "Timezone Auto-Set", source: "tenant", value: ({ config }) => config.enableTimezoneAutoSet ?? false, default: false }, + enableDoGroupIdAutoSet: { section: "Agent Behavior", label: "DO GroupId Auto-Set", source: "tenant", value: ({ config }) => config.enableDoGroupIdAutoSet ?? false, default: false }, + ntpServer: { section: "Agent Behavior", label: "NTP Server", source: "tenant", value: ({ config }) => config.ntpServer || NTP_DEFAULT, default: NTP_DEFAULT }, + enableImeMatchLog: { section: "Agent Behavior", label: "IME Match Log", source: "tenant", value: ({ config }) => config.enableImeMatchLog ?? false, default: false }, + enableGatherRuleDebugLog: { section: "Agent Behavior", label: "Gather Rule Debug Log", source: "tenant", value: ({ config }) => config.enableGatherRuleDebugLog ?? false, default: false }, + enableEspContinueAnywayObservation: { section: "Agent Behavior", label: "Continue-Anyway Observation", source: "tenant", value: ({ config }) => config.enableEspContinueAnywayObservation ?? false, default: false }, + logLevel: { section: "Agent Behavior", label: "Log Level", source: "tenant", value: ({ config }) => config.logLevel ?? "Info", default: "Info" }, + sendTraceEvents: { section: "Agent Behavior", label: "Send Trace Events", source: "tenant", value: ({ config }) => config.sendTraceEvents, default: true }, + // TenantEntitlementService.IsUnrestrictedModeActive = edition entitlement && GA gate && tenant toggle; + // feature-flags carries the backend-resolved value, so the entitlement logic is not rebuilt here. + unrestrictedMode: { section: "Agent Behavior", label: "Unrestricted Mode (effective)", source: "flags", value: ({ flags }) => (flags ? flags.unrestrictedMode : UNRESOLVED), default: false }, + allowAgentDowngrade: { section: "Agent Behavior", label: "Allow Agent Downgrade", source: "admin", value: admin((a) => a.allowAgentDowngrade), default: false }, + + maxAuthFailures: { section: "Auth Circuit Breaker", label: "Max Auth Failures", source: "tenant", value: ({ config }) => config.maxAuthFailures ?? 5, default: 5 }, + authFailureTimeoutMinutes: { section: "Auth Circuit Breaker", label: "Auth Failure Timeout (min)", source: "tenant", value: ({ config }) => config.authFailureTimeoutMinutes ?? 0, default: 0 }, + + showEnrollmentSummary: { section: "Enrollment Summary", label: "Show Summary", source: "tenant", value: ({ config }) => config.showEnrollmentSummary ?? false, default: false }, + enrollmentSummaryTimeoutSeconds: { section: "Enrollment Summary", label: "Timeout (sec)", source: "tenant", value: ({ config }) => config.enrollmentSummaryTimeoutSeconds ?? 60, default: 60 }, + enrollmentSummaryBrandingImageUrl: { section: "Enrollment Summary", label: "Branding Image URL", source: "tenant", value: ({ config }) => config.enrollmentSummaryBrandingImageUrl ?? null }, + enrollmentSummaryLaunchRetrySeconds: { section: "Enrollment Summary", label: "Launch Retry (sec)", source: "tenant", value: ({ config }) => config.enrollmentSummaryLaunchRetrySeconds ?? 120, default: 120 }, + + diagnosticsUploadEnabled: { section: "Diagnostics", label: "Upload Enabled", source: "tenant", value: ({ config }) => resolveDiagnosticsUploadEnabled(config), default: false }, + diagnosticsUploadMode: { section: "Diagnostics", label: "Upload Mode", source: "tenant", value: ({ config }) => config.diagnosticsUploadMode ?? "Off", default: "Off" }, + diagnosticsLogPaths: { excluded: "merged global + tenant path list (see Custom Log Paths and the global diagnostics settings)" }, + + collectors: SUB_OBJECT, + analyzers: SUB_OBJECT, + gatherRules: { excluded: "active gather-rule catalog for the tenant" }, + imeLogPatterns: { excluded: "active IME log-pattern catalog for the tenant" }, + whiteGloveSealingPatternIds: { excluded: "global pattern-id list (AdminConfiguration)" }, + latestAgentSha256: { excluded: "per-major agent hash oracle selected from the caller's X-Agent-Version (AdminConfiguration)" }, + latestAgentExeSha256: { excluded: "per-major agent hash oracle selected from the caller's X-Agent-Version (AdminConfiguration)" }, + deviceBlocked: NOT_CONFIG, + deviceKillSignal: NOT_CONFIG, + unblockAt: NOT_CONFIG, + migrateToApiBaseUrl: { excluded: "endpoint-migration target, global or per-tenant override (AdminConfiguration)" }, +}; + +export const COLLECTOR_FIELDS: Record = { + enablePerformanceCollector: { section: "Collectors", label: "Performance Collector", source: "tenant", value: ({ config }) => config.enablePerformanceCollector, default: true }, + performanceIntervalSeconds: { section: "Collectors", label: "Perf. Interval (sec)", source: "tenant", value: ({ config }) => config.performanceCollectorIntervalSeconds, default: 30 }, + collectorIdleTimeoutMinutes: { section: "Collectors", label: "Idle Timeout (min)", source: "admin", value: admin((a) => a.collectorIdleTimeoutMinutes), default: 15 }, + desktopDetectorNoCandidateTimeoutMinutes: { section: "Collectors", label: "Desktop Detector No-Candidate Timeout (min)", source: "admin", value: admin((a) => a.desktopDetectorNoCandidateTimeoutMinutes), default: 10 }, + enableAgentSelfMetrics: { section: "Collectors", label: "Agent Self-Metrics", source: "constant", value: () => true, default: true }, + agentSelfMetricsIntervalSeconds: { section: "Collectors", label: "Self-Metrics Interval (sec)", source: "constant", value: () => 60, default: 60 }, + helloWaitTimeoutSeconds: { section: "Collectors", label: "Hello Wait Timeout (sec)", source: "tenant", value: ({ config }) => config.helloWaitTimeoutSeconds, default: 30 }, + agentMaxLifetimeMinutes: { section: "Collectors", label: "Agent Max Lifetime (min)", source: "tenant", value: ({ config }) => config.agentMaxLifetimeMinutes ?? 360, default: 360 }, + modernDeploymentHarmlessEventIds: { section: "Collectors", label: "Modern Deployment Harmless Event IDs", source: "admin", value: admin((a) => parseModernDeploymentHarmlessEventIds(a.modernDeploymentHarmlessEventIdsJson)), default: [100, 1005, 1010] }, + enableDeliveryOptimizationCollector: CLASS_DEFAULT, + deliveryOptimizationIntervalSeconds: CLASS_DEFAULT, + enableOfficeInstallDetector: CLASS_DEFAULT, + officeInstallSettleSeconds: CLASS_DEFAULT, + enablePowerStateWatcher: CLASS_DEFAULT, + stallProbeEnabled: CLASS_DEFAULT, + stallProbeThresholdsMinutes: CLASS_DEFAULT, + stallProbeTraceIndices: CLASS_DEFAULT, + stallProbeSources: CLASS_DEFAULT, + sessionStalledAfterProbeIndex: CLASS_DEFAULT, + modernDeploymentWatcherEnabled: CLASS_DEFAULT, + modernDeploymentLogLevelMax: CLASS_DEFAULT, + modernDeploymentBackfillEnabled: CLASS_DEFAULT, + modernDeploymentBackfillLookbackMinutes: CLASS_DEFAULT, + windowsUpdateWatcherEnabled: CLASS_DEFAULT, + windowsUpdateTargetedEventIds: CLASS_DEFAULT, + windowsUpdateBackfillLookbackMinutes: CLASS_DEFAULT, + windowsUpdateChannelCensusEnabled: CLASS_DEFAULT, + mdmRebootPolicyWatcherEnabled: CLASS_DEFAULT, + mdmRebootPolicyBackfillLookbackMinutes: CLASS_DEFAULT, + enableSystemTimelineWatcher: CLASS_DEFAULT, + systemEventBackfillLookbackMinutes: CLASS_DEFAULT, +}; + +export const ANALYZER_FIELDS: Record = { + enableLocalAdminAnalyzer: { section: "Analyzers", label: "Local Admin Analyzer", source: "tenant", value: ({ config }) => config.enableLocalAdminAnalyzer ?? true, default: true }, + localAdminAllowedAccounts: { section: "Analyzers", label: "Allowed Local Admin Accounts", source: "tenant", value: ({ config }) => parseLocalAdminAllowedAccounts(config.localAdminAllowedAccountsJson), default: [] }, + enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant", value: ({ config }) => config.enableSoftwareInventoryAnalyzer ?? false, default: false }, + enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant", value: ({ config }) => config.enableIntegrityBypassAnalyzer ?? true, default: true }, + enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant", value: ({ config }) => config.enableRealmJoinWatcher ?? false, default: false }, + keepAwakeDuringUserEsp: { section: "Analyzers", label: "Keep Awake During User ESP", source: "tenant", value: ({ config }) => config.keepAwakeDuringUserEsp ?? false, default: false }, + enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", source: "tenant", value: ({ config }) => config.enableConsoleBypassDetection ?? true, default: true }, +}; + +// ── Ordered views for rendering ────────────────────────────────────────────── + +export interface CataloguedRow { + key: string; + row: TRow; +} + +function groupBySection( + sections: readonly TSection[], + records: Array>, +): Array<{ section: TSection; rows: CataloguedRow[] }> { + const bySection = new Map[]>(sections.map((s) => [s, []])); + for (const record of records) { + for (const [key, spec] of Object.entries(record)) { + if (isExcluded(spec)) continue; + bySection.get(spec.section)!.push({ key, row: spec }); + } + } + return sections.map((section) => ({ section, rows: bySection.get(section)! })); +} + +export const TENANT_REPORT_SECTIONS = groupBySection(TENANT_SECTIONS, [TENANT_FIELDS]); + +export const RUNTIME_REPORT_SECTIONS = groupBySection(RUNTIME_SECTIONS, [ + RUNTIME_FIELDS, + COLLECTOR_FIELDS, + ANALYZER_FIELDS, +]); + +/** + * Value equality for the "custom" highlight: null, undefined and the empty string are one + * "unset" value (the table row stores a cleared string as "", the C# initializer is null), + * arrays compare by content. + */ +export function isNonDefault(value: unknown, def: unknown): boolean { + const isNil = (v: unknown) => v === null || v === undefined || v === ""; + const valueNil = isNil(value); + const defNil = isNil(def); + if (valueNil || defNil) return valueNil !== defNil; + if (Array.isArray(value) && Array.isArray(def)) { + return value.length !== def.length || value.some((v, i) => v !== def[i]); + } + return value !== def; +}