mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-11-03 00:53:30 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43b3008266 | |||
| 2304436b8c | |||
| ad875d4021 | |||
| fd6b68231e | |||
| 88494a312f | |||
| 6901f6f682 | |||
| 82c0b20be1 | |||
| 4fa439cc3c | |||
| b631c63c53 | |||
| e9aaf3db53 | |||
| a37930e87a | |||
| 6bc5d47afe | |||
| 0ebbf87e5b |
+1
-1
Submodule .claude/commands updated: c5604145b3...d937abd228
+1
-1
Submodule internal updated: 2609b9f3e5...af17a42883
+277
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+187
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -1709,7 +1709,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
_allAppsCompletedFired = true;
|
||||
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
|
||||
OnAllAppsCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
-17
@@ -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.
|
||||
|
||||
+29
@@ -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
|
||||
|
||||
+15
-12
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+127
-6
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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}
|
||||
/>
|
||||
|
||||
+22
-16
@@ -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's IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode "Group (2)"; 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's IP and MAC address, so devices on the same local network peer with each other. Takes effect with DO Download Mode "Group (2)"; 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({
|
||||
|
||||
+23
-20
@@ -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"
|
||||
}`}
|
||||
+1
-2
@@ -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>
|
||||
|
||||
+1
-2
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user