mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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<keyof TenantConfiguration> and Record<keyof AgentConfigResponse | CollectorConfiguration | AnalyzerConfiguration>: 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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: fea9a5c7e7...f80fd1b166
+196
-456
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<number, string> = {
|
||||
@@ -218,21 +37,22 @@ const WEBHOOK_PROVIDERS: Record<number, string> = {
|
||||
30: 'Discord',
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<RuntimeSource, string> = {
|
||||
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<string, unknown>): 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<string, unknown>;
|
||||
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 (
|
||||
<tr className={highlight ? 'bg-purple-50 dark:bg-purple-900/20' : ''}>
|
||||
<td className="py-1.5 px-3 text-sm text-gray-600 dark:text-gray-400 font-medium whitespace-nowrap">{label}</td>
|
||||
<td className="py-1.5 px-3 text-sm text-gray-600 dark:text-gray-400 font-medium whitespace-nowrap">
|
||||
{label}
|
||||
{sourceTag && <span className="ml-2 text-xs text-gray-400 dark:text-gray-500 font-normal">{sourceTag}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-sm text-gray-900 dark:text-gray-100 font-mono break-all">
|
||||
{displayVal}
|
||||
{display}
|
||||
{highlight && <span className="ml-2 text-xs text-purple-600 dark:text-purple-400 font-sans">(custom)</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -300,46 +127,63 @@ function Section({ title, children }: SectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Compute runtime parameters ───────────────────────────────────────────────
|
||||
// ── Notification channels (the one non-scalar block) ─────────────────────────
|
||||
|
||||
function computeRuntime(c: TenantConfig): Record<string, unknown> {
|
||||
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 <ConfigRow label="Provider" display={WEBHOOK_PROVIDERS[0]} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{channels.map((ch, i) => (
|
||||
<tr key={ch.id ?? i} className={i > 0 ? 'border-t-2 border-gray-200 dark:border-gray-600' : ''}>
|
||||
<td colSpan={2} className="p-0">
|
||||
<table className="w-full">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<ConfigRow label="Channel" display={`${ch.name || ch.id}${ch.enabled === false ? ' (disabled)' : ''}`} />
|
||||
<ConfigRow label="Provider" display={WEBHOOK_PROVIDERS[ch.providerType ?? 0] ?? 'Unknown'} />
|
||||
<ConfigRow label="Webhook URL" display={maskSasUrl(ch.url)} />
|
||||
<ConfigRow label="Notify On Start" display={formatValue(ch.notifyOnStart ?? false)} />
|
||||
<ConfigRow label="Notify On Success" display={formatValue(ch.notifyOnSuccess ?? false)} />
|
||||
<ConfigRow label="Notify On Failure" display={formatValue(ch.notifyOnFailure ?? false)} />
|
||||
<ConfigRow label="Notify On SLA" display={formatValue(ch.notifyOnSlaEvents ?? false)} />
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
@@ -349,13 +193,13 @@ export function SectionTenantConfigReport() {
|
||||
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
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<TenantConfig | null>(null);
|
||||
const [config, setConfig] = useState<TenantConfiguration | null>(null);
|
||||
const [flags, setFlags] = useState<TenantFeatureFlagsResponse | null>(null);
|
||||
const [adminConfig, setAdminConfig] = useState<AdminConfiguration | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTenants, setLoadingTenants] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sideError, setSideError] = useState<string | null>(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<TenantConfig>(
|
||||
api.config.tenant(selectedTenantId),
|
||||
getAccessToken,
|
||||
);
|
||||
setConfig(data);
|
||||
setSideError(null);
|
||||
const [configResult, flagsResult, adminResult] = await Promise.allSettled([
|
||||
fetchJson<TenantConfiguration>(api.config.tenant(selectedTenantId), getAccessToken),
|
||||
fetchJson<TenantFeatureFlagsResponse>(api.config.featureFlags(selectedTenantId), getAccessToken),
|
||||
fetchJson<AdminConfiguration>(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 (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
@@ -487,7 +322,7 @@ export function SectionTenantConfigReport() {
|
||||
)}
|
||||
|
||||
{/* Config report */}
|
||||
{!loading && config && (
|
||||
{!loading && config && runtimeCtx && (
|
||||
<>
|
||||
{/* Tenant header card */}
|
||||
<div className="mb-6 p-4 bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
@@ -509,6 +344,34 @@ export function SectionTenantConfigReport() {
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{config.updatedBy || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Effective entitlement — backend-resolved (feature-flags), not the stored plan tier */}
|
||||
{flags && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm mt-3 pt-3 border-t border-purple-200 dark:border-purple-800">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">Effective Edition</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{flags.edition}
|
||||
<span className="ml-1 text-xs text-gray-500 dark:text-gray-400">via {flags.editionSource}</span>
|
||||
{flags.isTrial && flags.trialExpiresUtc ? ` (trial until ${formatDate(flags.trialExpiresUtc)})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">MCP Usage Plan</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{flags.entitlements.mcpUsagePlan || '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">Delegated Tenant Slots</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{flags.entitlements.maxDelegatedTenants}
|
||||
{!flags.entitlements.delegatedAdminAllowed && <span className="ml-1 text-xs text-gray-500 dark:text-gray-400">(delegation not included)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block">Retention Cap (days)</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{flags.entitlements.retentionCapDays}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{config.disabled && (
|
||||
<div className="mt-3 p-2 bg-red-100 dark:bg-red-900/40 rounded text-sm text-red-700 dark:text-red-300 font-medium">
|
||||
TENANT DISABLED{config.disabledReason ? `: ${config.disabledReason}` : ''}
|
||||
@@ -522,6 +385,12 @@ export function SectionTenantConfigReport() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sideError && (
|
||||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg text-sm text-amber-800 dark:text-amber-300">
|
||||
{sideError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mb-4 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
||||
<span className="inline-block w-3 h-3 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-700 rounded" />
|
||||
@@ -533,177 +402,48 @@ export function SectionTenantConfigReport() {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Tenant Configuration</h2>
|
||||
|
||||
<Section title="Tenant Status">
|
||||
<ConfigRow label="Disabled" value={config.disabled} configKey="disabled" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Disabled Reason" value={config.disabledReason} />
|
||||
<ConfigRow label="Disabled Until" value={config.disabledUntil} isDate />
|
||||
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
|
||||
<ConfigRow label="Contact Email" value={config.contactEmail} />
|
||||
<ConfigRow label="Company" value={config.companyName} />
|
||||
<ConfigRow
|
||||
label="Plan Tier"
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
configKey="planTier"
|
||||
defaults={DEFAULTS}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Security & Validation">
|
||||
<ConfigRow label="Validate Autopilot Device" value={config.validateAutopilotDevice} configKey="validateAutopilotDevice" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Validate Corporate Identifier" value={config.validateCorporateIdentifier} configKey="validateCorporateIdentifier" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Allow Insecure Agent Requests" value={config.allowInsecureAgentRequests} configKey="allowInsecureAgentRequests" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Device API Rate Limit Override" value={config.customRateLimitRequestsPerMinute} configKey="customRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
<ConfigRow label="MCP & Integrations API Rate Limit Override" value={config.customUserRateLimitRequestsPerMinute} configKey="customUserRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Hardware Whitelist">
|
||||
<ConfigRow label="Manufacturers" value={config.manufacturerWhitelist} configKey="manufacturerWhitelist" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Models" value={config.modelWhitelist} configKey="modelWhitelist" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Data Management">
|
||||
<ConfigRow label="Data Retention (days)" value={config.dataRetentionDays} configKey="dataRetentionDays" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Session Timeout (hours)" value={config.sessionTimeoutHours} configKey="sessionTimeoutHours" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Max NDJSON Payload (MB)" value={config.maxNdjsonPayloadSizeMB} configKey="maxNdjsonPayloadSizeMB" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Agent Collectors">
|
||||
<ConfigRow label="Performance Collector" value={config.enablePerformanceCollector} configKey="enablePerformanceCollector" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Perf. Interval (sec)" value={config.performanceCollectorIntervalSeconds} configKey="performanceCollectorIntervalSeconds" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Hello Wait Timeout (sec)" value={config.helloWaitTimeoutSeconds} configKey="helloWaitTimeoutSeconds" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Auth Circuit Breaker">
|
||||
<ConfigRow label="Max Auth Failures" value={config.maxAuthFailures} configKey="maxAuthFailures" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Auth Failure Timeout (min)" value={config.authFailureTimeoutMinutes} configKey="authFailureTimeoutMinutes" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Agent Max Lifetime (min)" value={config.agentMaxLifetimeMinutes} configKey="agentMaxLifetimeMinutes" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Agent Behavior">
|
||||
<ConfigRow label="Self-Destruct On Complete" value={config.selfDestructOnComplete} configKey="selfDestructOnComplete" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Keep Log File" value={config.keepLogFile} configKey="keepLogFile" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Reboot On Complete" value={config.rebootOnComplete} configKey="rebootOnComplete" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Reboot Delay (sec)" value={config.rebootDelaySeconds} configKey="rebootDelaySeconds" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Geo-Location" value={config.enableGeoLocation} configKey="enableGeoLocation" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Timezone Auto-Set" value={config.enableTimezoneAutoSet} configKey="enableTimezoneAutoSet" defaults={DEFAULTS} />
|
||||
<ConfigRow label="DO GroupId Auto-Set" value={config.enableDoGroupIdAutoSet} configKey="enableDoGroupIdAutoSet" defaults={DEFAULTS} />
|
||||
<ConfigRow label="NTP Server" value={config.ntpServer} configKey="ntpServer" defaults={DEFAULTS} />
|
||||
<ConfigRow label="IME Match Log" value={config.enableImeMatchLog} configKey="enableImeMatchLog" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Gather Rule Debug Log" value={config.enableGatherRuleDebugLog} configKey="enableGatherRuleDebugLog" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Continue-Anyway Observation" value={config.enableEspContinueAnywayObservation} configKey="enableEspContinueAnywayObservation" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Log Level" value={config.logLevel} configKey="logLevel" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Max Batch Size" value={config.maxBatchSize} configKey="maxBatchSize" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Show Script Output" value={config.showScriptOutput} configKey="showScriptOutput" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Send Trace Events" value={config.sendTraceEvents} configKey="sendTraceEvents" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Enrollment Summary">
|
||||
<ConfigRow label="Show Summary" value={config.showEnrollmentSummary} configKey="showEnrollmentSummary" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Timeout (sec)" value={config.enrollmentSummaryTimeoutSeconds} configKey="enrollmentSummaryTimeoutSeconds" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Branding Image URL" value={config.enrollmentSummaryBrandingImageUrl} configKey="enrollmentSummaryBrandingImageUrl" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Launch Retry (sec)" value={config.enrollmentSummaryLaunchRetrySeconds} configKey="enrollmentSummaryLaunchRetrySeconds" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Analyzers">
|
||||
<ConfigRow label="Local Admin Analyzer" value={config.enableLocalAdminAnalyzer} configKey="enableLocalAdminAnalyzer" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Software Inventory Analyzer" value={config.enableSoftwareInventoryAnalyzer} configKey="enableSoftwareInventoryAnalyzer" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Allowed Local Admin Accounts" value={config.localAdminAllowedAccountsJson} configKey="localAdminAllowedAccountsJson" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Webhooks">
|
||||
{webhookChannels.length === 0 && (
|
||||
<ConfigRow label="Provider" value={WEBHOOK_PROVIDERS[0] ?? 'None'} />
|
||||
)}
|
||||
{webhookChannels.map((ch, i) => (
|
||||
<div key={ch.id ?? i} className={i > 0 ? 'mt-2 pt-2 border-t border-gray-100 dark:border-gray-700' : ''}>
|
||||
<ConfigRow label="Channel" value={`${ch.name || ch.id}${ch.enabled === false ? ' (disabled)' : ''}`} />
|
||||
<ConfigRow label="Provider" value={WEBHOOK_PROVIDERS[ch.providerType ?? 0] ?? 'Unknown'} />
|
||||
<ConfigRow label="Webhook URL" value={ch.url} masked />
|
||||
<ConfigRow label="Notify On Start" value={ch.notifyOnStart ?? false} />
|
||||
<ConfigRow label="Notify On Success" value={ch.notifyOnSuccess ?? false} />
|
||||
<ConfigRow label="Notify On Failure" value={ch.notifyOnFailure ?? false} />
|
||||
<ConfigRow label="Notify On SLA" value={ch.notifyOnSlaEvents ?? false} />
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="Diagnostics">
|
||||
<ConfigRow label="Upload Mode" value={config.diagnosticsUploadMode} configKey="diagnosticsUploadMode" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Blob SAS URL" value={config.diagnosticsBlobSasUrl} masked />
|
||||
<ConfigRow label="Custom Log Paths" value={config.diagnosticsLogPathsJson} configKey="diagnosticsLogPathsJson" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Feature Flags (Global)">
|
||||
<ConfigRow label="Bootstrap Token Enabled" value={config.bootstrapTokenEnabled} configKey="bootstrapTokenEnabled" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Unrestricted Mode Enabled" value={config.unrestrictedModeEnabled} configKey="unrestrictedModeEnabled" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Unrestricted Mode Active" value={config.unrestrictedMode} configKey="unrestrictedMode" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
{TENANT_REPORT_SECTIONS.map(({ section, rows }) => (
|
||||
<Section key={section} title={section}>
|
||||
{section === 'Webhooks' && <ChannelRows config={config} />}
|
||||
{rows.map(({ key, row }) => {
|
||||
const value = config[key as keyof TenantConfiguration];
|
||||
return (
|
||||
<ConfigRow
|
||||
key={key}
|
||||
label={row.label}
|
||||
display={formatByKind(row.kind, value)}
|
||||
highlight={'default' in row && isNonDefault(value, row.default)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ────── RIGHT: Runtime Parameters (Agent Config) ────── */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Runtime Parameters (Agent)</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{runtime && (
|
||||
<>
|
||||
<Section title="Upload & Batching">
|
||||
<ConfigRow label="Upload Interval (sec)" value={runtime.uploadIntervalSeconds} configKey="uploadIntervalSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Max Batch Size" value={runtime.maxBatchSize} configKey="maxBatchSize" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Agent Behavior">
|
||||
<ConfigRow label="Self-Destruct" value={runtime.selfDestructOnComplete} configKey="selfDestructOnComplete" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Keep Log File" value={runtime.keepLogFile} configKey="keepLogFile" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Reboot On Complete" value={runtime.rebootOnComplete} configKey="rebootOnComplete" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Reboot Delay (sec)" value={runtime.rebootDelaySeconds} configKey="rebootDelaySeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Geo-Location" value={runtime.enableGeoLocation} configKey="enableGeoLocation" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Timezone Auto-Set" value={runtime.enableTimezoneAutoSet} configKey="enableTimezoneAutoSet" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="DO GroupId Auto-Set" value={runtime.enableDoGroupIdAutoSet} configKey="enableDoGroupIdAutoSet" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="NTP Server" value={runtime.ntpServer} configKey="ntpServer" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="IME Match Log" value={runtime.enableImeMatchLog} configKey="enableImeMatchLog" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Gather Rule Debug Log" value={runtime.enableGatherRuleDebugLog} configKey="enableGatherRuleDebugLog" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Continue-Anyway Observation" value={runtime.enableEspContinueAnywayObservation} configKey="enableEspContinueAnywayObservation" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Log Level" value={runtime.logLevel} configKey="logLevel" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Send Trace Events" value={runtime.sendTraceEvents} configKey="sendTraceEvents" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Unrestricted Mode" value={runtime.unrestrictedMode} configKey="unrestrictedMode" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Auth Circuit Breaker">
|
||||
<ConfigRow label="Max Auth Failures" value={runtime.maxAuthFailures} configKey="maxAuthFailures" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Auth Failure Timeout (min)" value={runtime.authFailureTimeoutMinutes} configKey="authFailureTimeoutMinutes" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Enrollment Summary">
|
||||
<ConfigRow label="Show Summary" value={runtime.showEnrollmentSummary} configKey="showEnrollmentSummary" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Timeout (sec)" value={runtime.enrollmentSummaryTimeoutSeconds} configKey="enrollmentSummaryTimeoutSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Branding Image URL" value={runtime.enrollmentSummaryBrandingImageUrl} />
|
||||
<ConfigRow label="Launch Retry (sec)" value={runtime.enrollmentSummaryLaunchRetrySeconds} configKey="enrollmentSummaryLaunchRetrySeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Collectors">
|
||||
<ConfigRow label="Performance Collector" value={runtime.enablePerformanceCollector} configKey="enablePerformanceCollector" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Perf. Interval (sec)" value={runtime.performanceIntervalSeconds} configKey="performanceIntervalSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Idle Timeout (min)" value={runtime.collectorIdleTimeoutMinutes} configKey="collectorIdleTimeoutMinutes" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Agent Self-Metrics" value={runtime.enableAgentSelfMetrics} configKey="enableAgentSelfMetrics" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Self-Metrics Interval (sec)" value={runtime.agentSelfMetricsIntervalSeconds} configKey="agentSelfMetricsIntervalSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Hello Wait Timeout (sec)" value={runtime.helloWaitTimeoutSeconds} configKey="helloWaitTimeoutSeconds" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Agent Max Lifetime (min)" value={runtime.agentMaxLifetimeMinutes} configKey="agentMaxLifetimeMinutes" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Diagnostics">
|
||||
<ConfigRow label="Upload Enabled" value={runtime.diagnosticsUploadEnabled} />
|
||||
<ConfigRow label="Upload Mode" value={runtime.diagnosticsUploadMode} configKey="diagnosticsUploadMode" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Analyzers">
|
||||
<ConfigRow label="Local Admin Analyzer" value={runtime.enableLocalAdminAnalyzer} configKey="enableLocalAdminAnalyzer" defaults={RUNTIME_DEFAULTS} />
|
||||
<ConfigRow label="Software Inventory Analyzer" value={runtime.enableSoftwareInventoryAnalyzer} configKey="enableSoftwareInventoryAnalyzer" defaults={RUNTIME_DEFAULTS} />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
{RUNTIME_REPORT_SECTIONS.map(({ section, rows }) => (
|
||||
<Section key={section} title={section}>
|
||||
{rows.map(({ key, row }) => {
|
||||
const value = row.value(runtimeCtx);
|
||||
return (
|
||||
<ConfigRow
|
||||
key={key}
|
||||
label={row.label}
|
||||
sourceTag={SOURCE_LABELS[row.source]}
|
||||
display={formatValue(value)}
|
||||
highlight={'default' in row && value !== UNRESOLVED && isNonDefault(value, row.default)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+215
@@ -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<string, number>();
|
||||
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> = {}): 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<TenantConfiguration> = {}, extra: Partial<RuntimeContext> = {}): 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<string, (typeof RUNTIME_FIELDS)[keyof typeof RUNTIME_FIELDS]>)[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);
|
||||
});
|
||||
});
|
||||
+420
@@ -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 = <T extends object>(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<keyof TenantConfiguration, TenantRow | Excluded> = {
|
||||
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 = <T>(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<keyof AgentConfigResponse, RuntimeRow | Excluded> = {
|
||||
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<keyof CollectorConfiguration, RuntimeRow | Excluded> = {
|
||||
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<keyof AnalyzerConfiguration, RuntimeRow | Excluded> = {
|
||||
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<TRow> {
|
||||
key: string;
|
||||
row: TRow;
|
||||
}
|
||||
|
||||
function groupBySection<TSection extends string, TRow extends { section: TSection }>(
|
||||
sections: readonly TSection[],
|
||||
records: Array<Record<string, TRow | Excluded>>,
|
||||
): Array<{ section: TSection; rows: CataloguedRow<TRow>[] }> {
|
||||
const bySection = new Map<TSection, CataloguedRow<TRow>[]>(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<TenantSection, TenantRow>(TENANT_SECTIONS, [TENANT_FIELDS]);
|
||||
|
||||
export const RUNTIME_REPORT_SECTIONS = groupBySection<RuntimeSection, RuntimeRow>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user