Compare commits

..

7 Commits

Author SHA1 Message Date
Oliver Kieselbach 82c0b20be1 bump pointer: internal (D-397), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:27:53 +01:00
Oliver Kieselbach 4fa439cc3c Settings: Save only; the Reset button is gone from every card
Reset sat next to Save and discarded a card's edits with one click and no
confirmation. The bar is now SaveBar; the Reset handlers go with it, as does the
Reset button on the Global Admin settings card. A failed save shows its reason
until the next Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:27:31 +01:00
Oliver Kieselbach b631c63c53 bump pointer: internal (D-395, D-396 web live; open checks in the backlog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:07:09 +01:00
Oliver Kieselbach e9aaf3db53 bump pointer: internal (D-395, D-396, work files)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:59:00 +01:00
Oliver Kieselbach a37930e87a Agent: two identical Debug repeats go to Trace after their first occurrence
NetworkChangeDetector "same logical network" (16x) and the DeviceInfo "payload unchanged" line
(3x) stayed on Debug in session 080a3016, measured against the D-394 contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:59:00 +01:00
Oliver Kieselbach 6bc5d47afe Settings: one card, one Save; the result of a save shows at its button
Agent Settings was the only page with two cards on one Save bar, so a change in the top card
read as auto-saved and was lost. Agent Parameters and Agent Collectors are now their own
sections: Save and Reset touch only their card, unsaved edits in the other card stay (D-395).

A failed save showed a green "Saved!" on every tenant settings card, because saveConfiguration
reports a failure as false and the bar only caught exceptions; the reason was only in the banner
at the top of the page. A failure now throws and the bar shows "Save failed: <reason>" next to
the button until the next Save or Reset (D-396).

The Contact card showed "Read-only" and locked Reset on invalid input. saveBlockedReason now
blocks only Save and names the reason; canSave means a read-only viewer and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:58:59 +01:00
Oliver Kieselbach 0ebbf87e5b bump pointer: internal (D-394 live: agent 2.0.1495; count the next session B-ztz)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 01:49:49 +01:00
29 changed files with 667 additions and 785 deletions
@@ -1004,7 +1004,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
var fingerprint = Persistence.StartupEventGate.ComputeFingerprint(data, excludedFields);
if (_startupGate.ShouldEmit(eventType, fingerprint)) return true;
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)");
_logger.Debug($"EnrollmentTracker: '{eventType}' suppressed — payload unchanged since last emission (restart dedup)", repeatKey: "DeviceInfo.unchanged:" + eventType);
return false;
}
@@ -160,7 +160,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
// Suppress events when network hasn't actually changed (DHCP renewals, OS chatter)
if (previousState != null && previousState.IsSameNetwork(newState))
{
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing");
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing", repeatKey: "NetworkChangeDetector.sameNetwork");
return;
}
@@ -143,7 +143,6 @@ interface AdminConfigContextValue {
// Actions
handleSaveAdminConfig: () => Promise<void>;
handleResetAdminConfig: () => void;
handleSaveDiagPaths: (paths: DiagnosticsLogPath[]) => Promise<void>;
}
@@ -393,58 +392,6 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, portalUserRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, enforceClientAppBinding, mcpClientRegistrationEnabled, modernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
if (!adminConfig) return;
setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute);
setUserRateLimit(adminConfig.userRateLimitRequestsPerMinute ?? 120);
setPortalUserRateLimit(adminConfig.portalUserRateLimitRequestsPerMinute ?? 600);
setGlobalAdminRateLimit(adminConfig.globalAdminRateLimitRequestsPerMinute ?? 600);
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
setAgentMigrateApiBaseUrl(adminConfig.agentMigrateApiBaseUrl ?? "");
setAgentMigrateTenantOverridesJson(adminConfig.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setOpsEventRetentionDays(adminConfig.opsEventRetentionDays ?? 90);
setSlaNotificationCooldownHours(adminConfig.slaNotificationCooldownHours ?? 24);
setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false);
setEnforceClientAppBinding(adminConfig.enforceClientAppBinding ?? false);
setMcpClientRegistrationEnabled(adminConfig.mcpClientRegistrationEnabled ?? false);
setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(adminConfig.modernDeploymentHarmlessEventIdsJson));
setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false);
setSelfServiceAppHomingEnabled(adminConfig.selfServiceAppHomingEnabled ?? false);
setImeMsiArchivingEnabled(adminConfig.imeMsiArchivingEnabled ?? true);
setMaxImeMsiDownloadSizeMB(adminConfig.maxImeMsiDownloadSizeMB ?? 250);
try {
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
} catch {
setGlobalDiagPaths([]);
}
try {
setOpsAlertRules(adminConfig.opsAlertRulesJson ? JSON.parse(adminConfig.opsAlertRulesJson) : []);
} catch {
setOpsAlertRules([]);
}
try {
setOpsNotificationChannels(
adminConfig.opsNotificationChannelsJson ? JSON.parse(adminConfig.opsNotificationChannelsJson) : []);
} catch {
setOpsNotificationChannels([]);
}
setOpsAlertTelegramEnabled(adminConfig.opsAlertTelegramEnabled ?? false);
setOpsAlertTelegramChatId(adminConfig.opsAlertTelegramChatId ?? "");
setOpsAlertTeamsEnabled(adminConfig.opsAlertTeamsEnabled ?? false);
setOpsAlertTeamsWebhookUrl(adminConfig.opsAlertTeamsWebhookUrl ?? "");
setOpsAlertSlackEnabled(adminConfig.opsAlertSlackEnabled ?? false);
setOpsAlertSlackWebhookUrl(adminConfig.opsAlertSlackWebhookUrl ?? "");
setExcessiveEventCountThreshold(adminConfig.excessiveEventCountThreshold ?? 2000);
setExcessiveEventAutoActionMode(adminConfig.excessiveEventAutoActionMode ?? "Off");
setExcessiveEventAutoActionThreshold(adminConfig.excessiveEventAutoActionThreshold ?? 2500);
setExcessiveEventAutoActionDurationHours(adminConfig.excessiveEventAutoActionDurationHours ?? 24);
setSuccessMessage(null);
setError(null);
}, [adminConfig]);
// Save diagnostics paths
const handleSaveDiagPaths = useCallback(async (paths: DiagnosticsLogPath[]) => {
@@ -591,7 +538,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
notificationEmails, setNotificationEmails,
error, setError, successMessage, setSuccessMessage,
getAccessToken,
handleSaveAdminConfig, handleResetAdminConfig, handleSaveDiagPaths, handleSaveOpsAlertConfig,
handleSaveAdminConfig, handleSaveDiagPaths, handleSaveOpsAlertConfig,
opsNotificationChannels, setOpsNotificationChannels,
handleTestOpsChannel, testingOpsChannelId, testOpsChannelResult,
}}>
@@ -46,7 +46,6 @@ interface AdminConfigSettingsSectionProps {
maxImeMsiDownloadSizeMB: number;
setMaxImeMsiDownloadSizeMB: (value: number) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
export function AdminConfigSettingsSection({
@@ -92,7 +91,6 @@ export function AdminConfigSettingsSection({
maxImeMsiDownloadSizeMB,
setMaxImeMsiDownloadSizeMB,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
return (
<div className="bg-gradient-to-br from-indigo-50 to-blue-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
@@ -492,13 +490,6 @@ export function AdminConfigSettingsSection({
)}
<div className="flex items-center justify-end space-x-3 pt-2">
<button
onClick={onReset}
disabled={savingConfig}
className="px-5 py-2 border border-indigo-300 dark:border-indigo-600 rounded-md text-indigo-800 dark:text-indigo-200 bg-white dark:bg-gray-700 hover:bg-indigo-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Reset
</button>
<button
onClick={onSave}
disabled={savingConfig}
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { apiErrorText, fetchJson } from "@/lib/apiClient";
import SaveResetBar from "../../settings/components/SaveResetBar";
import SaveBar from "../../settings/components/SaveBar";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import { fromWireAdminConfiguration, type AdminConfiguration } from "@/types/adminConfig";
import { saveAdminConfigChanges } from "@/lib/adminConfigSave";
@@ -185,17 +185,12 @@ export function VulnerabilityDataSection({
} catch (err) {
const message = apiErrorText(err, "Failed to save vulnerability configuration");
setError(message);
throw err; // Let SaveResetBar show "Save failed"
throw err; // Let SaveBar show "Save failed"
} finally {
setSaving(false);
}
}, [adminConfig, nvdApiKey, vulnerabilityCorrelationEnabled, getAccessToken, setError, setSuccessMessage, onAdminConfigUpdated]);
const handleReset = useCallback(() => {
setNvdApiKey(adminConfig?.nvdApiKey ?? "");
setVulnerabilityCorrelationEnabled(adminConfig?.vulnerabilityCorrelationEnabled ?? true);
setSyncResult(null);
}, [adminConfig]);
const handleSync = useCallback(async () => {
setSyncing(true);
@@ -665,10 +660,9 @@ export function VulnerabilityDataSection({
</p>
</div>
{/* Save / Reset Bar */}
<SaveResetBar
{/* Save Bar */}
<SaveBar
onSave={handleSave}
onReset={handleReset}
saving={saving}
canSave={canMutate}
/>
@@ -28,7 +28,7 @@ export function SectionGlobalSettings() {
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
handleSaveAdminConfig, handleResetAdminConfig,
handleSaveAdminConfig,
} = useAdminConfig();
useEffect(() => { ensureAdminConfigLoaded(); }, [ensureAdminConfigLoaded]);
@@ -79,7 +79,6 @@ export function SectionGlobalSettings() {
maxImeMsiDownloadSizeMB={maxImeMsiDownloadSizeMB}
setMaxImeMsiDownloadSizeMB={setMaxImeMsiDownloadSizeMB}
onSave={handleSaveAdminConfig}
onReset={handleResetAdminConfig}
/>
</>
);
@@ -100,7 +100,7 @@ interface TenantConfigContextValue {
loading: boolean;
/**
* True when the current user may CHANGE tenant configuration (Tenant Admin or Global Admin).
* Operators reach the settings area read-only: sections must render no Save/Reset bar and
* Operators reach the settings area read-only: sections must render no Save bar and
* disable their inputs when this is false. Mirrors the backend write gate (PUT config is
* TenantAdminOrGA), so hiding the affordances is UX — the server enforces regardless.
*/
@@ -172,8 +172,7 @@ interface TenantConfigContextValue {
setModelWhitelist: (v: string) => void;
webhookNotifyOnHardwareRejection: boolean;
setWebhookNotifyOnHardwareRejection: (v: boolean) => void;
handleSaveHardwareWhitelist: () => void;
handleResetHardwareWhitelist: () => void;
handleSaveHardwareWhitelist: () => Promise<void>;
// Agent settings
enablePerformanceCollector: boolean;
@@ -194,8 +193,7 @@ interface TenantConfigContextValue {
setContactEmail: (v: string) => void;
companyName: string;
setCompanyName: (v: string) => void;
handleSaveContact: () => void;
handleResetContact: () => void;
handleSaveContact: () => Promise<void>;
enableGeoLocation: boolean;
setEnableGeoLocation: (v: boolean) => void;
enableTimezoneAutoSet: boolean;
@@ -224,8 +222,8 @@ interface TenantConfigContextValue {
setEnableRealmJoinWatcher: (v: boolean) => void;
enableConfigMgrWatcher: boolean;
setEnableConfigMgrWatcher: (v: boolean) => void;
handleSaveAgentSettings: () => void;
handleResetAgentSettings: () => void;
handleSaveAgentParameters: () => Promise<void>;
handleSaveAgentCollectors: () => Promise<void>;
// Agent analyzers
enableLocalAdminAnalyzer: boolean;
@@ -240,8 +238,7 @@ interface TenantConfigContextValue {
setEnableIntegrityBypassAnalyzer: (v: boolean) => void;
enableConsoleBypassDetection: boolean;
setEnableConsoleBypassDetection: (v: boolean) => void;
handleSaveAgentAnalyzers: () => void;
handleResetAgentAnalyzers: () => void;
handleSaveAgentAnalyzers: () => Promise<void>;
// Unrestricted mode
unrestrictedMode: boolean;
@@ -254,8 +251,7 @@ interface TenantConfigContextValue {
testingChannelId: string | null;
testChannelResult: { channelId: string; success: boolean; message: string } | null;
handleTestChannel: (channelId: string) => Promise<void>;
handleSaveNotifications: () => void;
handleResetNotifications: () => void;
handleSaveNotifications: () => Promise<void>;
// SLA Targets
slaTargetSuccessRate: number | null;
@@ -276,8 +272,7 @@ interface TenantConfigContextValue {
setSlaNotifyOnConsecutiveFailures: (v: boolean) => void;
slaConsecutiveFailureThreshold: number;
setSlaConsecutiveFailureThreshold: (v: number) => void;
handleSaveSlaTargets: () => void;
handleResetSlaTargets: () => void;
handleSaveSlaTargets: () => Promise<void>;
// Diagnostics
diagnosticsBlobSasUrl: string;
@@ -292,8 +287,7 @@ interface TenantConfigContextValue {
setNewDiagPath: (v: string) => void;
newDiagDesc: string;
setNewDiagDesc: (v: string) => void;
handleSaveDiagnostics: () => void;
handleResetDiagnostics: () => void;
handleSaveDiagnostics: () => Promise<void>;
// Admin management
admins: TenantAdmin[];
@@ -325,8 +319,7 @@ interface TenantConfigContextValue {
setDataRetentionDays: (v: number) => void;
sessionTimeoutHours: number;
setSessionTimeoutHours: (v: number) => void;
handleSaveDataManagement: () => void;
handleResetDataManagement: () => void;
handleSaveDataManagement: () => Promise<void>;
// Offboarding
showOffboardDialog: boolean;
@@ -367,7 +360,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const { addNotification } = useNotifications();
// Operators reach the settings area read-only — only a Tenant Admin or Global Admin may
// change configuration. Drives section rendering (no Save/Reset bar, disabled inputs)
// change configuration. Drives section rendering (no Save bar, disabled inputs)
// and the defensive guard in saveConfiguration; the backend enforces the same gate.
const canEditConfig = user?.isTenantAdmin === true || user?.isGlobalAdmin === true;
@@ -727,10 +720,11 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
// Resolves to null when the save went through, otherwise to the text of the failure.
const runSectionSave = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<string | null> => {
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PATCH regardless).
if (!tenantId || !config || !canEditConfig) return false;
if (!tenantId || !config || !canEditConfig) return "This configuration cannot be edited right now.";
// The validation gates and Unrestricted Mode have no save bar, so their form state must always
// equal the stored row. Some of their toggles move the state before this save runs, so a failed
@@ -832,7 +826,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
if (await patchTenantConfigFields(api.config.fields(tenantId), patchFields, `settings:${sectionName}`, getAccessToken)) {
// The feature flags derive from these fields: the cached copy is stale from here on.
invalidateCachedAuthFetch(CONFIG_PATH_PREFIX);
setConfig({ ...config, ...patchFields } as TenantConfiguration);
// Functional merge: the Agent Settings page has two Save bars, and a save still in flight
// must not lose its fields to the later one that started from the same loaded config.
setConfig((prev) => ({ ...(prev ?? config), ...patchFields }) as TenantConfiguration);
}
// Sync the gate-relevant form state with what is now persisted (merge of loaded
@@ -841,13 +837,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length });
setSuccessMessage("Configuration saved successfully!");
setTimeout(() => setSuccessMessage(null), 3000);
return true;
return null;
} catch (err) {
syncGateState(config);
const msg = apiErrorText(err, "Failed to save configuration");
trackEvent("settings_error", { action: "save", section: sectionName, error: msg });
setError(msg);
return false;
return msg;
} finally {
setSavingSection(null);
}
@@ -868,6 +864,19 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableIntegrityBypassAnalyzer, enableConsoleBypassDetection, unrestrictedMode,
]);
// Flows without a save bar (validation gates, Unrestricted Mode, consent) branch on the outcome.
const saveConfiguration = useCallback(
async (sectionName: SettingsSectionName, overrides?: Parameters<typeof runSectionSave>[1]) =>
(await runSectionSave(sectionName, overrides)) === null,
[runSectionSave],
);
// Save bars: a failure throws, so SaveBar shows its reason next to the button.
const saveSection = useCallback(async (sectionName: SettingsSectionName) => {
const failure = await runSectionSave(sectionName);
if (failure !== null) throw new Error(failure);
}, [runSectionSave]);
// -----------------------------------------------------------------------
// Consent flow
// -----------------------------------------------------------------------
@@ -1178,22 +1187,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}, [tenantId, getAccessToken]);
// -----------------------------------------------------------------------
// Per-section save/reset handlers
// Per-section save handlers
// -----------------------------------------------------------------------
const handleSaveHardwareWhitelist = useCallback(() => saveConfiguration("hardwareWhitelist"), [saveConfiguration]);
const handleResetHardwareWhitelist = useCallback(() => {
if (!config) return;
setManufacturerWhitelist(config.manufacturerWhitelist);
setModelWhitelist(config.modelWhitelist);
setWebhookNotifyOnHardwareRejection(config.webhookNotifyOnHardwareRejection ?? false);
// The hardware-rejection toggle writes through to the per-channel flags — restore those
// to their saved values too, without disturbing other unsaved channel edits.
const savedById = new Map(channelsFromConfig(config).map((c) => [c.id, c]));
setNotificationChannels((prev) => prev.map((c) => {
const saved = savedById.get(c.id);
return saved ? { ...c, notifyOnHardwareRejection: saved.notifyOnHardwareRejection } : c;
}));
}, [config]);
const handleSaveHardwareWhitelist = useCallback(() => saveSection("hardwareWhitelist"), [saveSection]);
// Hardware-rejection notify: with channels configured, the hardware-section toggle is a
// convenience view over the per-channel flags (checked = any enabled channel opted in;
@@ -1207,92 +1203,21 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setNotificationChannels((prev) => prev.map((c) => ({ ...c, notifyOnHardwareRejection: v })));
}, []);
const handleSaveAgentSettings = useCallback(() => saveConfiguration("agentSettings"), [saveConfiguration]);
const handleResetAgentSettings = useCallback(() => {
if (!config) return;
setEnablePerformanceCollector(config.enablePerformanceCollector ?? true);
setPerformanceCollectorInterval(config.performanceCollectorIntervalSeconds ?? 30);
setHelloWaitTimeoutSeconds(config.helloWaitTimeoutSeconds ?? HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS);
setSelfDestructOnComplete(config.selfDestructOnComplete ?? true);
setKeepLogFile(config.keepLogFile ?? false);
setRebootOnComplete(config.rebootOnComplete ?? false);
setRebootDelaySeconds(config.rebootDelaySeconds ?? 10);
// contactEmail/companyName deliberately NOT reset here — they are owned by the Contact section
// (resetting it from the Agent Settings card silently discarded unsaved Contact edits).
setEnableGeoLocation(config.enableGeoLocation ?? true);
setEnableTimezoneAutoSet(config.enableTimezoneAutoSet ?? false);
setEnableDoGroupIdAutoSet(config.enableDoGroupIdAutoSet ?? false);
setEnableImeMatchLog(config.enableImeMatchLog ?? false);
setEnableGatherRuleDebugLog(config.enableGatherRuleDebugLog ?? false);
setLogLevel(config.logLevel ?? "Info");
setShowScriptOutput(config.showScriptOutput ?? true);
setShowEnrollmentSummary(config.showEnrollmentSummary ?? false);
setEnrollmentSummaryTimeoutSeconds(config.enrollmentSummaryTimeoutSeconds ?? 60);
setEnrollmentSummaryBrandingImageUrl(config.enrollmentSummaryBrandingImageUrl ?? "");
setEnrollmentSummaryLaunchRetrySeconds(config.enrollmentSummaryLaunchRetrySeconds ?? 120);
setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false);
setEnableConfigMgrWatcher(config.enableConfigMgrWatcher ?? false);
setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false);
}, [config]);
const handleSaveAgentParameters = useCallback(() => saveSection("agentParameters"), [saveSection]);
const handleSaveAgentAnalyzers = useCallback(() => saveConfiguration("agentAnalyzers"), [saveConfiguration]);
const handleResetAgentAnalyzers = useCallback(() => {
if (!config) return;
setEnableLocalAdminAnalyzer(config.enableLocalAdminAnalyzer ?? true);
try {
setLocalAdminAllowedAccounts(config.localAdminAllowedAccountsJson ? JSON.parse(config.localAdminAllowedAccountsJson) : []);
} catch { setLocalAdminAllowedAccounts([]); }
setNewAllowedAccount("");
setEnableSoftwareInventoryAnalyzer(config.enableSoftwareInventoryAnalyzer ?? false);
setEnableIntegrityBypassAnalyzer(config.enableIntegrityBypassAnalyzer ?? true);
setEnableConsoleBypassDetection(config.enableConsoleBypassDetection ?? true);
}, [config]);
const handleSaveAgentCollectors = useCallback(() => saveSection("agentCollectors"), [saveSection]);
const handleSaveNotifications = useCallback(() => saveConfiguration("notifications"), [saveConfiguration]);
const handleResetNotifications = useCallback(() => {
if (!config) return;
setNotificationChannels(channelsFromConfig(config));
setTestChannelResult(null);
}, [config]);
const handleSaveAgentAnalyzers = useCallback(() => saveSection("agentAnalyzers"), [saveSection]);
const handleSaveSlaTargets = useCallback(() => saveConfiguration("slaTargets"), [saveConfiguration]);
const handleResetSlaTargets = useCallback(() => {
if (!config) return;
setSlaTargetSuccessRate(config.slaTargetSuccessRate ?? null);
setSlaTargetMaxDurationMinutes(config.slaTargetMaxDurationMinutes ?? null);
setSlaTargetAppInstallSuccessRate(config.slaTargetAppInstallSuccessRate ?? null);
setSlaNotifyOnSuccessRateBreach(config.slaNotifyOnSuccessRateBreach ?? false);
setSlaSuccessRateNotifyThreshold(config.slaSuccessRateNotifyThreshold ?? null);
setSlaNotifyOnDurationBreach(config.slaNotifyOnDurationBreach ?? false);
setSlaNotifyOnAppInstallBreach(config.slaNotifyOnAppInstallBreach ?? false);
setSlaNotifyOnConsecutiveFailures(config.slaNotifyOnConsecutiveFailures ?? false);
setSlaConsecutiveFailureThreshold(config.slaConsecutiveFailureThreshold ?? 5);
}, [config]);
const handleSaveNotifications = useCallback(() => saveSection("notifications"), [saveSection]);
const handleSaveContact = useCallback(() => saveConfiguration("contact"), [saveConfiguration]);
const handleResetContact = useCallback(() => {
if (!config) return;
setContactEmail(config.contactEmail ?? "");
setCompanyName(config.companyName ?? "");
}, [config]);
const handleSaveSlaTargets = useCallback(() => saveSection("slaTargets"), [saveSection]);
const handleSaveDiagnostics = useCallback(() => saveConfiguration("diagnostics"), [saveConfiguration]);
const handleResetDiagnostics = useCallback(() => {
if (!config) return;
setDiagnosticsBlobSasUrl(config.diagnosticsBlobSasUrl ?? "");
setDiagnosticsUploadMode(config.diagnosticsUploadMode ?? "Off");
setDiagnosticsUploadDestination(config.diagnosticsUploadDestination ?? "CustomerSas");
try {
setTenantDiagPaths(config.diagnosticsLogPathsJson ? JSON.parse(config.diagnosticsLogPathsJson) : []);
} catch { setTenantDiagPaths([]); }
}, [config]);
const handleSaveContact = useCallback(() => saveSection("contact"), [saveSection]);
const handleSaveDataManagement = useCallback(() => saveConfiguration("dataManagement"), [saveConfiguration]);
const handleResetDataManagement = useCallback(() => {
if (!config) return;
setDataRetentionDays(config.dataRetentionDays ?? 90);
setSessionTimeoutHours(config.sessionTimeoutHours ?? 5);
}, [config]);
const handleSaveDiagnostics = useCallback(() => saveSection("diagnostics"), [saveSection]);
const handleSaveDataManagement = useCallback(() => saveSection("dataManagement"), [saveSection]);
// Persist the toggle with the new value passed explicitly. The on/off value MUST be threaded
// through as an override rather than read from `unrestrictedMode` state: the toggle calls this
@@ -1588,7 +1513,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection: effectiveHwRejectionNotify,
setWebhookNotifyOnHardwareRejection: setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
handleSaveHardwareWhitelist,
// Agent settings
enablePerformanceCollector, setEnablePerformanceCollector,
@@ -1600,7 +1525,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
rebootDelaySeconds, setRebootDelaySeconds,
contactEmail, setContactEmail,
companyName, setCompanyName,
handleSaveContact, handleResetContact,
handleSaveContact,
enableGeoLocation, setEnableGeoLocation,
enableTimezoneAutoSet, setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet, setEnableDoGroupIdAutoSet,
@@ -1615,7 +1540,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
enableConfigMgrWatcher, setEnableConfigMgrWatcher,
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
handleSaveAgentSettings, handleResetAgentSettings,
handleSaveAgentParameters,
handleSaveAgentCollectors,
// Agent analyzers
enableLocalAdminAnalyzer, setEnableLocalAdminAnalyzer,
@@ -1624,7 +1550,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer,
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
handleSaveAgentAnalyzers,
// Unrestricted mode
unrestrictedMode, setUnrestrictedMode,
@@ -1633,7 +1559,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Notifications
notificationChannels, setNotificationChannels,
testingChannelId, testChannelResult,
handleTestChannel, handleSaveNotifications, handleResetNotifications,
handleTestChannel, handleSaveNotifications,
// SLA Targets
slaTargetSuccessRate, setSlaTargetSuccessRate,
@@ -1645,7 +1571,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
slaNotifyOnAppInstallBreach, setSlaNotifyOnAppInstallBreach,
slaNotifyOnConsecutiveFailures, setSlaNotifyOnConsecutiveFailures,
slaConsecutiveFailureThreshold, setSlaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
handleSaveSlaTargets,
// Diagnostics
diagnosticsBlobSasUrl, setDiagnosticsBlobSasUrl,
@@ -1654,7 +1580,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
tenantDiagPaths, setTenantDiagPaths,
newDiagPath, setNewDiagPath,
newDiagDesc, setNewDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
handleSaveDiagnostics,
// Admin management
admins, loadingAdmins,
@@ -1671,7 +1597,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// Data management
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
handleSaveDataManagement, handleResetDataManagement,
handleSaveDataManagement,
// Offboarding
showOffboardDialog, setShowOffboardDialog,
@@ -1690,32 +1616,33 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateIntuneDeviceBinding, handleToggleIntuneDeviceBinding,
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
handleSaveHardwareWhitelist,
enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds,
selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, companyName, handleSaveContact, handleResetContact,
contactEmail, companyName, handleSaveContact,
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
handleSaveAgentSettings, handleResetAgentSettings,
handleSaveAgentParameters,
handleSaveAgentCollectors,
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
handleSaveAgentAnalyzers,
unrestrictedMode, handleSaveUnrestrictedMode,
notificationChannels, testingChannelId, testChannelResult,
handleTestChannel, handleSaveNotifications, handleResetNotifications,
handleTestChannel, handleSaveNotifications,
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
handleSaveSlaTargets,
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination,
tenantDiagPaths, newDiagPath, newDiagDesc,
handleSaveDiagnostics, handleResetDiagnostics,
handleSaveDiagnostics,
admins, loadingAdmins, newAdminEmail, newMemberRole, newMemberKind, addingAdmin, removingAdmin, togglingAdmin,
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
bootstrapSessions, bootstrapLoading, fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement, handleResetDataManagement,
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement,
showOffboardDialog, offboardConfirmText, offboarding, offboardError, handleOffboard,
offboardingInProgress, handleDrainBarrierElapsed,
user, getAccessToken,
@@ -32,9 +32,10 @@ const EXPECTED_OWNED_FIELDS = [
"slaNotifyOnAppInstallBreach", "slaNotifyOnConsecutiveFailures", "slaConsecutiveFailureThreshold",
// contact
"contactEmail", "companyName",
// agentSettings
// agentCollectors
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
"enableRealmJoinWatcher", "enableConfigMgrWatcher",
// agentParameters
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "keepAwakeDuringUserEsp",
"enableImeMatchLog", "enableGatherRuleDebugLog",
@@ -92,6 +93,15 @@ describe("SECTION_FIELD_MAP parity", () => {
expect(owned).toEqual(EXPECTED_OWNED_FIELDS);
});
it("the Agent Collectors card saves only its own fields", () => {
// One card, one Save: a field moving back into a shared section would make the Save of one
// card write the unsaved edits of the other.
expect([...SECTION_FIELD_MAP.agentCollectors.fields].sort()).toEqual([
"enableConfigMgrWatcher", "enablePerformanceCollector", "enableRealmJoinWatcher",
"helloWaitTimeoutSeconds", "performanceCollectorIntervalSeconds",
]);
});
it("alsoWrites entries never overlap the same section's owned fields", () => {
for (const [section, spec] of sections) {
for (const field of spec.alsoWrites ?? []) {
@@ -13,7 +13,7 @@ export function SectionAgentAnalyzers() {
enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer,
enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer,
enableConsoleBypassDetection, setEnableConsoleBypassDetection,
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
handleSaveAgentAnalyzers,
savingSection,
} = useTenantConfig();
@@ -34,7 +34,6 @@ export function SectionAgentAnalyzers() {
enableConsoleBypassDetection={enableConsoleBypassDetection}
setEnableConsoleBypassDetection={setEnableConsoleBypassDetection}
onSave={handleSaveAgentAnalyzers}
onReset={handleResetAgentAnalyzers}
saving={savingSection === "agentAnalyzers"}
readOnly={!canEditConfig}
/>
@@ -2,7 +2,8 @@
import { useTenantConfig } from "../../TenantConfigContext";
import { TenantNotifications } from "../../TenantNotifications";
import AgentSettingsSection from "../../components/AgentSettingsSection";
import AgentParametersSection from "../../components/AgentParametersSection";
import AgentCollectorsSection from "../../components/AgentCollectorsSection";
export function SectionAgentSettings() {
const {
@@ -28,24 +29,15 @@ export function SectionAgentSettings() {
enrollmentSummaryTimeoutSeconds, setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds,
handleSaveAgentSettings, handleResetAgentSettings,
handleSaveAgentParameters,
handleSaveAgentCollectors,
savingSection,
} = useTenantConfig();
return (
<>
<TenantNotifications />
<AgentSettingsSection
enablePerformanceCollector={enablePerformanceCollector}
setEnablePerformanceCollector={setEnablePerformanceCollector}
performanceCollectorInterval={performanceCollectorInterval}
setPerformanceCollectorInterval={setPerformanceCollectorInterval}
helloWaitTimeoutSeconds={helloWaitTimeoutSeconds}
setHelloWaitTimeoutSeconds={setHelloWaitTimeoutSeconds}
enableRealmJoinWatcher={enableRealmJoinWatcher}
setEnableRealmJoinWatcher={setEnableRealmJoinWatcher}
enableConfigMgrWatcher={enableConfigMgrWatcher}
setEnableConfigMgrWatcher={setEnableConfigMgrWatcher}
<AgentParametersSection
selfDestructOnComplete={selfDestructOnComplete}
setSelfDestructOnComplete={setSelfDestructOnComplete}
keepLogFile={keepLogFile}
@@ -78,9 +70,23 @@ export function SectionAgentSettings() {
setEnrollmentSummaryBrandingImageUrl={setEnrollmentSummaryBrandingImageUrl}
enrollmentSummaryLaunchRetrySeconds={enrollmentSummaryLaunchRetrySeconds}
setEnrollmentSummaryLaunchRetrySeconds={setEnrollmentSummaryLaunchRetrySeconds}
onSave={handleSaveAgentSettings}
onReset={handleResetAgentSettings}
saving={savingSection === "agentSettings"}
onSave={handleSaveAgentParameters}
saving={savingSection === "agentParameters"}
readOnly={!canEditConfig}
/>
<AgentCollectorsSection
enablePerformanceCollector={enablePerformanceCollector}
setEnablePerformanceCollector={setEnablePerformanceCollector}
performanceCollectorInterval={performanceCollectorInterval}
setPerformanceCollectorInterval={setPerformanceCollectorInterval}
helloWaitTimeoutSeconds={helloWaitTimeoutSeconds}
setHelloWaitTimeoutSeconds={setHelloWaitTimeoutSeconds}
enableRealmJoinWatcher={enableRealmJoinWatcher}
setEnableRealmJoinWatcher={setEnableRealmJoinWatcher}
enableConfigMgrWatcher={enableConfigMgrWatcher}
setEnableConfigMgrWatcher={setEnableConfigMgrWatcher}
onSave={handleSaveAgentCollectors}
saving={savingSection === "agentCollectors"}
readOnly={!canEditConfig}
/>
</>
@@ -16,7 +16,7 @@ export function SectionDiagnostics() {
newDiagPath, setNewDiagPath,
newDiagDesc, setNewDiagDesc,
unrestrictedMode,
handleSaveDiagnostics, handleResetDiagnostics,
handleSaveDiagnostics,
savingSection,
} = useTenantConfig();
// Built-in sections + platform-wide paths (MemberRead) — read-only context for every role.
@@ -46,7 +46,6 @@ export function SectionDiagnostics() {
setNewDiagDesc={setNewDiagDesc}
unrestrictedMode={unrestrictedMode}
onSave={handleSaveDiagnostics}
onReset={handleResetDiagnostics}
saving={savingSection === "diagnostics"}
readOnly={!canEditConfig}
/>
@@ -1,6 +1,6 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -19,9 +19,8 @@ interface AgentAnalyzersSectionProps {
enableConsoleBypassDetection: boolean;
setEnableConsoleBypassDetection: (value: boolean) => void;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): analyzers visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): analyzers visible but inert, no Save bar. */
readOnly?: boolean;
}
@@ -53,7 +52,6 @@ export default function AgentAnalyzersSection({
enableConsoleBypassDetection,
setEnableConsoleBypassDetection,
onSave,
onReset,
saving,
readOnly = false,
}: AgentAnalyzersSectionProps) {
@@ -274,8 +272,8 @@ export default function AgentAnalyzersSection({
</div>
</ReadOnlyFieldset>
{/* Save / Reset */}
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{/* Save */}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -0,0 +1,154 @@
"use client";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import {
clampHelloWaitTimeoutSeconds,
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
} from "../lib/helloWaitTimeout";
interface AgentCollectorsSectionProps {
enablePerformanceCollector: boolean;
setEnablePerformanceCollector: (value: boolean) => void;
performanceCollectorInterval: number;
setPerformanceCollectorInterval: (value: number) => void;
helloWaitTimeoutSeconds: number;
setHelloWaitTimeoutSeconds: (value: number) => void;
enableRealmJoinWatcher: boolean;
setEnableRealmJoinWatcher: (value: boolean) => void;
enableConfigMgrWatcher: boolean;
setEnableConfigMgrWatcher: (value: boolean) => void;
onSave: () => Promise<void> | void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
readOnly?: boolean;
}
export default function AgentCollectorsSection({
enablePerformanceCollector,
setEnablePerformanceCollector,
performanceCollectorInterval,
setPerformanceCollectorInterval,
helloWaitTimeoutSeconds,
setHelloWaitTimeoutSeconds,
enableRealmJoinWatcher,
setEnableRealmJoinWatcher,
enableConfigMgrWatcher,
setEnableConfigMgrWatcher,
onSave,
saving,
readOnly = false,
}: AgentCollectorsSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="emerald"
iconPath="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
title="Agent Collectors"
subtitle="Enable optional data collectors on enrolled devices. These generate additional telemetry traffic."
docsPath={DOCS_PATHS.agentCollectors}
/>
<div className="p-6 space-y-5">
{/* notice={false}: the Agent Parameters card above already shows the read-only notice. */}
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Performance Collector</p>
</div>
<p className="text-sm text-gray-500 mt-1">CPU, memory, disk metrics at configurable intervals</p>
{enablePerformanceCollector && (
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min={30}
max={300}
value={performanceCollectorInterval}
onChange={(e) => setPerformanceCollectorInterval(parseInt(e.target.value) || 30)}
onBlur={() => setPerformanceCollectorInterval(Math.max(30, Math.min(300, performanceCollectorInterval)))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 300 seconds (5 minutes)</p>
</div>
)}
</div>
<button
onClick={() => setEnablePerformanceCollector(!enablePerformanceCollector)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enablePerformanceCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enablePerformanceCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* RealmJoin Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">RealmJoin Watcher</p>
<p className="text-sm text-gray-500 mt-1">Tracks RealmJoin deployment state during provisioning — deployment phase, per-package start and completion, and the RealmJoin completion gate. Off by default; enable only for tenants that provision devices with RealmJoin, elsewhere it produces no signal.</p>
</div>
<button
onClick={() => setEnableRealmJoinWatcher(!enableRealmJoinWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableRealmJoinWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableRealmJoinWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* ConfigMgr Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">ConfigMgr Watcher</p>
<p className="text-sm text-gray-500 mt-1">Shows in Session Info whether the Configuration Manager client is installed, with its service state, version and site code. Off by default; enable for tenants that co-manage devices with Configuration Manager.</p>
</div>
<button
onClick={() => setEnableConfigMgrWatcher(!enableConfigMgrWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableConfigMgrWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableConfigMgrWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Hello Wait Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Hello Wait Timeout</p>
</div>
<p className="text-sm text-gray-500 mt-1">Seconds to wait for the Windows Hello wizard after ESP exit</p>
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Timeout:</span>
<input
type="number"
min={HELLO_WAIT_TIMEOUT_MIN_SECONDS}
max={HELLO_WAIT_TIMEOUT_MAX_SECONDS}
value={helloWaitTimeoutSeconds}
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS)}
onBlur={() => setHelloWaitTimeoutSeconds(clampHelloWaitTimeoutSeconds(helloWaitTimeoutSeconds))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 3600 seconds (1 hour). Values up to 300 seconds use the built-in 5-minute wait.</p>
</div>
</div>
</div>
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
}
@@ -0,0 +1,342 @@
"use client";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
interface AgentParametersSectionProps {
selfDestructOnComplete: boolean;
setSelfDestructOnComplete: (value: boolean) => void;
keepLogFile: boolean;
setKeepLogFile: (value: boolean) => void;
rebootOnComplete: boolean;
setRebootOnComplete: (value: boolean) => void;
rebootDelaySeconds: number;
setRebootDelaySeconds: (value: number) => void;
enableGeoLocation: boolean;
setEnableGeoLocation: (value: boolean) => void;
enableTimezoneAutoSet: boolean;
setEnableTimezoneAutoSet: (value: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (value: boolean) => void;
keepAwakeDuringUserEsp: boolean;
setKeepAwakeDuringUserEsp: (value: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (value: boolean) => void;
enableGatherRuleDebugLog: boolean;
setEnableGatherRuleDebugLog: (value: boolean) => void;
logLevel: string;
setLogLevel: (value: string) => void;
showScriptOutput: boolean;
setShowScriptOutput: (value: boolean) => void;
showEnrollmentSummary: boolean;
setShowEnrollmentSummary: (value: boolean) => void;
enrollmentSummaryTimeoutSeconds: number;
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
enrollmentSummaryBrandingImageUrl: string;
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
enrollmentSummaryLaunchRetrySeconds: number;
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
onSave: () => Promise<void> | void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
readOnly?: boolean;
}
export default function AgentParametersSection({
selfDestructOnComplete,
setSelfDestructOnComplete,
keepLogFile,
setKeepLogFile,
rebootOnComplete,
setRebootOnComplete,
rebootDelaySeconds,
setRebootDelaySeconds,
enableGeoLocation,
setEnableGeoLocation,
enableTimezoneAutoSet,
setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet,
setEnableDoGroupIdAutoSet,
keepAwakeDuringUserEsp,
setKeepAwakeDuringUserEsp,
enableImeMatchLog,
setEnableImeMatchLog,
enableGatherRuleDebugLog,
setEnableGatherRuleDebugLog,
logLevel,
setLogLevel,
showScriptOutput,
setShowScriptOutput,
showEnrollmentSummary,
setShowEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl,
setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds,
setEnrollmentSummaryLaunchRetrySeconds,
onSave,
saving,
readOnly = false,
}: AgentParametersSectionProps) {
return (
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="violet"
iconPath="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
title="Agent Parameters"
subtitle="Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh."
docsPath={DOCS_PATHS.agentParameters}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Self-Destruct on Complete</p>
<p className="text-sm text-gray-500">Remove Scheduled Task and all agent files when enrollment completes</p>
</div>
<button onClick={() => setSelfDestructOnComplete(!selfDestructOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${selfDestructOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${selfDestructOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Log File */}
{selfDestructOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Keep Log File</p>
<p className="text-sm text-gray-500">Preserve the agent log during self-destruct (all other files are removed)</p>
</div>
<button onClick={() => setKeepLogFile(!keepLogFile)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepLogFile ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepLogFile ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Reboot on Complete */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Reboot on Complete</p>
<p className="text-sm text-gray-500">Reboot the device after enrollment completes (and after self-destruct if enabled)</p>
</div>
<button onClick={() => setRebootOnComplete(!rebootOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${rebootOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rebootOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{rebootOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Reboot Delay</p>
<p className="text-sm text-gray-500">Seconds before reboot is initiated — gives the user time to see what is happening</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={rebootDelaySeconds}
onChange={(e) => setRebootDelaySeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
)}
{/* Geo Location */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Geo-Location Detection</p>
<p className="text-sm text-gray-500">Capture device location, ISP and network info at enrollment start (queries external IP service)</p>
</div>
<button onClick={() => setEnableGeoLocation(!enableGeoLocation)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGeoLocation ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGeoLocation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Timezone Auto-Set (sub-toggle of Geo-Location) */}
{enableGeoLocation && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Set Timezone Automatically</p>
<p className="text-sm text-gray-500">Set the device timezone based on IP geolocation result (uses tzutil /s)</p>
</div>
<button onClick={() => setEnableTimezoneAutoSet(!enableTimezoneAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableTimezoneAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableTimezoneAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Delivery Optimization Group ID */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Set Delivery Optimization Group ID</p>
<p className="text-sm text-gray-500">
Set the DOGroupId policy from a network fingerprint — a deterministic GUID derived from the default gateway&apos;s IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode &quot;Group (2)&quot;; an existing DOGroupId or DOGroupIdSource policy (Intune/GPO) is never overwritten.
</p>
</div>
<button onClick={() => setEnableDoGroupIdAutoSet(!enableDoGroupIdAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableDoGroupIdAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableDoGroupIdAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Awake During User-ESP */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Keep Awake During User-ESP</p>
<p className="text-sm text-gray-500">Hold the device awake (system and display) during the User-ESP (Account Setup) phase so idle standby or sleep cannot stall app installs and account provisioning. Reboots are unaffected; the hold is released once the phase completes.</p>
</div>
<button onClick={() => setKeepAwakeDuringUserEsp(!keepAwakeDuringUserEsp)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepAwakeDuringUserEsp ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepAwakeDuringUserEsp ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* IME Match Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">IME Pattern Match Log</p>
<p className="text-sm text-gray-500">
Write every matched IME log line to a local file for diagnostics
{enableImeMatchLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\ime-pattern-matches.log</span>}
</p>
</div>
<button onClick={() => setEnableImeMatchLog(!enableImeMatchLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableImeMatchLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableImeMatchLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Gather Rule Debug Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Gather Rule Debug Log</p>
<p className="text-sm text-gray-500">
Trace gather rule evaluation to a local file to diagnose rules that produce no timeline events. The trace never leaves the device.
{enableGatherRuleDebugLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log</span>}
</p>
</div>
<button onClick={() => setEnableGatherRuleDebugLog(!enableGatherRuleDebugLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGatherRuleDebugLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGatherRuleDebugLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Show Script Output */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Script Output (stdout)</p>
<p className="text-sm text-gray-500">Show standard output from PowerShell scripts in the timeline. Disable if scripts may output sensitive data. Error output (stderr) is always shown.</p>
</div>
<button onClick={() => setShowScriptOutput(!showScriptOutput)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showScriptOutput ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showScriptOutput ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Log Level */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Log Level</p>
<p className="text-sm text-gray-500">Agent log verbosity — Info shows the enrollment flow, Debug adds detail, Verbose adds every detail, Trace adds each internal step (every watcher, poll and queue event)</p>
</div>
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Verbose">Verbose</option>
<option value="Trace">Trace</option>
</select>
</div>
{/* Enrollment Summary Dialog */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Enrollment Summary</p>
<p className="text-sm text-gray-500">Display a visual enrollment summary dialog to the end user after enrollment completes (success or failure). Requires the SummaryDialog companion to be deployed alongside the agent.</p>
</div>
<button onClick={() => setShowEnrollmentSummary(!showEnrollmentSummary)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showEnrollmentSummary ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showEnrollmentSummary ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{showEnrollmentSummary && (
<>
{/* Auto-close timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Auto-Close Timeout</p>
<p className="text-sm text-gray-500">Seconds until the summary dialog closes automatically. 0 = no auto-close.</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryTimeoutSeconds}
onChange={(e) => setEnrollmentSummaryTimeoutSeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Launch Retry Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Launch Retry Timeout</p>
<p className="text-sm text-gray-500">How long the agent retries launching the dialog when the desktop is locked by a credential UI (e.g. Windows Hello). 0 = no retry.</p>
</div>
<div className="flex items-center gap-2 ml-4">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryLaunchRetrySeconds}
onChange={(e) => setEnrollmentSummaryLaunchRetrySeconds(Math.min(3600, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Branding Image URL */}
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div className="mb-2">
<p className="font-medium text-gray-900">Branding Image URL</p>
<p className="text-sm text-gray-500">Optional banner image at the top of the summary dialog. Recommended size: 540 x 80 px. Larger images will be center-cropped.</p>
</div>
<input
type="url"
value={enrollmentSummaryBrandingImageUrl}
onChange={(e) => setEnrollmentSummaryBrandingImageUrl(e.target.value)}
placeholder="https://example.com/branding-banner.png"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
</>
)}
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
}
@@ -1,480 +0,0 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
import {
clampHelloWaitTimeoutSeconds,
HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS,
HELLO_WAIT_TIMEOUT_MAX_SECONDS,
HELLO_WAIT_TIMEOUT_MIN_SECONDS,
} from "../lib/helloWaitTimeout";
interface AgentSettingsSectionProps {
enablePerformanceCollector: boolean;
setEnablePerformanceCollector: (value: boolean) => void;
performanceCollectorInterval: number;
setPerformanceCollectorInterval: (value: number) => void;
helloWaitTimeoutSeconds: number;
setHelloWaitTimeoutSeconds: (value: number) => void;
enableRealmJoinWatcher: boolean;
setEnableRealmJoinWatcher: (value: boolean) => void;
enableConfigMgrWatcher: boolean;
setEnableConfigMgrWatcher: (value: boolean) => void;
selfDestructOnComplete: boolean;
setSelfDestructOnComplete: (value: boolean) => void;
keepLogFile: boolean;
setKeepLogFile: (value: boolean) => void;
rebootOnComplete: boolean;
setRebootOnComplete: (value: boolean) => void;
rebootDelaySeconds: number;
setRebootDelaySeconds: (value: number) => void;
enableGeoLocation: boolean;
setEnableGeoLocation: (value: boolean) => void;
enableTimezoneAutoSet: boolean;
setEnableTimezoneAutoSet: (value: boolean) => void;
enableDoGroupIdAutoSet: boolean;
setEnableDoGroupIdAutoSet: (value: boolean) => void;
keepAwakeDuringUserEsp: boolean;
setKeepAwakeDuringUserEsp: (value: boolean) => void;
enableImeMatchLog: boolean;
setEnableImeMatchLog: (value: boolean) => void;
enableGatherRuleDebugLog: boolean;
setEnableGatherRuleDebugLog: (value: boolean) => void;
logLevel: string;
setLogLevel: (value: string) => void;
showScriptOutput: boolean;
setShowScriptOutput: (value: boolean) => void;
showEnrollmentSummary: boolean;
setShowEnrollmentSummary: (value: boolean) => void;
enrollmentSummaryTimeoutSeconds: number;
setEnrollmentSummaryTimeoutSeconds: (value: number) => void;
enrollmentSummaryBrandingImageUrl: string;
setEnrollmentSummaryBrandingImageUrl: (value: string) => void;
enrollmentSummaryLaunchRetrySeconds: number;
setEnrollmentSummaryLaunchRetrySeconds: (value: number) => void;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
readOnly?: boolean;
}
export default function AgentSettingsSection({
enablePerformanceCollector,
setEnablePerformanceCollector,
performanceCollectorInterval,
setPerformanceCollectorInterval,
helloWaitTimeoutSeconds,
setHelloWaitTimeoutSeconds,
enableRealmJoinWatcher,
setEnableRealmJoinWatcher,
enableConfigMgrWatcher,
setEnableConfigMgrWatcher,
selfDestructOnComplete,
setSelfDestructOnComplete,
keepLogFile,
setKeepLogFile,
rebootOnComplete,
setRebootOnComplete,
rebootDelaySeconds,
setRebootDelaySeconds,
enableGeoLocation,
setEnableGeoLocation,
enableTimezoneAutoSet,
setEnableTimezoneAutoSet,
enableDoGroupIdAutoSet,
setEnableDoGroupIdAutoSet,
keepAwakeDuringUserEsp,
setKeepAwakeDuringUserEsp,
enableImeMatchLog,
setEnableImeMatchLog,
enableGatherRuleDebugLog,
setEnableGatherRuleDebugLog,
logLevel,
setLogLevel,
showScriptOutput,
setShowScriptOutput,
showEnrollmentSummary,
setShowEnrollmentSummary,
enrollmentSummaryTimeoutSeconds,
setEnrollmentSummaryTimeoutSeconds,
enrollmentSummaryBrandingImageUrl,
setEnrollmentSummaryBrandingImageUrl,
enrollmentSummaryLaunchRetrySeconds,
setEnrollmentSummaryLaunchRetrySeconds,
onSave,
onReset,
saving,
readOnly = false,
}: AgentSettingsSectionProps) {
return (
<>
{/* Agent Parameters */}
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="violet"
iconPath="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
title="Agent Parameters"
subtitle="Control agent behavior on enrolled devices. Changes take effect on the next agent config refresh."
docsPath={DOCS_PATHS.agentParameters}
/>
<div className="p-6 space-y-4">
<ReadOnlyFieldset readOnly={readOnly}>
<div className="space-y-4">
{/* Self-Destruct */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Self-Destruct on Complete</p>
<p className="text-sm text-gray-500">Remove Scheduled Task and all agent files when enrollment completes</p>
</div>
<button onClick={() => setSelfDestructOnComplete(!selfDestructOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${selfDestructOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${selfDestructOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Log File */}
{selfDestructOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Keep Log File</p>
<p className="text-sm text-gray-500">Preserve the agent log during self-destruct (all other files are removed)</p>
</div>
<button onClick={() => setKeepLogFile(!keepLogFile)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepLogFile ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepLogFile ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Reboot on Complete */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Reboot on Complete</p>
<p className="text-sm text-gray-500">Reboot the device after enrollment completes (and after self-destruct if enabled)</p>
</div>
<button onClick={() => setRebootOnComplete(!rebootOnComplete)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${rebootOnComplete ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${rebootOnComplete ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{rebootOnComplete && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Reboot Delay</p>
<p className="text-sm text-gray-500">Seconds before reboot is initiated — gives the user time to see what is happening</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={rebootDelaySeconds}
onChange={(e) => setRebootDelaySeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
)}
{/* Geo Location */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Geo-Location Detection</p>
<p className="text-sm text-gray-500">Capture device location, ISP and network info at enrollment start (queries external IP service)</p>
</div>
<button onClick={() => setEnableGeoLocation(!enableGeoLocation)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGeoLocation ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGeoLocation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Timezone Auto-Set (sub-toggle of Geo-Location) */}
{enableGeoLocation && (
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Set Timezone Automatically</p>
<p className="text-sm text-gray-500">Set the device timezone based on IP geolocation result (uses tzutil /s)</p>
</div>
<button onClick={() => setEnableTimezoneAutoSet(!enableTimezoneAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableTimezoneAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableTimezoneAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
)}
{/* Delivery Optimization Group ID */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Set Delivery Optimization Group ID</p>
<p className="text-sm text-gray-500">
Set the DOGroupId policy from a network fingerprint — a deterministic GUID derived from the default gateway&apos;s IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode &quot;Group (2)&quot;; an existing DOGroupId or DOGroupIdSource policy (Intune/GPO) is never overwritten.
</p>
</div>
<button onClick={() => setEnableDoGroupIdAutoSet(!enableDoGroupIdAutoSet)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableDoGroupIdAutoSet ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableDoGroupIdAutoSet ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Keep Awake During User-ESP */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Keep Awake During User-ESP</p>
<p className="text-sm text-gray-500">Hold the device awake (system and display) during the User-ESP (Account Setup) phase so idle standby or sleep cannot stall app installs and account provisioning. Reboots are unaffected; the hold is released once the phase completes.</p>
</div>
<button onClick={() => setKeepAwakeDuringUserEsp(!keepAwakeDuringUserEsp)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepAwakeDuringUserEsp ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepAwakeDuringUserEsp ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* IME Match Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">IME Pattern Match Log</p>
<p className="text-sm text-gray-500">
Write every matched IME log line to a local file for diagnostics
{enableImeMatchLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\ime-pattern-matches.log</span>}
</p>
</div>
<button onClick={() => setEnableImeMatchLog(!enableImeMatchLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableImeMatchLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableImeMatchLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Gather Rule Debug Log */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Gather Rule Debug Log</p>
<p className="text-sm text-gray-500">
Trace gather rule evaluation to a local file to diagnose rules that produce no timeline events. The trace never leaves the device.
{enableGatherRuleDebugLog && <span className="block text-xs text-gray-400 mt-0.5 font-mono">%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log</span>}
</p>
</div>
<button onClick={() => setEnableGatherRuleDebugLog(!enableGatherRuleDebugLog)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableGatherRuleDebugLog ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableGatherRuleDebugLog ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Show Script Output */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Script Output (stdout)</p>
<p className="text-sm text-gray-500">Show standard output from PowerShell scripts in the timeline. Disable if scripts may output sensitive data. Error output (stderr) is always shown.</p>
</div>
<button onClick={() => setShowScriptOutput(!showScriptOutput)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showScriptOutput ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showScriptOutput ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Log Level */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Log Level</p>
<p className="text-sm text-gray-500">Agent log verbosity — Info shows the enrollment flow, Debug adds detail, Verbose adds every detail, Trace adds each internal step (every watcher, poll and queue event)</p>
</div>
<select
value={logLevel}
onChange={(e) => setLogLevel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
>
<option value="Info">Info</option>
<option value="Debug">Debug</option>
<option value="Verbose">Verbose</option>
<option value="Trace">Trace</option>
</select>
</div>
{/* Enrollment Summary Dialog */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">Show Enrollment Summary</p>
<p className="text-sm text-gray-500">Display a visual enrollment summary dialog to the end user after enrollment completes (success or failure). Requires the SummaryDialog companion to be deployed alongside the agent.</p>
</div>
<button onClick={() => setShowEnrollmentSummary(!showEnrollmentSummary)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${showEnrollmentSummary ? 'bg-violet-500' : 'bg-gray-300'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${showEnrollmentSummary ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{showEnrollmentSummary && (
<>
{/* Auto-close timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Auto-Close Timeout</p>
<p className="text-sm text-gray-500">Seconds until the summary dialog closes automatically. 0 = no auto-close.</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryTimeoutSeconds}
onChange={(e) => setEnrollmentSummaryTimeoutSeconds(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Launch Retry Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div>
<p className="font-medium text-gray-900">Launch Retry Timeout</p>
<p className="text-sm text-gray-500">How long the agent retries launching the dialog when the desktop is locked by a credential UI (e.g. Windows Hello). 0 = no retry.</p>
</div>
<div className="flex items-center gap-2 ml-4">
<input
type="number"
min={0}
max={3600}
value={enrollmentSummaryLaunchRetrySeconds}
onChange={(e) => setEnrollmentSummaryLaunchRetrySeconds(Math.min(3600, Math.max(0, parseInt(e.target.value) || 0)))}
className="w-20 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 text-right focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
<span className="text-sm text-gray-500 whitespace-nowrap">seconds</span>
</div>
</div>
{/* Branding Image URL */}
<div className="p-4 rounded-lg border border-gray-200 hover:border-violet-200 transition-colors ml-4">
<div className="mb-2">
<p className="font-medium text-gray-900">Branding Image URL</p>
<p className="text-sm text-gray-500">Optional banner image at the top of the summary dialog. Recommended size: 540 x 80 px. Larger images will be center-cropped.</p>
</div>
<input
type="url"
value={enrollmentSummaryBrandingImageUrl}
onChange={(e) => setEnrollmentSummaryBrandingImageUrl(e.target.value)}
placeholder="https://example.com/branding-banner.png"
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-violet-500 focus:border-violet-500"
/>
</div>
</>
)}
</div>
</ReadOnlyFieldset>
</div>
</div>
{/* Agent Collectors */}
<div className="bg-white rounded-lg shadow">
<SectionCardHeader
tone="emerald"
iconPath="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
title="Agent Collectors"
subtitle="Enable optional data collectors on enrolled devices. These generate additional telemetry traffic."
docsPath={DOCS_PATHS.agentCollectors}
/>
<div className="p-6 space-y-5">
<ReadOnlyFieldset readOnly={readOnly} notice={false}>
<div className="space-y-5">
{/* Performance Collector */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Performance Collector</p>
</div>
<p className="text-sm text-gray-500 mt-1">CPU, memory, disk metrics at configurable intervals</p>
{enablePerformanceCollector && (
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Interval:</span>
<input
type="number"
min={30}
max={300}
value={performanceCollectorInterval}
onChange={(e) => setPerformanceCollectorInterval(parseInt(e.target.value) || 30)}
onBlur={() => setPerformanceCollectorInterval(Math.max(30, Math.min(300, performanceCollectorInterval)))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 300 seconds (5 minutes)</p>
</div>
)}
</div>
<button
onClick={() => setEnablePerformanceCollector(!enablePerformanceCollector)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enablePerformanceCollector ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enablePerformanceCollector ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* RealmJoin Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">RealmJoin Watcher</p>
<p className="text-sm text-gray-500 mt-1">Tracks RealmJoin deployment state during provisioning — deployment phase, per-package start and completion, and the RealmJoin completion gate. Off by default; enable only for tenants that provision devices with RealmJoin, elsewhere it produces no signal.</p>
</div>
<button
onClick={() => setEnableRealmJoinWatcher(!enableRealmJoinWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableRealmJoinWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableRealmJoinWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* ConfigMgr Watcher */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<p className="font-medium text-gray-900">ConfigMgr Watcher</p>
<p className="text-sm text-gray-500 mt-1">Shows in Session Info whether the Configuration Manager client is installed, with its service state, version and site code. Off by default; enable for tenants that co-manage devices with Configuration Manager.</p>
</div>
<button
onClick={() => setEnableConfigMgrWatcher(!enableConfigMgrWatcher)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableConfigMgrWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableConfigMgrWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{/* Hello Wait Timeout */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-medium text-gray-900">Hello Wait Timeout</p>
</div>
<p className="text-sm text-gray-500 mt-1">Seconds to wait for the Windows Hello wizard after ESP exit</p>
<div className="mt-2">
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">Timeout:</span>
<input
type="number"
min={HELLO_WAIT_TIMEOUT_MIN_SECONDS}
max={HELLO_WAIT_TIMEOUT_MAX_SECONDS}
value={helloWaitTimeoutSeconds}
onChange={(e) => setHelloWaitTimeoutSeconds(parseInt(e.target.value) || HELLO_WAIT_TIMEOUT_DEFAULT_SECONDS)}
onBlur={() => setHelloWaitTimeoutSeconds(clampHelloWaitTimeoutSeconds(helloWaitTimeoutSeconds))}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm text-gray-900 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-sm text-gray-500">seconds</span>
</div>
<p className="text-xs text-gray-400 mt-1">Minimum: 30 seconds, Maximum: 3600 seconds (1 hour). Values up to 300 seconds use the built-in 5-minute wait.</p>
</div>
</div>
</div>
</div>
</ReadOnlyFieldset>
{/* One Save/Reset bar for both cards: Agent Parameters and Agent Collectors are one
section (agentSettings) and are saved together. */}
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
</div>
</div>
</>
);
}
@@ -1,6 +1,6 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -14,9 +14,8 @@ interface DataManagementSectionProps {
/** Edition retention cap (Community 90 / Pro 365) — from feature-flags entitlements. */
retentionCapDays?: number;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): values visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): values visible but inert, no Save bar. */
readOnly?: boolean;
}
@@ -28,7 +27,6 @@ export default function DataManagementSection({
isGlobalAdmin,
retentionCapDays = 90,
onSave,
onReset,
saving,
readOnly = false,
}: DataManagementSectionProps) {
@@ -125,7 +123,7 @@ export default function DataManagementSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -7,7 +7,7 @@ import { validateDiagnosticsPath } from "@/lib/guardValidation";
import { ValidationIndicator } from "@/components/ValidationIndicator";
import { BuiltInSectionsList } from "@/components/diagnostics/BuiltInSectionsList";
import { ContextPill, DiagnosticsPathRow } from "@/components/diagnostics/DiagnosticsPathRow";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { parseSasExpiry } from "./diagnosticsSasExpiry";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -39,9 +39,8 @@ interface DiagnosticsSectionProps {
setNewDiagDesc: (value: string) => void;
unrestrictedMode?: boolean;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): settings visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): settings visible but inert, no Save bar. */
readOnly?: boolean;
}
@@ -64,7 +63,6 @@ export default function DiagnosticsSection({
setNewDiagDesc,
unrestrictedMode = false,
onSave,
onReset,
saving,
readOnly = false,
}: DiagnosticsSectionProps) {
@@ -368,7 +366,7 @@ export default function DiagnosticsSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { joinList, parseList } from "../lib/hardwareWhitelist";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -13,9 +13,8 @@ interface HardwareWhitelistSectionProps {
modelWhitelist: string;
setModelWhitelist: (value: string) => void;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): inputs disabled, no Save/Reset bar. */
/** Read-only viewer (Operator): inputs disabled, no Save bar. */
readOnly?: boolean;
}
@@ -129,7 +128,6 @@ export default function HardwareWhitelistSection({
modelWhitelist,
setModelWhitelist,
onSave,
onReset,
saving,
readOnly = false,
}: HardwareWhitelistSectionProps) {
@@ -188,7 +186,7 @@ export default function HardwareWhitelistSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import SaveResetBar from "./SaveResetBar";
import SaveBar from "./SaveBar";
import ReadOnlyFieldset from "./ReadOnlyFieldset";
import { MAX_NOTIFICATION_CHANNELS, NotificationChannel } from "../types";
import { ChannelEditor } from "@/components/notifications/ChannelEditor";
@@ -16,9 +16,8 @@ interface NotificationsSectionProps {
testingChannelId: string | null;
testChannelResult: { channelId: string; success: boolean; message: string } | null;
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** Read-only viewer (Operator): channels visible but inert, no Save/Reset bar. */
/** Read-only viewer (Operator): channels visible but inert, no Save bar. */
readOnly?: boolean;
/** Global Admin: the platform-bot Telegram provider is offered in the provider dropdown. */
showTelegramProvider?: boolean;
@@ -34,7 +33,6 @@ export default function NotificationsSection({
testingChannelId,
testChannelResult,
onSave,
onReset,
saving,
readOnly = false,
showTelegramProvider = false,
@@ -103,7 +101,7 @@ export default function NotificationsSection({
</div>
</ReadOnlyFieldset>
{!readOnly && <SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />}
{!readOnly && <SaveBar onSave={onSave} saving={saving} />}
</div>
</div>
);
@@ -4,7 +4,7 @@
* Wraps a settings section's interactive content for read-only viewers (Operators).
* The native fieldset `disabled` attribute inertly disables every nested input, select,
* textarea and button while values stay visible and copyable; a small notice explains why.
* Sections must ADDITIONALLY not render their SaveResetBar when read-only — a read-only
* Sections must ADDITIONALLY not render their SaveBar when read-only — a read-only
* viewer gets no save affordance at all, not a disabled one.
*/
export default function ReadOnlyFieldset({
@@ -1,54 +1,57 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";
import { apiErrorText } from "@/lib/apiClient";
interface SaveResetBarProps {
interface SaveBarProps {
/** A failed save throws: the bar shows the reason next to the button, where the user is looking. */
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** When false, Save/Reset are disabled (read-only viewer). Defaults to true. */
/** When false, Save is disabled (read-only viewer). Defaults to true. */
canSave?: boolean;
/** Input the user can still fix: Save is disabled with this reason. */
saveBlockedReason?: string;
}
export default function SaveResetBar({ onSave, onReset, saving, canSave = true }: SaveResetBarProps) {
export default function SaveBar({ onSave, saving, canSave = true, saveBlockedReason }: SaveBarProps) {
const [saveResult, setSaveResult] = useState<"idle" | "saved" | "error">("idle");
const [failureReason, setFailureReason] = useState("");
const savedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const handleSave = useCallback(async () => {
clearTimeout(savedTimer.current);
setSaveResult("idle");
try {
await onSave();
setSaveResult("saved");
setTimeout(() => setSaveResult("idle"), 3000);
} catch {
savedTimer.current = setTimeout(() => setSaveResult("idle"), 3000);
} catch (err) {
// Stays until the next Save: unlike "Saved!" it needs reading and acting on.
setFailureReason(apiErrorText(err, ""));
setSaveResult("error");
setTimeout(() => setSaveResult("idle"), 4000);
}
}, [onSave]);
return (
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
<div className="flex flex-wrap items-center justify-end gap-3 pt-4 border-t border-gray-200">
{!canSave && (
<span className="text-sm text-gray-400">Read-only</span>
)}
{saveBlockedReason && (
<span className="min-w-0 break-words text-sm text-amber-600">{saveBlockedReason}</span>
)}
{/* Inline feedback (errors only — success is shown via button state) */}
{saveResult === "error" && (
<span className="flex items-center text-sm text-red-600 font-medium">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span role="alert" className="flex items-center min-w-0 text-sm text-red-600 font-medium">
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
Save failed
<span className="min-w-0 break-words">Save failed{failureReason && `: ${failureReason}`}</span>
</span>
)}
<button
onClick={onReset}
disabled={saving || !canSave}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving || !canSave}
disabled={saving || !canSave || !!saveBlockedReason}
className={`px-4 py-2 rounded-md text-sm text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center space-x-2 ${
saveResult === "saved" ? "bg-emerald-600" : "bg-green-600 hover:bg-green-700"
}`}
@@ -11,7 +11,7 @@ export function SectionDataManagement() {
dataRetentionDays, setDataRetentionDays,
sessionTimeoutHours, setSessionTimeoutHours,
editionInfo,
handleSaveDataManagement, handleResetDataManagement,
handleSaveDataManagement,
savingSection,
} = useTenantConfig();
@@ -31,7 +31,6 @@ export function SectionDataManagement() {
isGlobalAdmin={isGlobalAdminView}
retentionCapDays={editionInfo.entitlements.retentionCapDays}
onSave={handleSaveDataManagement}
onReset={handleResetDataManagement}
saving={savingSection === "dataManagement"}
readOnly={!canEditConfig}
/>
@@ -61,13 +61,8 @@ export const SECTION_FIELD_MAP = {
contact: {
fields: ["contactEmail", "companyName"],
},
agentSettings: {
agentParameters: {
fields: [
"enablePerformanceCollector",
"performanceCollectorIntervalSeconds",
"helloWaitTimeoutSeconds",
"enableRealmJoinWatcher",
"enableConfigMgrWatcher",
"selfDestructOnComplete",
"keepLogFile",
"rebootOnComplete",
@@ -86,6 +81,15 @@ export const SECTION_FIELD_MAP = {
"enrollmentSummaryLaunchRetrySeconds",
],
},
agentCollectors: {
fields: [
"enablePerformanceCollector",
"performanceCollectorIntervalSeconds",
"helloWaitTimeoutSeconds",
"enableRealmJoinWatcher",
"enableConfigMgrWatcher",
],
},
agentAnalyzers: {
fields: [
"enableLocalAdminAnalyzer",
@@ -1,7 +1,7 @@
"use client";
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import SaveBar from "../../components/SaveBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
@@ -11,7 +11,7 @@ export function SectionContact() {
canEditConfig,
contactEmail, setContactEmail,
companyName, setCompanyName,
handleSaveContact, handleResetContact,
handleSaveContact,
savingSection,
} = useTenantConfig();
@@ -19,6 +19,11 @@ export function SectionContact() {
const looksInvalid = trimmed.length > 0 && !trimmed.includes("@");
// Mirrors TenantConfigValidation.MaxCompanyNameLength — the backend rejects longer values.
const companyTooLong = companyName.trim().length > 200;
const saveBlockedReason = looksInvalid
? "Enter a valid email address to save."
: companyTooLong
? "Shorten the company name to save."
: undefined;
return (
<div className="bg-white rounded-lg shadow">
@@ -87,11 +92,10 @@ export function SectionContact() {
</div>
{canEditConfig && (
<SaveResetBar
<SaveBar
onSave={handleSaveContact}
onReset={handleResetContact}
saving={savingSection === "contact"}
canSave={!looksInvalid && !companyTooLong}
saveBlockedReason={saveBlockedReason}
/>
)}
</div>
@@ -17,7 +17,7 @@ export function SectionHardwareWhitelist() {
modelWhitelist, setModelWhitelist,
webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection,
notificationChannels,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
handleSaveHardwareWhitelist,
savingSection,
} = useTenantConfig();
@@ -42,7 +42,6 @@ export function SectionHardwareWhitelist() {
modelWhitelist={modelWhitelist}
setModelWhitelist={setModelWhitelist}
onSave={handleSaveHardwareWhitelist}
onReset={handleResetHardwareWhitelist}
saving={savingSection === "hardwareWhitelist"}
readOnly={!canEditConfig}
/>
@@ -19,7 +19,7 @@ export function SectionNotifications() {
canEditConfig,
notificationChannels, setNotificationChannels,
handleTestChannel, testingChannelId, testChannelResult,
handleSaveNotifications, handleResetNotifications,
handleSaveNotifications,
savingSection,
} = useTenantConfig();
@@ -55,7 +55,6 @@ export function SectionNotifications() {
testingChannelId={testingChannelId}
testChannelResult={testChannelResult}
onSave={handleSaveNotifications}
onReset={handleResetNotifications}
saving={savingSection === "notifications"}
readOnly={!canEditConfig}
showTelegramProvider={showTelegramProvider}
@@ -1,7 +1,7 @@
"use client";
import { useTenantConfig } from "../../TenantConfigContext";
import SaveResetBar from "../../components/SaveResetBar";
import SaveBar from "../../components/SaveBar";
import ReadOnlyFieldset from "../../components/ReadOnlyFieldset";
import { trackEvent } from "@/lib/appInsights";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -19,7 +19,7 @@ export function SectionSlaTargets() {
slaNotifyOnAppInstallBreach, setSlaNotifyOnAppInstallBreach,
slaNotifyOnConsecutiveFailures, setSlaNotifyOnConsecutiveFailures,
slaConsecutiveFailureThreshold, setSlaConsecutiveFailureThreshold,
handleSaveSlaTargets, handleResetSlaTargets,
handleSaveSlaTargets,
savingSection,
} = useTenantConfig();
@@ -218,7 +218,7 @@ export function SectionSlaTargets() {
</ReadOnlyFieldset>
{canEditConfig && (
<SaveResetBar
<SaveBar
onSave={() => {
trackEvent('sla_targets_saved', {
hasSuccessRateTarget: slaTargetSuccessRate != null,
@@ -227,10 +227,6 @@ export function SectionSlaTargets() {
});
return handleSaveSlaTargets();
}}
onReset={() => {
trackEvent('sla_targets_reset');
return handleResetSlaTargets();
}}
saving={savingSection === "slaTargets"}
/>
)}