Compare commits

...

13 Commits

Author SHA1 Message Date
Oliver Kieselbach 43b3008266 Merge origin/main: the phone layout of Install Progress and the No data row
Both changed InstallProgress; the row keeps the new detection badge and the frozen text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:24:48 +01:00
Oliver Kieselbach 2304436b8c bump pointer: internal (D-398, D-399), .claude/commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:14:32 +01:00
Oliver Kieselbach ad875d4021 Session page: a row reads No data once nothing has arrived from the agent for 20 minutes
When the uploads of an unfinished session stopped, its app and script rows kept a
ticking timer until the session timed out hours later, and read like a hanging package.
They now freeze at the agent's last report and say No data, and the next data brings
them back to live. The session status does not change.

Silence is measured on the server clock (lastIngestAt, every event's receivedAt), since
device clocks can be hours off. A Stalled session counts without a timestamp; an agent
that reported the stall itself keeps its timer until it goes quiet too. A session that
has ended keeps Incomplete and its wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 16:14:25 +01:00
Oliver Kieselbach fd6b68231e Agent: an IME log rollover keeps the read position instead of replaying the archive
IME rolls a log with File.Move at 3 MB. The tracker kept positions per path, took the
archive for a new file and read it from 0, so every line played again: finished apps
fell back to Installing, the subgraph line of an older app marked the running app as
skipped with a wrong start, and the app summary jumped.

A position now belongs to the file (volume serial plus file index). It follows the file
to its new name, only the unread tail is read, and the live name starts at 0. Invocation
markers and a held tail move along: reading the archive again used to restore the marker
of a platform script in flight by accident. A new file longer than the old position no
longer loses lines after a restart. An unreadable identity keeps the old behaviour.

OnAllAppsCompleted had no subscriber and is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:48:47 +01:00
Oliver Kieselbach 88494a312f Agent: a failed upload names its cause and the endpoint it tried
The NETWORK fail line only said "An error occurred while sending the request",
so DNS, connect timeout, proxy and TLS looked the same. It now carries the
WebException status, the socket error or native code and the innermost message,
which on .NET Framework names the endpoint; a destination other than the backend
means a proxy. The distress and emergency channels get the same text.

The "same logical network" line keeps every occurrence on Debug: when it happened
is the information.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:31:53 +01:00
Oliver Kieselbach 6901f6f682 Install Progress on phones: the green detection pill moves into Details, red and amber shorten (#194) 2026-10-31 08:50:27 +01:00
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
56 changed files with 1856 additions and 886 deletions
@@ -0,0 +1,277 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// IME rolls a log with File.Move at 3 MB. The tracker used to take the archive for a new file and
/// read it from 0: every app line played again, finished apps fell back to Installing and the
/// subgraph line of an older app marked the running app as skipped (session 190fed00).
/// </summary>
public sealed partial class ImeLogTrackerOverwriteRewindTests
{
private const string FirstStamp = "20260910-193000";
private const string SecondStamp = "20260910-193500";
private static string Folder(Harness h) => Path.GetDirectoryName(h.LogPath)!;
private static string ArchivePath(Harness h, string stamp = FirstStamp)
=> Path.Combine(Folder(h), $"{Path.GetFileNameWithoutExtension(h.LogName)}-{stamp}.log");
private static string Roll(Harness h, string stamp = FirstStamp)
{
var archive = ArchivePath(h, stamp);
File.Move(h.LogPath, archive);
return archive;
}
private static List<ImeLogPattern> AppPatterns() => new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "T-POLICIES", Category = "always", Enabled = true,
Pattern = @"^policies discovered = (?<policies>\[.*\])", Action = "policiesDiscovered",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-SUBGRAPH", Category = "always", Enabled = true,
Pattern = @"^subgraph (?<id>[\w-]+)", Action = "cancelStuckAndSetCurrent",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-INSTALLING", Category = "always", Enabled = true,
Pattern = @"^installing (?<id>[\w-]+)", Action = "updateStateInstalling",
Parameters = new Dictionary<string, string>(),
},
new ImeLogPattern
{
PatternId = "T-INSTALLED", Category = "always", Enabled = true,
Pattern = @"^installed (?<id>[\w-]+)", Action = "updateStateInstalled",
Parameters = new Dictionary<string, string>(),
},
};
private const string AppPoliciesLine = @"policies discovered = [{""Id"":""app-1"",""Name"":""App One""},{""Id"":""app-2"",""Name"":""App Two""}]";
private static string OneFinishedOneRunning() =>
Entry(AppPoliciesLine) + Entry("subgraph app-1") + Entry("installing app-1") + Entry("installed app-1")
+ Entry("subgraph app-2") + Entry("installing app-2");
[Theory]
[InlineData("AppWorkload.log")]
[InlineData("AppActionProcessor.log")]
[InlineData("HealthScripts.log")]
[InlineData("AgentExecutor.log")]
[InlineData("IntuneManagementExtension.log")]
public async Task Rolled_over_log_is_not_read_again(string logName)
{
using var h = new Harness(logName, MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3"));
await h.Pass();
Roll(h);
h.Append(Entry("marker 4"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
h.Tracker.RequestOverwriteCheckForTest();
h.Now = h.Now.AddSeconds(2);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
Assert.Equal(0, h.Health.OverwriteRewinds);
}
[Fact]
public async Task Bytes_written_after_the_last_read_come_from_the_archive_once_and_first()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2"));
await h.Pass();
h.Append(Entry("marker 3"));
Roll(h);
h.Append(Entry("marker 4"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
}
[Fact]
public async Task Line_held_back_at_the_end_of_the_file_is_finished_from_the_archive()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
var second = Entry("marker 2");
h.Append(Entry("marker 1") + second.Substring(0, 10));
await h.Pass();
Assert.Equal(new[] { 1 }, h.Matched());
h.Append(second.Substring(10));
Roll(h);
h.Append(Entry("marker 3"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3 }, h.Matched());
}
[Fact]
public async Task Rollover_leaves_finished_and_running_apps_alone()
{
using var h = new Harness("AppWorkload.log", AppPatterns());
var transitions = new List<string>();
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
h.Append(OneFinishedOneRunning());
await h.Pass();
var before = transitions.ToList();
Roll(h);
h.Append(Entry("marker unrelated"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(before, transitions);
var states = h.Tracker.GetAllKnownPackageStates().ToDictionary(p => p.Id, p => p.InstallationState);
Assert.Equal(AppInstallationState.Installed, states["app-1"]);
Assert.Equal(AppInstallationState.Installing, states["app-2"]);
}
[Fact]
public async Task Without_a_readable_identity_the_archive_is_read_again_as_before()
{
// The fallback, and the proof that the tests above catch the replay: this is what they saw before the fix.
using var h = new Harness("AppWorkload.log", AppPatterns());
h.Tracker.FileIdentityProvider = _ => null!;
var transitions = new List<string>();
h.Tracker.OnAppStateChanged = (pkg, from, to) => transitions.Add($"{pkg.Id}:{from}->{to}");
h.Append(OneFinishedOneRunning());
await h.Pass();
Roll(h);
h.Append(Entry("marker unrelated"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Contains("app-2:Installing->Skipped", transitions);
Assert.Contains("app-1:Installed->Installing", transitions);
}
[Fact]
public async Task Live_file_that_sorts_before_its_archive_hands_over_its_bookmark()
{
// LogMaxHistory <= 1: the archive is _X.log, which sorts behind X.log. The pass reaches the
// shrunken live file first, and the next rollover replaces the archive under the same name.
using var h = new Harness("IntuneManagementExtension.log", MarkerPatterns());
var archive = Path.Combine(Folder(h), "_IntuneManagementExtension.log");
h.Append(Entry("marker 1") + Entry("marker 2") + Entry("marker 3") + Entry("marker 4"));
await h.Pass();
File.Move(h.LogPath, archive);
h.Append(Entry("marker 5"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5 }, h.Matched());
h.Append(Entry("marker 6"));
File.Delete(archive);
File.Move(h.LogPath, archive);
h.Append(Entry("marker 8"));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 8 }, h.Matched());
}
[Fact]
public async Task Rollover_during_a_restart_with_a_longer_new_file_loses_and_repeats_nothing()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2"));
await h.Pass();
h.Restart();
h.Append(Entry("marker 3"));
Roll(h);
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6") + Entry("marker 7"));
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6, 7 }, h.Matched());
}
[Fact]
public async Task Two_rollovers_during_a_restart_with_the_bookmarked_file_deleted_lose_nothing()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1") + Entry("marker 2"));
await h.Pass();
h.Restart();
var first = Roll(h, FirstStamp);
h.Append(Entry("marker 3"));
Roll(h, SecondStamp);
h.Append(Entry("marker 4") + Entry("marker 5") + Entry("marker 6"));
File.Delete(first);
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, h.Matched());
}
[Fact]
public async Task Archives_found_at_the_first_start_are_read_from_the_beginning_in_order()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
File.WriteAllText(ArchivePath(h, FirstStamp), Entry("marker 1") + Entry("marker 2"));
File.WriteAllText(ArchivePath(h, SecondStamp), Entry("marker 3"));
h.Append(Entry("marker 4"));
await h.Pass();
Assert.Equal(new[] { 1, 2, 3, 4 }, h.Matched());
}
[Fact]
public async Task Platform_script_in_flight_keeps_its_end_block_across_a_rollover()
{
// Reading the archive again used to restore the marker of the run in flight by accident.
using var h = new Harness("AgentExecutor.log", ScriptPatterns());
h.Append(Entry("ExecutorLog AgentExecutor gets invoked") + Entry(PlatformStartLine) + Entry("[Executor] created powershell with process id 12708"));
await h.Pass();
Roll(h);
h.Append(Entry("Powershell exit code is 0") + Entry("write output done. output = Hello from E, error = ") + Entry("Agent executor completed."));
h.Now = h.Now.AddMilliseconds(100);
await h.Pass();
h.Tracker.ProcessLogMessageForTest(ResultLine);
var script = Assert.Single(h.Completed);
Assert.Equal(PlatformId, script.PolicyId);
Assert.Equal(0, script.ExitCode);
Assert.Equal("Hello from E", script.Stdout);
}
[Fact]
public void Identity_of_a_real_file_survives_a_rename_and_differs_from_the_next_file()
{
using var h = new Harness("AppWorkload.log", MarkerPatterns());
h.Append(Entry("marker 1"));
var before = ImeLogFileIdentity.TryRead(h.LogPath);
Assert.NotNull(before);
var archive = Roll(h);
h.Append(Entry("marker 2"));
Assert.Equal(before, ImeLogFileIdentity.TryRead(archive));
Assert.NotEqual(before, ImeLogFileIdentity.TryRead(h.LogPath));
Assert.Null(ImeLogFileIdentity.TryRead(archive + ".missing"));
}
}
}
@@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// A bookmark belongs to a file, not to a name: IME rolls a log by renaming it, so the identity
/// (volume plus file index) tells what happened to the bytes behind the bookmark.
/// </summary>
public sealed class LogFilePositionTrackerTests
{
private const string Live = @"C:\Logs\AppWorkload.log";
private const string Archive = @"C:\Logs\AppWorkload-20260910-193000.log";
private const string Underscore = @"C:\Logs\_AppWorkload.log";
private static readonly DateTime Checked = new DateTime(2026, 9, 10, 19, 30, 0, DateTimeKind.Utc);
private static Dictionary<string, string> Ids(params (string Path, string Id)[] entries)
{
var ids = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entries) ids[entry.Path] = entry.Id;
return ids;
}
private static LogFilePositionTracker TrackerWithBookmark(string path, string id, long position)
{
var tracker = new LogFilePositionTracker();
tracker.Reconcile(Ids((path, id)));
tracker.MarkChecked(path, Checked);
tracker.SetPosition(path, position);
return tracker;
}
[Fact]
public void Renamed_file_keeps_its_bookmark_and_the_old_path_starts_over()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
var move = Assert.Single(moves);
Assert.Equal(Live, move.From);
Assert.Equal(Archive, move.To);
Assert.Equal(3000, move.Position);
Assert.Equal(3000, tracker.GetPosition(Archive));
Assert.Equal("A", tracker.GetFileId(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
Assert.Equal("B", tracker.GetFileId(Live));
}
[Fact]
public void Both_paths_keep_the_freshness_reference()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
Assert.True(tracker.HasSeen(Archive));
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Archive));
Assert.True(tracker.HasSeen(Live));
Assert.Equal(Checked, tracker.GetLastCheckedUtc(Live));
}
[Fact]
public void Renamed_file_whose_old_path_is_not_recreated_yet_keeps_its_bookmark()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Archive, "A")));
Assert.Single(moves);
Assert.Equal(3000, tracker.GetPosition(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
Assert.False(tracker.HasIdentity(Live));
}
[Fact]
public void Second_name_for_the_same_file_moves_nothing()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "A")));
Assert.Empty(moves);
Assert.Equal(3000, tracker.GetPosition(Live));
Assert.False(tracker.HasSeen(Archive));
}
[Fact]
public void Unknown_file_stays_unseen_so_its_first_pass_is_not_taken_for_fresh_lines()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, "A"), (Archive, "X")));
Assert.Empty(moves);
Assert.True(tracker.HasIdentity(Archive));
Assert.False(tracker.HasSeen(Archive));
Assert.Equal(0, tracker.GetPosition(Archive));
}
[Fact]
public void Path_holding_another_file_with_no_one_to_take_over_starts_at_zero()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, "B")));
Assert.Empty(moves);
// GetSafePosition alone would keep 3000 here when the new file is longer: lost lines.
Assert.Equal(0, tracker.GetSafePosition(Live, 5000));
Assert.Equal("B", tracker.GetFileId(Live));
}
[Fact]
public void Unreadable_identity_changes_nothing()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
var moves = tracker.Reconcile(Ids((Live, null!), (Archive, null!)));
Assert.Empty(moves);
Assert.Equal(3000, tracker.GetPosition(Live));
Assert.Equal("A", tracker.GetFileId(Live));
Assert.True(tracker.HasIdentity(Archive));
Assert.Null(tracker.GetFileId(Archive));
}
[Fact]
public void Single_slot_archive_is_replaced_by_the_next_rollover()
{
// LogMaxHistory <= 1: IME deletes _X.log and renames X.log to it.
var tracker = TrackerWithBookmark(Underscore, "A", 9000);
tracker.Reconcile(Ids((Underscore, "A"), (Live, "B")));
tracker.SetPosition(Live, 3000);
var moves = tracker.Reconcile(Ids((Underscore, "B"), (Live, "C")));
var move = Assert.Single(moves);
Assert.Equal(Live, move.From);
Assert.Equal(Underscore, move.To);
Assert.Equal(3000, tracker.GetPosition(Underscore));
Assert.Equal(0, tracker.GetPosition(Live));
}
[Fact]
public void A_renamed_file_can_leave_the_path_it_moved_into()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
tracker.Reconcile(Ids((Live, "A"), (Underscore, "B")));
tracker.SetPosition(Underscore, 700);
var moves = tracker.Reconcile(Ids((Underscore, "A"), (Archive, "B"), (Live, "C")));
Assert.Equal(2, moves.Count);
Assert.Equal(3000, tracker.GetPosition(Underscore));
Assert.Equal(700, tracker.GetPosition(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
}
[Fact]
public void Behind_bookmark_only_when_smaller_than_the_position()
{
var tracker = TrackerWithBookmark(Live, "A", 3000);
Assert.True(tracker.IsBehindBookmark(Live, 2999));
Assert.False(tracker.IsBehindBookmark(Live, 3000));
Assert.False(tracker.IsBehindBookmark(Archive, 0));
}
[Fact]
public void Restored_identity_is_used_to_find_a_rollover_during_the_downtime()
{
var tracker = new LogFilePositionTracker();
tracker.RestorePosition(Live, 3000, 3000);
tracker.RestoreFileId(Live, "A");
var moves = tracker.Reconcile(Ids((Archive, "A"), (Live, "B")));
Assert.Single(moves);
Assert.Equal(3000, tracker.GetPosition(Archive));
Assert.Equal(0, tracker.GetPosition(Live));
}
}
}
@@ -550,6 +550,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Contains("network", result.ErrorReason);
}
[Fact]
public async Task Network_error_reason_names_the_cause_instead_of_the_generic_text()
{
using var rig = new Rig();
rig.Handler.QueueThrow(new HttpRequestException(
"An error occurred while sending the request.",
new WebException(
"Unable to connect to the remote server",
new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.TimedOut),
WebExceptionStatus.ConnectFailure,
null)));
var result = await rig.Sut.UploadBatchAsync(new[] { NewEventItem(1) }, CancellationToken.None);
Assert.True(result.IsTransient);
Assert.StartsWith("network: status=ConnectFailure, socket=TimedOut: ", result.ErrorReason);
}
[Fact]
public async Task HttpClient_timeout_maps_to_Transient()
{
@@ -2,7 +2,9 @@ using System;
using System.ComponentModel;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
@@ -69,5 +71,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
}
// The .NET Framework shape of a failed connect or name lookup through HttpClient.
private static Exception SendFailureChain(WebExceptionStatus status, SocketError socketError) =>
new HttpRequestException(
"An error occurred while sending the request.",
new WebException("Unable to connect to the remote server", new SocketException((int)socketError), status, null));
[Theory]
[InlineData(WebExceptionStatus.ConnectFailure, SocketError.TimedOut, "status=ConnectFailure, socket=TimedOut: ")]
[InlineData(WebExceptionStatus.NameResolutionFailure, SocketError.HostNotFound, "status=NameResolutionFailure, socket=HostNotFound: ")]
public void Send_failure_names_status_and_socket_error(WebExceptionStatus status, SocketError socketError, string expectedPrefix)
{
var described = TransportErrorCodes.DescribeSendFailure(SendFailureChain(status, socketError));
Assert.StartsWith(expectedPrefix, described);
Assert.DoesNotContain("An error occurred while sending the request", described);
}
[Fact]
public void Send_failure_of_a_tls_handshake_names_status_and_native_code()
{
var described = TransportErrorCodes.DescribeSendFailure(TlsFailureChain(new Win32Exception(IllegalMessage)));
Assert.StartsWith("status=SecureChannelFailure, win32=0x80090326: ", described);
}
[Fact]
public void Send_failure_without_details_is_the_innermost_message()
{
Assert.Equal("inner", TransportErrorCodes.DescribeSendFailure(new HttpRequestException("outer", new InvalidOperationException("inner"))));
Assert.Equal("boom", TransportErrorCodes.DescribeSendFailure(new InvalidOperationException("boom")));
Assert.Null(TransportErrorCodes.DescribeSendFailure(null!));
}
[Fact]
public async Task Real_refused_connect_names_the_endpoint_it_tried()
{
// The endpoint in the message is what tells the backend from a proxy, and only a real
// connect produces it: a hand-built SocketException carries none.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
using var client = new HttpClient(new HttpClientHandler { UseProxy = false });
var failure = await Assert.ThrowsAsync<HttpRequestException>(() => client.GetAsync($"http://127.0.0.1:{port}/"));
var described = TransportErrorCodes.DescribeSendFailure(failure);
Assert.StartsWith("status=ConnectFailure, socket=ConnectionRefused: ", described);
Assert.Contains($"127.0.0.1:{port}", described);
}
}
}
@@ -0,0 +1,33 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Which file a path holds right now: volume serial plus file index. IME rolls a log with
/// <c>File.Move</c>, so the identity stays with the bytes when the name changes.
/// </summary>
internal static class ImeLogFileIdentity
{
/// <summary>
/// Null when the file cannot be opened. An attribute-only handle with full sharing, so it
/// can never make IME's rename of the log fail.
/// </summary>
public static string TryRead(string path)
{
using (var handle = FileNativeMethods.CreateFileW(
path,
FileNativeMethods.FILE_READ_ATTRIBUTES,
FileNativeMethods.FILE_SHARE_READ | FileNativeMethods.FILE_SHARE_WRITE | FileNativeMethods.FILE_SHARE_DELETE,
IntPtr.Zero,
FileNativeMethods.OPEN_EXISTING,
0,
IntPtr.Zero))
{
if (handle.IsInvalid) return null;
if (!FileNativeMethods.GetFileInformationByHandle(handle, out var info)) return null;
return $"{info.VolumeSerialNumber:X8}-{info.FileIndexHigh:X8}{info.FileIndexLow:X8}";
}
}
}
}
@@ -1709,7 +1709,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
_allAppsCompletedFired = true;
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
OnAllAppsCompleted?.Invoke();
}
}
}
@@ -51,23 +51,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
if (!Directory.Exists(_logFolder))
return;
// Get all matching log files, sorted by name (archived files come before current).
// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
// the Win32 "*.ext" 8.3 search-pattern quirk.
var files = new List<string>();
try
{
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
}
catch (DirectoryNotFoundException) { }
files.Sort(StringComparer.OrdinalIgnoreCase);
var files = EnumerateLogFiles();
if (FileIdentitiesAreDue(files)) ReconcileFileIdentities(files);
// Σ(file length − bookmark) after this pass — the tracker's queue depth.
long backlogBytes = 0;
@@ -94,6 +79,66 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
/// <summary>
/// All matching log files, sorted by name (archived files come before current).
/// M2: a SINGLE directory enumeration filtered in-memory against the patterns, instead
/// of one Directory.GetFiles(pattern) per LogFilePattern every 100 ms poll (~90 folder
/// enumerations/s → 1). The in-memory regex match is anchored, so it is also immune to
/// the Win32 "*.ext" 8.3 search-pattern quirk.
/// </summary>
private List<string> EnumerateLogFiles()
{
var files = new List<string>();
try
{
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
}
catch (DirectoryNotFoundException) { }
files.Sort(StringComparer.OrdinalIgnoreCase);
return files;
}
/// <summary>
/// On the first pass (a restart can have rolled logs over) and when a file has no known
/// identity yet, which is how a rolled-over archive first shows up. Nothing is opened in
/// the regular poll.
/// </summary>
private bool FileIdentitiesAreDue(List<string> files)
{
if (!_fileIdentitiesReconciled) return true;
foreach (var filePath in files)
{
if (!_positionTracker.HasIdentity(filePath)) return true;
}
return false;
}
/// <summary>
/// Lets every bookmark follow its file (see <see cref="LogFilePositionTracker.Reconcile"/>).
/// Reading a rolled-over log again would replay every app and script line in it: finished
/// apps fall back to Installing, and the subgraph line of an older app marks the app that is
/// running right now as skipped.
/// </summary>
private void ReconcileFileIdentities(List<string> files)
{
_fileIdentitiesReconciled = true;
var idByPath = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var filePath in files) idByPath[filePath] = FileIdentityProvider(filePath);
foreach (var move in _positionTracker.Reconcile(idByPath))
{
MoveFileContext(move.From, move.To);
_logger.Info($"ImeLogTracker: {Path.GetFileName(move.From)} rolled over to {Path.GetFileName(move.To)} - continuing at offset {move.Position} instead of reading it again");
}
_stateDirty = true;
}
/// <summary>
/// One pass over one file: the bytes behind the bookmark, on a multi-writer file preceded
/// by the overwrite check that may move the bookmark back first (see
@@ -104,6 +149,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists) return 0;
// IME renamed the file after this pass listed the folder, or its archive sorts behind it
// (_IntuneManagementExtension.log). Either way the archive needs the bookmark before the
// read below resets it.
if (_positionTracker.IsBehindBookmark(filePath, fileInfo.Length))
ReconcileFileIdentities(EnumerateLogFiles());
// Captured BEFORE MarkChecked below stamps this pass. Growth measured
// against a previously observed state is what makes this pass's lines valid
// "written now" anchors — for the per-file measurement AND per-line anchoring.
@@ -616,6 +616,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private void InvalidateInvocationMarkersFrom(string fileName, long offset) => TakeInvocationMarkersFrom(fileName, offset, out _);
/// <summary>
/// A log was rolled over: what the tracker knows about its bytes goes with them. The markers keep
/// <see cref="_lastPlatformMarker"/> alive, so the run in flight still owns its lines in the fresh file;
/// the ledger stays behind and empties, an archive is not checked for overwrites.
/// </summary>
private void MoveFileContext(string fromPath, string toPath)
{
var from = Path.GetFileName(fromPath);
var to = Path.GetFileName(toPath);
List<InvocationMarker> markers;
if (_invocationMarkers.TryGetValue(from, out markers))
{
_invocationMarkers.Remove(from);
_invocationMarkers[to] = markers;
}
if (_platformMarkerFiles.Contains(from)) _platformMarkerFiles.Add(to);
FileLedger ledger;
if (_ledgers.TryGetValue(from, out ledger)) ledger.Clear();
HeldTail held;
if (_heldTails.TryGetValue(fromPath, out held))
{
_heldTails.Remove(fromPath);
_heldTails[toPath] = held;
}
}
/// <summary>
/// An entry that reads unchanged after a rewind keeps its markers: gives back the set-aside markers at exactly
/// <paramref name="entryOffset"/>. Set-aside markers in front of it belonged to entries that changed and stay
@@ -88,6 +88,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
// system clock, ImeLogHost rewires it to the agent IClock, tests to their VirtualClock.
internal Func<DateTime> UtcNowProvider { get; set; } = () => DateTime.UtcNow;
// Which file a path holds; null when unreadable. Tests swap it to cover that path.
internal Func<string, string> FileIdentityProvider { get; set; } = ImeLogFileIdentity.TryRead;
// False until the bookmarks were checked against the disk once in this process.
private bool _fileIdentitiesReconciled;
/// <summary>
/// CMTrace timestamps from IME are emitted in UTC, but DateTimeKind may arrive as
/// Unspecified depending on the parser path. Normalize via SpecifyKind=Utc rather than
@@ -494,7 +500,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public Action OnImeStarted { get; set; }
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
public Action<string> OnPoliciesDiscovered { get; set; }
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<string> OnImeSessionChange { get; set; }
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
@@ -954,13 +959,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
if (!Directory.Exists(_logFolder)) return;
var inputs = new List<ImeLogEraPreScan.ScanInput>();
var files = new List<string>();
foreach (var filePath in Directory.EnumerateFiles(_logFolder))
{
if (MatchesLogFilePattern(Path.GetFileName(filePath)))
files.Add(filePath);
}
files.Sort(StringComparer.OrdinalIgnoreCase);
var files = EnumerateLogFiles();
// First: the read below resets the bookmark of a file that shrank, and the archive
// that file moved to during the downtime still needs that bookmark.
ReconcileFileIdentities(files);
foreach (var filePath in files)
{
var info = new FileInfo(filePath);
@@ -994,9 +996,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
LoadState();
// Era anchors for the backlog the first pass is about to read (2026-09-04). Runs
// before the poll loop so every replayed line already finds its era; read-only on
// the position tracker. Replay tooling anchors nothing — its logs did not come from
// this device's bootstrap.
// before the poll loop so every replayed line already finds its era. Replay tooling
// anchors nothing — its logs did not come from this device's bootstrap.
if (!SimulationMode) PreScanEras();
_cts = new CancellationTokenSource();
@@ -1180,6 +1181,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
var fullPath = Path.Combine(_logFolder, kvp.Key);
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
_positionTracker.RestoreFileId(fullPath, kvp.Value.FileId);
}
}
@@ -1346,7 +1348,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
state.FilePositions[fileName] = new FilePositionData
{
Position = kvp.Value.Position,
LastKnownSize = kvp.Value.LastKnownSize
LastKnownSize = kvp.Value.LastKnownSize,
FileId = _positionTracker.GetFileId(kvp.Key)
};
}
@@ -250,5 +250,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
/// <summary>Which file the position belongs to (see <see cref="LogFilePositionTracker.Reconcile"/>); null on older state files.</summary>
public string FileId { get; set; }
}
}
@@ -13,12 +13,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private readonly Dictionary<string, FilePositionState> _positions
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets the safe read position for a file, detecting rollover/truncation.
/// If the current file size is smaller than the stored position, the file
/// has been rotated - resets to 0 to read from beginning.
/// Returns 0 if no position has been recorded yet.
/// </summary>
// Which file each path held when last checked (see Reconcile). Apart from _positions: a file
// whose identity is known but which no pass looked at yet must still count as unseen for
// HasSeen. A null value means the identity could not be read.
private readonly Dictionary<string, string> _fileIds
= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether this file was already observed in an earlier pass.
/// <para>
@@ -31,6 +31,112 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
public bool HasSeen(string filePath) => _positions.ContainsKey(filePath);
/// <summary>Whether <see cref="Reconcile"/> already looked at this path.</summary>
public bool HasIdentity(string filePath) => _fileIds.ContainsKey(filePath);
/// <summary>The identity recorded for this path, or null.</summary>
public string GetFileId(string filePath)
{
string id;
return _fileIds.TryGetValue(filePath, out id) ? id : null;
}
/// <summary>Restores a persisted identity. <see cref="Reconcile"/> checks it against the disk on the next pass.</summary>
public void RestoreFileId(string filePath, string fileId)
{
if (fileId != null) _fileIds[filePath] = fileId;
}
/// <summary>True when the file is smaller than its bookmark: it was rolled over or truncated.</summary>
public bool IsBehindBookmark(string filePath, long currentFileSize)
{
FilePositionState state;
return _positions.TryGetValue(filePath, out state) && currentFileSize < state.Position;
}
/// <summary>
/// Brings the bookmarks in line with the files on disk. A bookmark belongs to a file, not to
/// a name: IME rolls a log by renaming it, so a known file under a new path keeps its
/// position and only the bytes behind it are still unread. The old path starts at 0 with
/// whatever file it holds next, and keeps its freshness reference because that file was
/// written since the last look. Returns the moves.
/// <list type="bullet">
/// <item>A file that is still at its old path as well (a hard link) moves nothing.</item>
/// <item>A path that holds another file with no bookmark to take over starts at 0, even
/// when the new file is longer than the old position.</item>
/// <item>An unreadable identity (null) changes nothing, the path keeps its bookmark.</item>
/// </list>
/// </summary>
public List<BookmarkMove> Reconcile(IDictionary<string, string> idByPath)
{
var moves = new List<BookmarkMove>();
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in idByPath)
{
if (entry.Value == null || GetFileId(entry.Key) == entry.Value) continue;
var holder = FindBookmarkLeftBehind(entry.Value, entry.Key, idByPath);
if (holder != null && claimed.Add(holder))
moves.Add(new BookmarkMove(holder, entry.Key, _positions[holder].Position));
}
// In two steps: a path can be source and target at once (X.log becomes _X.log while
// _X.log's own file was replaced).
var movedStates = new List<FilePositionState>();
foreach (var move in moves)
{
var state = _positions[move.From];
movedStates.Add(state);
_positions[move.From] = new FilePositionState { LastCheckedUtc = state.LastCheckedUtc };
_fileIds.Remove(move.From);
}
for (var i = 0; i < moves.Count; i++)
{
_positions[moves[i].To] = movedStates[i];
_fileIds[moves[i].To] = idByPath[moves[i].To];
}
foreach (var entry in idByPath)
{
if (entry.Value == null)
{
if (!_fileIds.ContainsKey(entry.Key)) _fileIds[entry.Key] = null;
continue;
}
string known;
FilePositionState state;
if (_fileIds.TryGetValue(entry.Key, out known) && known != null && known != entry.Value
&& _positions.TryGetValue(entry.Key, out state))
{
state.Position = 0;
state.LastKnownSize = 0;
}
_fileIds[entry.Key] = entry.Value;
}
return moves;
}
private string FindBookmarkLeftBehind(string fileId, string newPath, IDictionary<string, string> idByPath)
{
foreach (var known in _fileIds)
{
if (known.Value != fileId || string.Equals(known.Key, newPath, StringComparison.OrdinalIgnoreCase)) continue;
if (!_positions.ContainsKey(known.Key)) continue;
string stillThere;
if (idByPath.TryGetValue(known.Key, out stillThere) && stillThere == fileId) continue;
return known.Key;
}
return null;
}
/// <summary>
/// Gets the safe read position for a file, detecting rollover/truncation.
/// If the current file size is smaller than the stored position, the file
/// has been rotated - resets to 0 to read from beginning.
/// Returns 0 if no position has been recorded yet.
/// </summary>
public long GetSafePosition(string filePath, long currentFileSize)
{
FilePositionState state;
@@ -144,6 +250,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
}
}
/// <summary>One bookmark that followed its file to a new path.</summary>
public sealed class BookmarkMove
{
public BookmarkMove(string from, string to, long position)
{
From = from;
To = to;
Position = position;
}
public string From { get; }
public string To { get; }
public long Position { get; }
}
public class FilePositionState
{
public long Position { get; set; }
@@ -9,6 +9,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// Win32 file-handle declarations for <c>PinnedSourceFolder</c>: open a file or directory
/// without following a reparse point at its final component, and ask the handle where it
/// really points (<c>GetFinalPathNameByHandle</c>) instead of trusting the path string.
/// <c>ImeLogFileIdentity</c> reads a file's identity through the same declarations.
/// </summary>
internal static class FileNativeMethods
{
@@ -18,6 +19,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
public const uint FILE_SHARE_READ = 0x1;
public const uint FILE_SHARE_WRITE = 0x2;
public const uint FILE_SHARE_DELETE = 0x4;
public const uint OPEN_EXISTING = 3;
@@ -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,6 +160,8 @@ 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))
{
// Every occurrence on purpose, no repeatKey: when it happened is the information
// (did anything move on the network when uploads stopped?).
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing");
return;
}
@@ -194,7 +194,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
catch (Exception ex)
{
// Swallow — distress channel must never cascade failures into the caller
_logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}");
_logger?.Debug($"[DistressChannel] Failed to send report: {TransportErrorCodes.DescribeSendFailure(ex)}");
}
}
@@ -180,7 +180,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
catch (Exception ex)
{
// Swallow — emergency channel must never cascade failures into the caller
_logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}");
_logger?.Debug($"[EmergencyChannel] Failed to send report: {TransportErrorCodes.DescribeSendFailure(ex)}");
}
if (!delivered)
@@ -1,6 +1,8 @@
using System;
using System.ComponentModel;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
{
@@ -31,6 +33,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
return code == null ? ex.Message : $"{ex.Message} (win32 {code})";
}
/// <summary>
/// What a failed send hit, in one line: the <see cref="WebException.Status"/>, the socket error
/// or native code, and the innermost message. On .NET Framework a socket error's message names
/// the endpoint it tried, which tells a backend that does not answer from a proxy that does not.
/// </summary>
public static string DescribeSendFailure(Exception ex)
{
if (ex == null) return null;
WebExceptionStatus? status = null;
SocketError? socketError = null;
var innermost = ex;
for (var current = ex; current != null; current = current.InnerException)
{
innermost = current;
if (current is WebException web && status == null) status = web.Status;
if (current is SocketException socket) socketError = socket.SocketErrorCode;
}
var code = socketError.HasValue ? $"socket={socketError.Value}" : NativeCodePart(ex);
var details = status.HasValue
? (code == null ? $"status={status.Value}" : $"status={status.Value}, {code}")
: code;
return details == null ? innermost.Message : $"{details}: {innermost.Message}";
}
private static string NativeCodePart(Exception ex)
{
var code = InnermostNativeCode(ex);
return code == null ? null : $"win32={code}";
}
/// <summary>Eight-digit hex with <c>0x</c>: <c>-2146893018</c> → <c>0x80090326</c>.</summary>
public static string Hex(int value) => "0x" + value.ToString("X8", CultureInfo.InvariantCulture);
}
@@ -161,9 +161,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
catch (HttpRequestException ex)
{
sw.Stop();
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {ex.Message}");
var cause = TransportErrorCodes.DescribeSendFailure(ex);
_logger?.Warning($"BackendTelemetryUploader: ingest NETWORK fail after {sw.ElapsedMilliseconds}ms (corr={correlationId}, items={items.Count}): {cause}");
// DNS failure, socket error, etc. — network-layer is retryable.
return UploadResult.Transient($"network: {ex.Message}");
return UploadResult.Transient($"network: {cause}");
}
using (response)
@@ -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}
/>
</>
);
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import { isAgentSilent, isTerminalStatus, nextSilenceCheckMs } from "@/lib/sessionStatus";
const CHECK_INTERVAL_MS = 30_000;
/**
* Whether the agent of an unfinished session has gone quiet, re-checked while the page stays open. Only
* in a visible tab: a tab waking up has not caught up with the events yet and would flash "No data".
* New data clears it at once, since the answer is derived from the latest ingest time at every render.
*/
export function useAgentSilence(status: string | null | undefined, lastIngestAtMs: number | null): boolean {
const [checkedAtMs, setCheckedAtMs] = useState(() => Date.now());
useEffect(() => {
if (isTerminalStatus(status)) return;
const id = setInterval(() => {
if (document.visibilityState !== "visible") return;
setCheckedAtMs(previous => nextSilenceCheckMs(previous, Date.now(), status, lastIngestAtMs));
}, CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, [status, lastIngestAtMs]);
return isAgentSilent(status, lastIngestAtMs, checkedAtMs);
}
@@ -30,6 +30,8 @@ export interface UseSessionDerivedDataReturn {
displayStatus: string;
enrollmentDurationFromEvents: string | null;
lastObservedAtMs: number | null;
/** When the server last received anything from the agent. Server clock, unlike lastObservedAtMs. */
lastIngestAtMs: number | null;
standbySeconds: number | null;
/** The OOBE quality update of a running session (null otherwise); its label depends on the clock. */
osUpdateLive: OsUpdateLiveFacts | null;
@@ -168,6 +170,19 @@ export function useSessionDerivedData(
return max;
}, [events, sessionLastEventAt]);
// The session row is not refreshed with every event, the event's own receipt time is.
const sessionLastIngestAt = session?.lastIngestAt ?? null;
const lastIngestAtMs = useMemo(() => {
const fromSession = sessionLastIngestAt ? new Date(sessionLastIngestAt).getTime() : NaN;
let max: number | null = Number.isFinite(fromSession) ? fromSession : null;
for (const e of events) {
if (!e.receivedAt) continue;
const t = new Date(e.receivedAt).getTime();
if (Number.isFinite(t) && (max === null || t > max)) max = t;
}
return max;
}, [events, sessionLastIngestAt]);
// The OOBE quality update while the session runs — the live counterpart of the time
// attribution's os_update / awaiting_sign_in segments, which exist only once it ended.
const sessionStatus = session?.status;
@@ -269,6 +284,7 @@ export function useSessionDerivedData(
displayStatus,
enrollmentDurationFromEvents,
lastObservedAtMs,
lastIngestAtMs,
standbySeconds,
osUpdateLive,
phaseNamesMap,
@@ -28,6 +28,7 @@ import { useSessionDetail } from "./hooks/useSessionDetail";
import { useSessionEvents } from "./hooks/useSessionEvents";
import { useSessionSignalR } from "./hooks/useSessionSignalR";
import { useSessionDerivedData } from "./hooks/useSessionDerivedData";
import { useAgentSilence } from "./hooks/useAgentSilence";
import { useSessionTenantConfig } from "./hooks/useSessionTenantConfig";
import { useSessionTimeAttribution } from "./hooks/useSessionTimeAttribution";
@@ -193,6 +194,7 @@ function SessionDetailContent() {
displayStatus,
enrollmentDurationFromEvents,
lastObservedAtMs,
lastIngestAtMs,
standbySeconds,
osUpdateLive,
isSkipUserStatusPage,
@@ -206,10 +208,12 @@ function SessionDetailContent() {
userEnrollGrouped,
} = derived;
// Observation end for the progress panels: only once the session is terminal does a row
// without a terminal event stop being "still running" — it becomes incomplete, its timer
// bounded by the agent's last report. Live sessions pass null and keep ticking.
const observedUntilMs = isTerminalStatus(displayStatus) ? lastObservedAtMs : null;
// Observation end for the progress panels: once the session is terminal, or no data has arrived
// for a while, a row without a terminal event stops being "still running" — its timer is bounded
// by the agent's last report. A silent session reads "No data" and goes live again with new
// data; live sessions pass null and keep ticking.
const agentSilent = useAgentSilence(displayStatus, lastIngestAtMs);
const observedUntilMs = isTerminalStatus(displayStatus) || agentSilent ? lastObservedAtMs : null;
// One boundary for the Download / Install / Script panels, so all three split at the same moment.
const userPhaseBoundary = useMemo(
() => findUserPhaseBoundary(events, session?.enrollmentType),
@@ -772,6 +776,7 @@ function SessionDetailContent() {
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={scriptDisplayNamesByRefKey}
observedUntilMs={observedUntilMs}
agentSilent={agentSilent}
userPhaseBoundary={userPhaseBoundary}
/>
</div>
@@ -784,6 +789,7 @@ function SessionDetailContent() {
events={downloadEvents}
summaryStats={appSummaryStats}
observedUntilMs={observedUntilMs}
agentSilent={agentSilent}
userPhaseBoundary={userPhaseBoundary}
/>
</div>
@@ -800,6 +806,7 @@ function SessionDetailContent() {
events={installEvents}
summaryStats={appSummaryStats}
observedUntilMs={observedUntilMs}
agentSilent={agentSilent}
userPhaseBoundary={userPhaseBoundary}
/>
</div>
@@ -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"}
/>
)}
@@ -8,6 +8,7 @@ import PhaseDivider from "@/components/PhaseDivider";
import AssignmentPill from "@/components/AssignmentPill";
import { userPhaseSplitIndex, type UserPhaseBoundary } from "@/lib/userPhaseBoundary";
import { shouldSkipLowBytesTotal, shouldSkipNoActivity, hasByteActivity } from "@/lib/downloadProgressFilters";
import { frozenRowText } from "@/lib/sessionStatus";
import { formatBytes, formatThroughput, formatDuration } from "@/lib/formatting";
import DoBreakdownBar from "./DoBreakdownBar";
@@ -65,9 +66,11 @@ interface SummaryStats {
interface DownloadProgressProps {
events: DownloadEvent[];
summaryStats?: SummaryStats | null;
// Epoch ms of the agent's last report, passed only once the session is terminal. A download
// that never finished by then is incomplete, not active.
// Epoch ms of the agent's last report, passed once the session is terminal or the agent went
// silent. A download that never finished by then is not active any more.
observedUntilMs?: number | null;
// The session has not ended, data just stopped arriving: the row reads No data, not Incomplete.
agentSilent?: boolean;
// When the Enrollment Status Page entered Account Setup; null when the session has no such split.
userPhaseBoundary?: UserPhaseBoundary | null;
}
@@ -155,7 +158,7 @@ function effectiveDurationMs(dl: DownloadItem): number {
return 0;
}
export default function DownloadProgress({ events, summaryStats, observedUntilMs = null, userPhaseBoundary = null }: DownloadProgressProps) {
export default function DownloadProgress({ events, summaryStats, observedUntilMs = null, agentSilent = false, userPhaseBoundary = null }: DownloadProgressProps) {
// Legacy-agent guard: drop download events replayed from a previous enrollment's IME log.
// Silent (empty finals set) — the InstallProgress panel already reports the hidden count
// for the same apps; a second note here would double-report them.
@@ -276,8 +279,8 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
// This avoids active items jumping when completion status changes.
return a.firstSeenIndex - b.firstSeenIndex;
});
// Observation ended (terminal session): a download that never finished stays at its last
// observed bytes and is reported as incomplete rather than active.
// Observation ended (terminal session or silent agent): a download that never finished stays
// at its last observed bytes and is not reported as active.
if (observedUntilMs == null) return folded;
return folded.map(d => (!d.isComplete && !d.isSkipped && d.hasByteEvidence ? { ...d, isIncomplete: true } : d));
}, [current, observedUntilMs]);
@@ -348,6 +351,7 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
// rows without any observed download activity sit behind the "no download" toggle.
const activeCount = downloads.filter(d => !d.isComplete && !d.isSkipped && !d.isUninstall && d.hasByteEvidence && !d.isIncomplete).length;
const incompleteCount = downloads.filter(d => d.isIncomplete && !d.isUninstall).length;
const frozenText = frozenRowText("downloading", agentSilent);
const completedCount = downloads.filter(d => d.isComplete && !d.isSkipped && !d.isUninstall && d.hasByteEvidence).length;
const uninstallCount = downloads.filter(d => d.isUninstall && !d.isSkipped && d.hasByteEvidence).length;
const noDownloadCount = downloads.filter(d => !d.isSkipped && !d.hasByteEvidence).length;
@@ -415,9 +419,9 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still downloading when the agent stopped observing — the outcome was never seen. Not a failure."
title={frozenText.countTitle}
>
{incompleteCount} incomplete
{incompleteCount} {frozenText.countLabel}
</span>
)}
{uninstallCount > 0 && (
@@ -474,7 +478,7 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
return (
<Fragment key={dl.appName}>
{userPhaseBoundary && i === userSplit && <PhaseDivider boundary={userPhaseBoundary} side="after" />}
<DownloadItem download={dl} progressPercent={progressPercent} />
<DownloadItem download={dl} progressPercent={progressPercent} agentSilent={agentSilent} />
</Fragment>
);
})}
@@ -486,9 +490,10 @@ export default function DownloadProgress({ events, summaryStats, observedUntilMs
);
}
function DownloadItem({ download: dl, progressPercent }: { download: DownloadItem; progressPercent: number }) {
function DownloadItem({ download: dl, progressPercent, agentSilent }: { download: DownloadItem; progressPercent: number; agentSilent: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const [showDoStats, setShowDoStats] = useState(false);
const frozenText = frozenRowText("downloading", agentSilent);
const hasKnownTotal = dl.bytesTotal > 0;
const durationMs = effectiveDurationMs(dl);
// No-download rows get neither a progress bar nor success green — there was no download.
@@ -546,9 +551,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
{dl.isIncomplete && (
<span
className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still downloading when the agent stopped observing. The outcome is unknown — the download may well have finished after the session ended."
title={frozenText.rowTitle}
>
Incomplete
{frozenText.label}
</span>
)}
</div>
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useState } from "react";
import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/lib/errorCodeMap";
import { partitionHistoricReplayEvents } from "@/lib/historicReplay";
import { applyObservationEnd, buildInstallItems, isRebootOrRetryClass, type InstallEvent, type InstallItem } from "@/lib/installProgress";
import { frozenRowText } from "@/lib/sessionStatus";
import TruncatedLabel from "@/components/TruncatedLabel";
import { InstallSourcePill } from "@/components/InstallSourcePill";
import PendingAppRow from "@/components/PendingAppRow";
@@ -25,9 +26,11 @@ interface SummaryStats {
interface InstallProgressProps {
events: InstallEvent[];
summaryStats?: SummaryStats | null;
// Epoch ms of the agent's last report, passed only once the session is terminal. A row still
// installing at that point renders as Incomplete instead of ticking against the wall clock.
// Epoch ms of the agent's last report, passed once the session is terminal or the agent went
// silent. A row still installing at that point stops ticking against the wall clock.
observedUntilMs?: number | null;
// The session has not ended, data just stopped arriving: the row reads No data, not Incomplete.
agentSilent?: boolean;
// When the Enrollment Status Page entered Account Setup; null when the session has no such split.
userPhaseBoundary?: UserPhaseBoundary | null;
}
@@ -47,7 +50,40 @@ function formatDuration(ms: number): string {
return `${hours}h ${remainingMinutes}m`;
}
export default function InstallProgress({ events, summaryStats, observedUntilMs = null, userPhaseBoundary = null }: InstallProgressProps) {
interface DetectionBadge {
label: string;
/** Label below `sm`; null hides the pill there — it then appears in the row's Details only. */
phoneLabel: string | null;
tone: string;
title: string;
}
// IME's check right after the install, or after the restart the installer requested (D-392).
// Any result other than Detected/NotDetected is IME's DetectedDifferentVersion.
function postInstallDetectionBadge(result: string, afterReboot: boolean | undefined): DetectionBadge {
const check = `Intune's detection check right after the ${afterReboot ? "restart the installer requested" : "installer finished"}`;
if (result === "Detected") {
// Only confirms what the check icon and the green row already say, so on a phone it yields
// its room to the app name instead of squeezing it to a few letters.
return { label: "Detected after install", phoneLabel: null, tone: "bg-green-100 text-green-700", title: `${check}: the app was found.` };
}
if (result === "NotDetected") {
return {
label: "Not detected after install",
phoneLabel: "Not detected",
tone: "bg-red-200 text-red-800",
title: `${check}: the app was not found, so Intune counts the install as failed (0x87D1041C).`,
};
}
return {
label: "Different version detected",
phoneLabel: "Other version",
tone: "bg-amber-200 text-amber-700",
title: `${check}: a different version was found; Intune counts the app as installed.`,
};
}
export default function InstallProgress({ events, summaryStats, observedUntilMs = null, agentSilent = false, userPhaseBoundary = null }: InstallProgressProps) {
// Legacy-agent guard: split off app events replayed from a previous enrollment's IME log
// (newer agents suppress them at the source) so week-old installs never render as current.
// office_*/realmjoin_* events never carry rejectedSourceTimestamp and pass through untouched.
@@ -118,6 +154,7 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
const installedFromSummary = summaryStats?.installed;
const userSplit = userPhaseSplitIndex(filteredInstalls, i => Date.parse(i.firstSeenAt), userPhaseBoundary);
const frozenText = frozenRowText("installing", agentSilent);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
@@ -181,9 +218,9 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still installing when the agent stopped observing — the outcome was never seen. Not a failure."
title={frozenText.countTitle}
>
{incompleteCount} incomplete
{incompleteCount} {frozenText.countLabel}
</span>
)}
{postponedCount > 0 && (
@@ -221,7 +258,7 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
{filteredInstalls.map((item, i) => (
<Fragment key={item.key}>
{userPhaseBoundary && i === userSplit && <PhaseDivider boundary={userPhaseBoundary} side="after" />}
<InstallItemRow item={item} />
<InstallItemRow item={item} agentSilent={agentSilent} />
</Fragment>
))}
{showPending && pendingNames.map((name) => (
@@ -240,8 +277,9 @@ export default function InstallProgress({ events, summaryStats, observedUntilMs
);
}
function InstallItemRow({ item }: { item: InstallItem }) {
function InstallItemRow({ item, agentSilent }: { item: InstallItem; agentSilent: boolean }) {
const [showDetails, setShowDetails] = useState(false);
const frozenText = frozenRowText("installing", agentSilent);
// Live 1s timer while this app is installing. The tick state carries the
// startedAt it was computed from so the read below can mask stale values from
// a previous activation — the effect only starts/stops the interval; the
@@ -263,6 +301,10 @@ function InstallItemRow({ item }: { item: InstallItem }) {
? liveElapsed.elapsedMs
: null;
// A failed row already says "Detection failed", so its NotDetected verdict is not repeated.
const detection = item.postInstallDetection && !(item.state === "Failed" && item.postInstallDetection === "NotDetected")
? postInstallDetectionBadge(item.postInstallDetection, item.postInstallAfterReboot)
: null;
// Session 080edee9 follow-up — only the genuine "no HRESULT available" timeout
// case wears the hedged orange treatment. Detection-failures and install-failures
@@ -343,29 +385,19 @@ function InstallItemRow({ item }: { item: InstallItem }) {
Uninstall
</span>
)}
{item.postInstallDetection && !(item.state === "Failed" && item.postInstallDetection === "NotDetected") && (
// IME's check right after the install (D-392); a failed row already says "Detection failed".
{detection && (
// The pill never shrinks (nowrap) while the name does, so on a phone a long label
// squeezed the app name to a few letters: short label there, or none at all.
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${
item.postInstallDetection === "Detected"
? "bg-green-100 text-green-700"
: item.postInstallDetection === "NotDetected"
? "bg-red-200 text-red-800"
: "bg-amber-200 text-amber-700"
}`}
title={`Intune's detection check right after the ${item.postInstallAfterReboot ? "restart the installer requested" : "installer finished"}: ${
item.postInstallDetection === "Detected"
? "the app was found."
: item.postInstallDetection === "NotDetected"
? "the app was not found, so Intune counts the install as failed (0x87D1041C)."
: "a different version was found; Intune counts the app as installed."
}`}
className={`text-xs px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${detection.tone}${detection.phoneLabel == null ? " hidden sm:inline" : ""}`}
title={detection.title}
>
{item.postInstallDetection === "Detected"
? "Detected after install"
: item.postInstallDetection === "NotDetected"
? "Not detected after install"
: "Different version detected"}
{detection.phoneLabel == null ? detection.label : (
<>
<span className="sm:hidden">{detection.phoneLabel}</span>
<span className="hidden sm:inline">{detection.label}</span>
</>
)}
</span>
)}
{item.state === "Failed" && item.isLikelyStuck && (
@@ -401,9 +433,9 @@ function InstallItemRow({ item }: { item: InstallItem }) {
{item.isIncomplete && (
<span
className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still installing when the agent stopped observing. The outcome is unknown — the install may well have finished after the session ended."
title={frozenText.rowTitle}
>
Incomplete
{frozenText.label}
</span>
)}
{item.state === "Preinstalled" && (
@@ -516,6 +548,15 @@ function InstallItemRow({ item }: { item: InstallItem }) {
</div>
) : null}
{/* Phones: the row header shows the detection result short or not at all, and touch has
no hover tooltip — Details carries the full verdict. */}
{showDetails && detection && (
<div className="sm:hidden mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className={`px-2 py-0.5 rounded-full font-medium whitespace-nowrap ${detection.tone}`}>{detection.label}</span>
<span className="text-gray-600">{detection.title}</span>
</div>
)}
{/* Event details (expandable) */}
{showDetails && item.eventData && (
<div className="mt-3 p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
@@ -22,6 +22,7 @@ import {
} from "@/lib/scriptExecutions";
import { lookupScriptDisplayName, type DisplayNamesByRefKey } from "@/lib/scriptDisplayNames";
import { getErrorCodeEntry, formatErrorCode, errorCodeTooltip } from "@/lib/errorCodeMap";
import { frozenRowText } from "@/lib/sessionStatus";
import { userPhaseSplitIndex, type UserPhaseBoundary } from "@/lib/userPhaseBoundary";
import PhaseDivider from "@/components/PhaseDivider";
@@ -53,14 +54,16 @@ interface ScriptExecutionsProps {
* stay distinct (rare in practice but never silently collide).
*/
displayNamesByRefKey?: DisplayNamesByRefKey;
// Epoch ms of the agent's last report, passed only once the session is terminal. A script
// still running at that point renders as Incomplete instead of ticking against the wall clock.
// Epoch ms of the agent's last report, passed once the session is terminal or the agent went
// silent. A script still running at that point stops ticking against the wall clock.
observedUntilMs?: number | null;
// The session has not ended, data just stopped arriving: the row reads No data, not Incomplete.
agentSilent?: boolean;
// When the Enrollment Status Page entered Account Setup; null when the session has no such split.
userPhaseBoundary?: UserPhaseBoundary | null;
}
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null, userPhaseBoundary = null }: ScriptExecutionsProps) {
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, observedUntilMs = null, agentSilent = false, userPhaseBoundary = null }: ScriptExecutionsProps) {
// Legacy-agent guard: split off script events replayed from a previous enrollment's IME
// log (newer agents suppress them at the source) so week-old runs never render as current
// executions. The muted note below keeps the gap explainable.
@@ -79,7 +82,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
// post-detection were non-compliant en route.
const runningCount = cards.filter(c => c.headerState === "Running").length;
// Still running when observation ended — unknown outcome, counted apart from "running"
// so a finished session never claims a script is executing.
// so a finished or silent session never claims a script is executing.
const incompleteCount = cards.filter(c => c.headerState === "Incomplete").length;
const successCount = cards.filter(c => c.headerState === "Success").length;
const nonCompliantCount = cards.filter(c => c.headerState === "NonCompliant").length;
@@ -88,6 +91,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
const remediationCount = cards.filter(c => c.scriptType === "remediation").length;
const userSplit = userPhaseSplitIndex(cards, c => Date.parse(c.timestamp), userPhaseBoundary);
const frozenText = frozenRowText("running", agentSilent);
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
@@ -110,9 +114,9 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
// Slate like the session-level Incomplete badge: unknown outcome, not a failure.
<span
className="px-2 py-0.5 rounded-full bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium"
title="Still running when the agent stopped observing — the outcome was never seen. Not a failure."
title={frozenText.countTitle}
>
{incompleteCount} incomplete
{incompleteCount} {frozenText.countLabel}
</span>
)}
{successCount > 0 && (
@@ -158,6 +162,7 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayNamesByRefKey={displayNamesByRefKey}
agentSilent={agentSilent}
/>
</Fragment>
))}
@@ -217,7 +222,7 @@ function cardStatusTextColor(state: ScriptCard["headerState"]): string {
* the parent header + nested phase rows under it; platform / single-phase remediation
* cards collapse the parent header into the single phase row (no nesting).
*/
function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey }: { card: ScriptCard; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; displayNamesByRefKey?: DisplayNamesByRefKey }) {
function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey, agentSilent }: { card: ScriptCard; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; displayNamesByRefKey?: DisplayNamesByRefKey; agentSilent: boolean }) {
// Cycles default to COLLAPSED so a session with many remediation policies stays
// scannable — the header carries the cycle-level outcome which is the at-a-glance
// signal the user wants first. Click the chevron to drill into per-phase details.
@@ -231,6 +236,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayName={lookupScriptDisplayName(displayNamesByRefKey, card.phases[0].scriptType, card.phases[0].policyId)}
agentSilent={agentSilent}
/>
);
}
@@ -255,6 +261,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
// ("Reported after"), never here.
const cardDurationLabel = formatScriptDuration(card.durationSeconds);
const isCardSlow = card.durationSeconds != null && card.durationSeconds >= STALE_RUNNING_THRESHOLD_SECONDS;
const frozenText = card.headerState === "Incomplete" ? frozenRowText("running", agentSilent) : null;
return (
<div className={`rounded-lg ${containerClass}`}>
@@ -284,7 +291,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
)}
</div>
<div className="flex items-center space-x-3 text-xs flex-shrink-0 ml-2">
<span className={`font-medium ${statusColor}`}>{card.headerLabel}</span>
<span className={`font-medium ${statusColor}`} title={frozenText?.rowTitle}>{frozenText?.label ?? card.headerLabel}</span>
{cardDurationLabel && (
<span
className={`font-mono ${isCardSlow ? "text-amber-600 font-medium" : "text-gray-500"}`}
@@ -309,6 +316,7 @@ function ScriptCardView({ card, showScriptOutput, latestBootstrapVersion, displa
showScriptOutput={showScriptOutput}
latestBootstrapVersion={latestBootstrapVersion}
displayName={lookupScriptDisplayName(displayNamesByRefKey, phase.scriptType, phase.policyId)}
agentSilent={agentSilent}
nested
/>
))}
@@ -356,7 +364,7 @@ function CardIcon({ state, className }: { state: ScriptCard["headerState"]; clas
);
}
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, displayName }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; nested?: boolean; displayName?: string | null }) {
function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested, displayName, agentSilent }: { item: ScriptItem; showScriptOutput?: boolean; latestBootstrapVersion?: string | null; nested?: boolean; displayName?: string | null; agentSilent: boolean }) {
const [showDetails, setShowDetails] = useState(false);
// Re-render every 5s while live in Running state so the elapsed time updates; an incomplete
// placeholder is frozen at the observation end and never ticks.
@@ -399,9 +407,10 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
const intuneUrl = getIntuneScriptUrl(item.policyId, item.scriptType);
// Status text for the right-hand summary cell
const frozenText = frozenRowText("running", agentSilent);
let statusText: string;
if (item.isIncomplete) {
statusText = "Incomplete";
statusText = frozenText.label;
} else if (item.state === "Running") {
statusText = isStale ? `Running (${elapsedSeconds}s — stuck?)` : `Running (${elapsedSeconds}s)`;
} else if (item.scriptType === "remediation" && item.complianceResult) {
@@ -515,7 +524,7 @@ function ScriptItemRow({ item, showScriptOutput, latestBootstrapVersion, nested,
: isNonCompliant ? "text-amber-700"
: "text-green-600"
}`}
title={item.isIncomplete ? "Still running when the agent stopped observing. The outcome is unknown — the script may well have finished after the session ended." : undefined}
title={item.isIncomplete ? frozenText.rowTitle : undefined}
>
{statusText}
</span>
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import InstallProgress from "../InstallProgress";
import type { InstallEvent } from "@/lib/installProgress";
function evt(eventType: string, timestamp: string, data: Record<string, unknown>): InstallEvent {
return { eventType, timestamp, data };
}
function renderWithCheckAfterInstall(postInstallDetection: string): string {
return renderToStaticMarkup(
createElement(InstallProgress, {
events: [
evt("app_install_started", "2026-10-21T16:40:00Z", { appName: "Contoso VPN" }),
evt("app_install_completed", "2026-10-21T16:42:39Z", { appName: "Contoso VPN", state: "Installed" }),
evt("app_post_install_detection", "2026-10-21T16:42:41Z", { appName: "Contoso VPN", postInstallDetection, afterReboot: "false" }),
],
}),
);
}
describe("InstallProgress — detection pill on narrow screens", () => {
it("hides the green confirmation below sm so the app name keeps its room", () => {
const html = renderWithCheckAfterInstall("Detected");
expect(html).toContain("bg-green-100 text-green-700 hidden sm:inline");
expect(html).toContain(">Detected after install</span>");
expect(html).not.toContain('class="sm:hidden"');
});
it("keeps the red and amber verdicts on phones, with a short label", () => {
const notDetected = renderWithCheckAfterInstall("NotDetected");
expect(notDetected).toContain('<span class="sm:hidden">Not detected</span>');
expect(notDetected).toContain('<span class="hidden sm:inline">Not detected after install</span>');
const otherVersion = renderWithCheckAfterInstall("DetectedDifferentVersion");
expect(otherVersion).toContain('<span class="sm:hidden">Other version</span>');
expect(otherVersion).toContain('<span class="hidden sm:inline">Different version detected</span>');
});
});
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { isTerminalStatus } from "../../lib/sessionStatus";
import {
AGENT_SILENCE_THRESHOLD_MS,
frozenRowText,
isAgentSilent,
isTerminalStatus,
nextSilenceCheckMs,
} from "../../lib/sessionStatus";
describe("isTerminalStatus", () => {
it("returns true for terminal statuses", () => {
@@ -25,3 +31,71 @@ describe("isTerminalStatus", () => {
expect(isTerminalStatus("FAILED")).toBe(false);
});
});
describe("isAgentSilent", () => {
const NOW = Date.UTC(2026, 10, 2, 8, 0, 0);
const quietFor = (ms: number) => NOW - ms;
it.each([
["InProgress", AGENT_SILENCE_THRESHOLD_MS - 1, false],
["InProgress", AGENT_SILENCE_THRESHOLD_MS, true],
["Pending", AGENT_SILENCE_THRESHOLD_MS, true],
["AwaitingUser", AGENT_SILENCE_THRESHOLD_MS, true],
["Unknown", AGENT_SILENCE_THRESHOLD_MS, true],
["Stalled", AGENT_SILENCE_THRESHOLD_MS, true],
// An agent that reported the stall itself is still talking.
["Stalled", 60_000, false],
["Succeeded", AGENT_SILENCE_THRESHOLD_MS * 10, false],
["Failed", AGENT_SILENCE_THRESHOLD_MS * 10, false],
["Incomplete", AGENT_SILENCE_THRESHOLD_MS * 10, false],
])("%s, quiet for %i ms: %s", (status, quiet, expected) => {
expect(isAgentSilent(status, quietFor(quiet), NOW)).toBe(expected);
});
it("without an ingest time only a Stalled session counts", () => {
expect(isAgentSilent("Stalled", null, NOW)).toBe(true);
expect(isAgentSilent("InProgress", null, NOW)).toBe(false);
expect(isAgentSilent(undefined, null, NOW)).toBe(false);
expect(isAgentSilent("Succeeded", null, NOW)).toBe(false);
});
});
describe("nextSilenceCheckMs", () => {
const LAST_INGEST = Date.UTC(2026, 10, 2, 8, 0, 0);
it("keeps the old clock while the answer stays the same, so a quiet page does not render", () => {
const before = LAST_INGEST + 60_000;
expect(nextSilenceCheckMs(before, before + 30_000, "InProgress", LAST_INGEST)).toBe(before);
const silent = LAST_INGEST + AGENT_SILENCE_THRESHOLD_MS;
expect(nextSilenceCheckMs(silent, silent + 30_000, "InProgress", LAST_INGEST)).toBe(silent);
});
it("moves the clock when the answer flips", () => {
const before = LAST_INGEST + AGENT_SILENCE_THRESHOLD_MS - 1;
const after = LAST_INGEST + AGENT_SILENCE_THRESHOLD_MS;
expect(nextSilenceCheckMs(before, after, "InProgress", LAST_INGEST)).toBe(after);
});
});
describe("frozenRowText", () => {
it("a session that ended keeps its wording", () => {
const text = frozenRowText("installing", false);
expect(text.label).toBe("Incomplete");
expect(text.countLabel).toBe("incomplete");
expect(text.countTitle).toBe("Still installing when the agent stopped observing — the outcome was never seen. Not a failure.");
expect(text.rowTitle).toBe("Still installing when the agent stopped observing. The outcome is unknown — the install may well have finished after the session ended.");
});
it.each([
["installing", "install"],
["downloading", "download"],
["running", "script"],
] as const)("a silent agent reads as no data: %s", (activity, subject) => {
const text = frozenRowText(activity, true);
expect(text.label).toBe("No data");
expect(text.countLabel).toBe("no data");
expect(text.countTitle).toBe(`Still ${activity} at the agent's last report. No data has arrived since, so the current state is unknown. Not a failure.`);
expect(text.rowTitle).toBe(`Still ${activity} at the agent's last report. No data has arrived since. The ${subject} may well have finished.`);
});
});
@@ -372,8 +372,9 @@ export function buildInstallItems(events: InstallEvent[]): InstallItem[] {
/**
* Marks rows that were still installing when observation ended. `observedUntilMs` is the last
* moment the agent reported anything (session `lastEventAt`) and must be passed only once the
* session is terminal — `null` means "still live", and every row comes back untouched so the
* panel keeps its live timer. A start after the observation end (clock skew) clamps to 0.
* session is terminal or its agent has gone silent — `null` means "still live", and every row
* comes back untouched so the panel keeps its live timer. A start after the observation end
* (clock skew) clamps to 0.
*/
export function applyObservationEnd(items: InstallItem[], observedUntilMs: number | null): InstallItem[] {
if (observedUntilMs == null) return items;
@@ -598,9 +598,9 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
/**
* Marks placeholders that were still running when observation ended. `observedUntilMs` is the
* last moment the agent reported anything (the session's lastEventAt) and is only passed once
* the session is terminal; null (live session) leaves every row untouched. Mirrors the install
* panel's applyObservationEnd: no timer runs on such a row, and `observedMs` is the watched
* span from the start line — a lower bound, never the run time.
* the session is terminal or its agent has gone silent; null (live session) leaves every row
* untouched. Mirrors the install panel's applyObservationEnd: no timer runs on such a row, and
* `observedMs` is the watched span from the start line — a lower bound, never the run time.
*/
export function applyObservationEnd(items: ScriptItem[], observedUntilMs: number | null): ScriptItem[] {
if (observedUntilMs == null) return items;
@@ -10,3 +10,59 @@
export function isTerminalStatus(status: string | null | undefined): boolean {
return status === "Succeeded" || status === "Failed" || status === "Incomplete";
}
// How long an unfinished session may stay quiet before its rows stop claiming to be live. A live
// agent reports every 30 s while anything happens; its periodic collectors stop after 15 min without
// a real event, and an OS-update restart was quiet for 12 min in the field.
export const AGENT_SILENCE_THRESHOLD_MS = 20 * 60 * 1000;
/**
* True when nothing has arrived from the agent of an unfinished session for the threshold. Measured
* on the server clock: device timestamps can be hours off. A Stalled session counts without a
* timestamp, the sweep has already seen the silence. An agent that reported the stall itself is
* still talking, so its app row keeps its timer until it goes quiet too.
*/
export function isAgentSilent(
status: string | null | undefined,
lastIngestAtMs: number | null,
nowMs: number,
): boolean {
if (isTerminalStatus(status)) return false;
if (lastIngestAtMs == null) return status === "Stalled";
return nowMs - lastIngestAtMs >= AGENT_SILENCE_THRESHOLD_MS;
}
/** The clock value to keep after a check: the old one unless the answer flips, so a quiet page does not render. */
export function nextSilenceCheckMs(
previousMs: number,
nowMs: number,
status: string | null | undefined,
lastIngestAtMs: number | null,
): number {
return isAgentSilent(status, lastIngestAtMs, previousMs) === isAgentSilent(status, lastIngestAtMs, nowMs)
? previousMs
: nowMs;
}
const FROZEN_ROW_SUBJECT = { installing: "install", downloading: "download", running: "script" } as const;
/**
* The words for a row frozen at the agent's last report. `agentSilent`: the session has not ended and
* data may still come. Otherwise the session ended without the outcome.
*/
export function frozenRowText(activity: keyof typeof FROZEN_ROW_SUBJECT, agentSilent: boolean) {
const subject = FROZEN_ROW_SUBJECT[activity];
return agentSilent
? {
label: "No data",
countLabel: "no data",
countTitle: `Still ${activity} at the agent's last report. No data has arrived since, so the current state is unknown. Not a failure.`,
rowTitle: `Still ${activity} at the agent's last report. No data has arrived since. The ${subject} may well have finished.`,
}
: {
label: "Incomplete",
countLabel: "incomplete",
countTitle: `Still ${activity} when the agent stopped observing — the outcome was never seen. Not a failure.`,
rowTitle: `Still ${activity} when the agent stopped observing. The outcome is unknown — the ${subject} may well have finished after the session ended.`,
};
}